@anja.codes/vitakit 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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/check.d.mts +22 -0
- package/dist/check.mjs +196 -0
- package/dist/cli.d.mts +45 -0
- package/dist/cli.mjs +225 -0
- package/dist/index.d.mts +398 -0
- package/dist/index.mjs +21 -0
- package/dist/nuxt.d.mts +15 -0
- package/dist/nuxt.mjs +98 -0
- package/dist/runtime/components/VitaDate.vue +20 -0
- package/dist/runtime/components/VitaEntry.vue +24 -0
- package/dist/runtime/components/VitaIssues.vue +25 -0
- package/dist/runtime/components/VitaLinks.vue +19 -0
- package/dist/runtime/components/VitaMarkdown.vue +14 -0
- package/dist/runtime/components/VitaPublication.vue +30 -0
- package/dist/runtime/components/VitaPublications.vue +39 -0
- package/dist/runtime/components/VitaSection.vue +60 -0
- package/dist/runtime/composables/useVitakit.ts +47 -0
- package/dist/schemas/education.ts +21 -0
- package/dist/schemas/employment.ts +21 -0
- package/dist/schemas/publications.ts +48 -0
- package/dist/schemas/supervision.ts +20 -0
- package/dist/schemas/talks.ts +19 -0
- package/dist/schemas/teaching.ts +19 -0
- package/dist/shared/index.d.mts +68 -0
- package/dist/shared/index.mjs +57 -0
- package/dist/shared/vitakit.BRhjqpAt.mjs +250 -0
- package/dist/shared/vitakit.BlU-p01e.mjs +14 -0
- package/dist/shared/vitakit.BxcnlFGP.mjs +325 -0
- package/dist/shared/vitakit.CypCF-4Q.mjs +503 -0
- package/dist/shared/vitakit.D8WRg_Xx.d.mts +153 -0
- package/dist/styles/vitakit-math.csl +314 -0
- package/dist/tex/templates/_generic.eta +6 -0
- package/dist/tex/templates/education.eta +9 -0
- package/dist/tex/templates/employment.eta +9 -0
- package/dist/tex/templates/publications.eta +7 -0
- package/dist/tex/templates/supervision.eta +4 -0
- package/dist/tex/templates/talks.eta +3 -0
- package/dist/tex/templates/teaching.eta +6 -0
- package/dist/tex/vitakit-cv.cls +119 -0
- package/dist/tex.d.mts +93 -0
- package/dist/tex.mjs +129 -0
- package/package.json +121 -0
- package/src/bin/vitakit-build-cv.mjs +50 -0
- package/src/bin/vitakit-check.mjs +57 -0
- package/src/bin/vitakit.mjs +158 -0
- package/styles/vitakit-math.csl +314 -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
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @anja.codes/vitakit
|
|
2
|
+
|
|
3
|
+
Structured CV data in, academic website and LaTeX CV out.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm create @anja.codes/vitakit my-site
|
|
7
|
+
cd my-site && pnpm install
|
|
8
|
+
pnpm dev # the website
|
|
9
|
+
pnpm build:cv # cv-tex/cv.pdf, from the same data
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
To add it to an existing Nuxt project instead:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// nuxt.config.ts
|
|
16
|
+
export default defineNuxtConfig({
|
|
17
|
+
modules: ['@anja.codes/vitakit/nuxt'],
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Documentation
|
|
22
|
+
|
|
23
|
+
[Getting started](./docs/getting-started.md) ·
|
|
24
|
+
[Content reference](./docs/content-reference.md) ·
|
|
25
|
+
[Commands](./docs/cli.md) ·
|
|
26
|
+
[Customising](./docs/customising.md) ·
|
|
27
|
+
[CV documents](./docs/cv-documents.md) ·
|
|
28
|
+
[Privacy](./docs/privacy.md) ·
|
|
29
|
+
[Troubleshooting](./docs/troubleshooting.md)
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
`content/` is compiled at build time into a typed module. Markdown and TeX are
|
|
34
|
+
rendered with remark and KaTeX, BibTeX with citation-js, and every file is
|
|
35
|
+
validated with Zod. Nothing content-related reaches the browser: no Markdown
|
|
36
|
+
parser, no content database, no KaTeX runtime.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
content/
|
|
40
|
+
profile.yml name, affiliation, links, navigation, footer
|
|
41
|
+
<section>.yml education, employment, teaching, talks, …
|
|
42
|
+
publications.bib grouped, linked reference list
|
|
43
|
+
publications.csl optional: its citation style on the website
|
|
44
|
+
pages/*.md prose pages, math and images included
|
|
45
|
+
schemas/<section>.ts optional: change a built-in schema, or add your own
|
|
46
|
+
schemas/publications.ts optional: publication groups, badges, fields
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Composables
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `useSection(id)` | validated, sorted, visibility-filtered entries |
|
|
54
|
+
| `usePublications()` | reference groups; each entry has its formatted `html` and its `badges` as data |
|
|
55
|
+
| `usePage(route)`, `usePages()` | compiled Markdown pages |
|
|
56
|
+
| `useProfile()` | everything in `profile.yml`, including `nav` |
|
|
57
|
+
| `useVitakit()` | everything, including `issues` |
|
|
58
|
+
|
|
59
|
+
## Components
|
|
60
|
+
|
|
61
|
+
`VitaSection`, `VitaEntry`, `VitaDate`, `VitaLinks`, `VitaPublications`,
|
|
62
|
+
`VitaPublication`, `VitaMarkdown`, `VitaIssues`. All are thin: they take props and emit markup, so
|
|
63
|
+
ejecting one into your own repo leaves nothing behind that can silently break.
|
|
64
|
+
|
|
65
|
+
## Sections
|
|
66
|
+
|
|
67
|
+
A file `content/<name>.yml` is a section. Its schema is found by convention:
|
|
68
|
+
`schemas/<name>.ts` in your repo wins, otherwise the built-in schema for
|
|
69
|
+
`<name>` is used, otherwise the build fails with a starter snippet.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// schemas/outreach.ts
|
|
73
|
+
import { section, dateRange, richText, institution } from '@anja.codes/vitakit'
|
|
74
|
+
|
|
75
|
+
export default section({
|
|
76
|
+
date: dateRange,
|
|
77
|
+
title: richText, // Markdown and $math$ allowed
|
|
78
|
+
venue: institution.optional(),
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every entry gets `visibility: public | cv | private`. The website loader is
|
|
83
|
+
hard-wired to `public`, so a phone number, an address or a referee marked `cv`
|
|
84
|
+
cannot reach the site; the other levels exist for the LaTeX side. Assume the
|
|
85
|
+
repository itself is private, and let `vitakit-check` verify the build output.
|
|
86
|
+
|
|
87
|
+
PDFs are never part of the website build: `build:cv` writes to `cv-tex/`, and
|
|
88
|
+
the website renders CV information directly from the same data.
|
|
89
|
+
|
|
90
|
+
## Strict builds, forgiving development
|
|
91
|
+
|
|
92
|
+
A broken file fails `nuxt generate`, naming the file, the entry and the field.
|
|
93
|
+
The same problem during `nuxt dev` is reported instead of thrown: the page shows
|
|
94
|
+
its error, `VitaIssues` lists all of them, and saving a fix recovers in place —
|
|
95
|
+
a typo never takes down the dev server.
|
|
96
|
+
|
|
97
|
+
## Status
|
|
98
|
+
|
|
99
|
+
Early but complete in both halves: website and PDF are produced from the same
|
|
100
|
+
content, with strict builds, forgiving development, output assertions and a
|
|
101
|
+
scaffolder whose output is regenerated and tested in CI.
|
|
102
|
+
|
|
103
|
+
Not yet implemented: cover-letter helpers (`\vitacount`, `\vitafirst`), a
|
|
104
|
+
`schema` command to draft a schema from existing YAML, a second theme, and a
|
|
105
|
+
Typst target.
|
|
106
|
+
|
|
107
|
+
MIT
|
|
108
|
+
|
|
109
|
+
## LaTeX CV
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pnpm build:cv # every document in cv-tex/
|
|
113
|
+
pnpm build:cv cv # one document
|
|
114
|
+
pnpm build:cv --no-pdf # only generate .tex
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A **document** is a `.tex` file you own. vitakit renders `content/` into
|
|
118
|
+
`cv-tex/generated/<document>/`, one file per section, and `latexmk` typesets it.
|
|
119
|
+
No `-shell-escape`, no Python.
|
|
120
|
+
|
|
121
|
+
```tex
|
|
122
|
+
\documentclass{vitakit-cv}
|
|
123
|
+
\vitadocument{cv}
|
|
124
|
+
\addbibresource{../content/publications.bib}
|
|
125
|
+
\input{preamble.tex}
|
|
126
|
+
\input{generated/cv/profile.tex}
|
|
127
|
+
|
|
128
|
+
\begin{document}
|
|
129
|
+
\vitaheader
|
|
130
|
+
\vitasection{employment} % order lives here: move a line, move a section
|
|
131
|
+
\vitasection{education}
|
|
132
|
+
\section{Publications}\vitainput{publications}
|
|
133
|
+
\end{document}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An optional `cv-tex/<name>.yml` decides *what* goes in:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
visibility: cv # include public and cv entries, not private
|
|
140
|
+
sections:
|
|
141
|
+
talks:
|
|
142
|
+
where: { kind: invited }
|
|
143
|
+
limit: 10
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Copy `cv.tex` to make a variant; a document is just a file. Generated files use
|
|
147
|
+
only the macros in `vitakit-cv.cls` (`\vitaentry`, `\vitanote`, `\vitasection`),
|
|
148
|
+
so redefining those in `preamble.tex` restyles everything. Any template can be
|
|
149
|
+
overridden by putting a file of the same name in `cv-tex/templates/`.
|
|
150
|
+
|
|
151
|
+
## Starting a project
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pnpm create @anja.codes/vitakit my-site
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
See [`@anja.codes/create-vitakit`](./create). The scaffolded output is kept in
|
|
158
|
+
[`examples/scaffolded`](./examples/scaffolded), regenerated by
|
|
159
|
+
`pnpm example` and verified in CI, so it cannot drift from the package.
|
package/dist/check.d.mts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface CheckOptions {
|
|
2
|
+
rootDir: string;
|
|
3
|
+
/** Generated site; defaults to `.output/public`. */
|
|
4
|
+
outDir?: string;
|
|
5
|
+
baseURL?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Skip the leak check. It compares text of non-public entries against the
|
|
8
|
+
* generated pages, which is a heuristic: a title or institution that is
|
|
9
|
+
* legitimately public elsewhere looks like a leak. Turn it off rather than
|
|
10
|
+
* rewording content to satisfy it.
|
|
11
|
+
*/
|
|
12
|
+
skipLeakCheck?: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface CheckResult {
|
|
15
|
+
/** Human-readable summary lines for what was verified. */
|
|
16
|
+
checked: string[];
|
|
17
|
+
failures: string[];
|
|
18
|
+
}
|
|
19
|
+
declare function checkStatic(options: CheckOptions): Promise<CheckResult>;
|
|
20
|
+
|
|
21
|
+
export { checkStatic };
|
|
22
|
+
export type { CheckOptions, CheckResult };
|
package/dist/check.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFile, stat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { l as loadContent } from './shared/vitakit.CypCF-4Q.mjs';
|
|
4
|
+
import { M as MARKER_KEY, k as atMost } from './shared/vitakit.BxcnlFGP.mjs';
|
|
5
|
+
import { r as richSource, a as richHtml } from './shared/vitakit.BlU-p01e.mjs';
|
|
6
|
+
import 'gray-matter';
|
|
7
|
+
import 'yaml';
|
|
8
|
+
import 'unified';
|
|
9
|
+
import 'remark-parse';
|
|
10
|
+
import 'remark-gfm';
|
|
11
|
+
import 'remark-math';
|
|
12
|
+
import 'remark-rehype';
|
|
13
|
+
import 'rehype-katex';
|
|
14
|
+
import 'rehype-raw';
|
|
15
|
+
import 'rehype-stringify';
|
|
16
|
+
import 'unist-util-visit';
|
|
17
|
+
import 'node:fs';
|
|
18
|
+
import '@behackl/citation-js-extras';
|
|
19
|
+
import 'rehype-parse';
|
|
20
|
+
import 'rehype-sanitize';
|
|
21
|
+
import 'zod';
|
|
22
|
+
import 'node:url';
|
|
23
|
+
|
|
24
|
+
async function exists(path) {
|
|
25
|
+
return stat(path).then(() => true, () => false);
|
|
26
|
+
}
|
|
27
|
+
async function htmlFiles(dir) {
|
|
28
|
+
const found = [];
|
|
29
|
+
for (const item of await readdir(dir, { withFileTypes: true })) {
|
|
30
|
+
const path = join(dir, item.name);
|
|
31
|
+
if (item.isDirectory()) found.push(...await htmlFiles(path));
|
|
32
|
+
else if (item.name.endsWith(".html")) found.push(path);
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
const IDENTIFYING = ["title", "position", "degree", "student", "event", "thesis", "project"];
|
|
37
|
+
function identifyingText(entry) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
const add = (value) => {
|
|
40
|
+
const source = richSource(value);
|
|
41
|
+
if (source) {
|
|
42
|
+
parts.push(source);
|
|
43
|
+
const html = richHtml(value);
|
|
44
|
+
if (html && html !== source) parts.push(html);
|
|
45
|
+
const prose = source.split(/\$\$[\s\S]*?\$\$|\$(?:\\.|[^$\\])*\$/);
|
|
46
|
+
if (prose.length > 1) parts.push(...prose.filter((part) => part.trim().length >= 20));
|
|
47
|
+
} else if (typeof value === "object" && value !== null && "title" in value) {
|
|
48
|
+
add(value.title);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
for (const field of IDENTIFYING) add(entry[field]);
|
|
52
|
+
return [...new Set(parts.map((part) => normaliseText(part)))].filter((part) => part.length >= 12);
|
|
53
|
+
}
|
|
54
|
+
const isRootRelative = (value) => value.startsWith("/") && !value.startsWith("//");
|
|
55
|
+
const underBase = (path, base) => path.startsWith(base) || `${path}/` === base;
|
|
56
|
+
function assetReferences(html) {
|
|
57
|
+
const found = [];
|
|
58
|
+
for (const [tag] of html.matchAll(/<(?:script|link)\b[^>]*>/gi)) {
|
|
59
|
+
const value = /\s(?:src|href)\s*=\s*(["'])(.*?)\1/i.exec(tag)?.[2];
|
|
60
|
+
if (value && isRootRelative(value)) found.push(value);
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
function urlReferences(html) {
|
|
65
|
+
const found = [];
|
|
66
|
+
for (const match of html.matchAll(/\s(src|href|poster|data|action|formaction|srcset)\s*=\s*(["'])(.*?)\2/gi)) {
|
|
67
|
+
const values = match[1].toLowerCase() === "srcset" ? match[3].split(/\s*,\s+|\s*,(?=\/)/).map((candidate) => candidate.trim().split(/\s+/)[0]) : [match[3]];
|
|
68
|
+
found.push(...values.filter(isRootRelative));
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
async function existsInSite(outDir, path) {
|
|
73
|
+
let decoded;
|
|
74
|
+
try {
|
|
75
|
+
decoded = decodeURIComponent(path.replace(/[?#].*$/, ""));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const target = join(outDir, decoded);
|
|
80
|
+
if (relative(outDir, target).startsWith("..")) return false;
|
|
81
|
+
const found = await stat(target).catch(() => void 0);
|
|
82
|
+
if (found?.isFile()) return true;
|
|
83
|
+
return found?.isDirectory() === true && (await stat(join(target, "index.html")).catch(() => void 0))?.isFile() === true;
|
|
84
|
+
}
|
|
85
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
86
|
+
function decodeEntity(reference, name) {
|
|
87
|
+
if (name[0] === "#") {
|
|
88
|
+
const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
89
|
+
return Number.isInteger(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : reference;
|
|
90
|
+
}
|
|
91
|
+
return ENTITIES[name] ?? reference;
|
|
92
|
+
}
|
|
93
|
+
function normaliseText(html) {
|
|
94
|
+
return html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, decodeEntity).replace(/\s+/g, " ").trim();
|
|
95
|
+
}
|
|
96
|
+
async function checkStatic(options) {
|
|
97
|
+
const outDir = join(options.rootDir, options.outDir ?? ".output/public");
|
|
98
|
+
const base = options.baseURL ?? process.env.NUXT_APP_BASE_URL ?? "/";
|
|
99
|
+
const checked = [];
|
|
100
|
+
const failures = [];
|
|
101
|
+
const fail = (message) => failures.push(message);
|
|
102
|
+
if (!await exists(outDir)) {
|
|
103
|
+
return { checked, failures: [`no generated site at ${outDir}; run \`nuxt generate\` first`] };
|
|
104
|
+
}
|
|
105
|
+
let content;
|
|
106
|
+
try {
|
|
107
|
+
content = await loadContent(options.rootDir, { strict: true, baseURL: base, maxVisibility: "private" });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return { checked, failures: [`content did not load: ${error instanceof Error ? error.message : String(error)}`] };
|
|
110
|
+
}
|
|
111
|
+
const files = await htmlFiles(outDir);
|
|
112
|
+
const pages = /* @__PURE__ */ new Map();
|
|
113
|
+
for (const file of files) pages.set(file, await readFile(file, "utf8"));
|
|
114
|
+
for (const page of content.pages) {
|
|
115
|
+
const file = join(outDir, page.route === "/" ? "index.html" : `${page.route.slice(1)}index.html`);
|
|
116
|
+
if (!await exists(file)) fail(`page ${page.route} was not generated (${file})`);
|
|
117
|
+
}
|
|
118
|
+
checked.push(`${content.pages.length} page(s) generated`);
|
|
119
|
+
let mathPages = 0;
|
|
120
|
+
for (const [file, html] of pages) {
|
|
121
|
+
if (html.includes("katex-error")) fail(`${file}: KaTeX reported an error`);
|
|
122
|
+
if (html.includes('class="katex')) mathPages += 1;
|
|
123
|
+
if (html.includes(MARKER_KEY)) fail(`${file}: an unrendered rich-text marker reached the output`);
|
|
124
|
+
}
|
|
125
|
+
checked.push(`mathematics rendered on ${mathPages} page(s)`);
|
|
126
|
+
const assets = [...pages.values()].flatMap(assetReferences);
|
|
127
|
+
const strayAssets = assets.filter((path) => !underBase(path, base));
|
|
128
|
+
const recorded = [...pages.values()].map((html) => /\bapp:\{baseURL:"([^"]*)"/.exec(html)?.[1]).find((value) => value !== void 0 && value !== base);
|
|
129
|
+
const builtForBase = !strayAssets.length && recorded === void 0;
|
|
130
|
+
if (!builtForBase) {
|
|
131
|
+
const evidence = recorded === void 0 ? `e.g. ${strayAssets[0]}` : `it was built for ${recorded}`;
|
|
132
|
+
fail(
|
|
133
|
+
`assets do not use the base path ${base} (${evidence}); the site was built for a different base. Set NUXT_APP_BASE_URL to the same value for \`nuxt generate\` and for this check.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
checked.push(`base path ${base}`);
|
|
137
|
+
if (!options.skipLeakCheck) {
|
|
138
|
+
const text = new Map([...pages].map(([file, html]) => [file, normaliseText(html)]));
|
|
139
|
+
let hidden = 0;
|
|
140
|
+
let leaked = 0;
|
|
141
|
+
for (const [id, entries] of Object.entries(content.sections)) {
|
|
142
|
+
for (const [index, entry] of entries.entries()) {
|
|
143
|
+
if (atMost(entry.visibility, "public")) continue;
|
|
144
|
+
hidden += 1;
|
|
145
|
+
const fragments = identifyingText(entry);
|
|
146
|
+
let found = false;
|
|
147
|
+
for (const [file, body] of text) {
|
|
148
|
+
const fragment = fragments.find((candidate) => body.includes(candidate));
|
|
149
|
+
if (fragment === void 0) continue;
|
|
150
|
+
found = true;
|
|
151
|
+
fail(
|
|
152
|
+
`content/${id}.yml entry ${index + 1} is marked "${entry.visibility}" but its text appears in ${relative(outDir, file)}: "${fragment.slice(0, 60)}${fragment.length > 60 ? "\u2026" : ""}".
|
|
153
|
+
Either raise the entry's visibility or stop rendering it on that page (pass --no-leak-check if the match is a coincidence).`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (found) leaked += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
checked.push(`${hidden - leaked} of ${hidden} non-public entr(ies) absent from the site`);
|
|
160
|
+
}
|
|
161
|
+
if (base !== "/" && builtForBase) {
|
|
162
|
+
const escaped = /* @__PURE__ */ new Set();
|
|
163
|
+
for (const [file, html] of pages) {
|
|
164
|
+
for (const path of urlReferences(html)) {
|
|
165
|
+
if (!underBase(path, base)) escaped.add(`${relative(outDir, file)}: ${path}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const problem of escaped) {
|
|
169
|
+
fail(`${problem} does not use the base path ${base}; in a component, use <NuxtLink> or prefix it with useRuntimeConfig().app.baseURL`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
checked.push(`assets and links use ${base}`);
|
|
173
|
+
if (builtForBase) {
|
|
174
|
+
const missing = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const [file, html] of pages) {
|
|
176
|
+
for (const path of urlReferences(html)) {
|
|
177
|
+
if (!underBase(path, base) || missing.has(path)) continue;
|
|
178
|
+
if (!await existsInSite(outDir, path.slice(base.length))) missing.set(path, relative(outDir, file));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const [path, file] of missing) {
|
|
182
|
+
fail(`${file}: ${path} leads nowhere: no such file or page in the generated site. A file belongs in public/, a page in content/pages/ or app/pages/`);
|
|
183
|
+
}
|
|
184
|
+
checked.push("links into the site lead somewhere");
|
|
185
|
+
}
|
|
186
|
+
if (content.publications.length) {
|
|
187
|
+
const bibPages = [...pages].filter(([, html]) => html.includes("vita-bib"));
|
|
188
|
+
for (const [file, html] of bibPages) {
|
|
189
|
+
if (/bibmathplaceholderx*\d+end/i.test(html)) fail(`${relative(outDir, file)}: a formula in the bibliography was not rendered`);
|
|
190
|
+
}
|
|
191
|
+
checked.push(bibPages.length ? `bibliography on ${bibPages.length} page(s)` : "bibliography not shown on any page (fine if publications.bib is only for the PDF)");
|
|
192
|
+
}
|
|
193
|
+
return { checked, failures };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { checkStatic };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
interface AddOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Also scaffold `app/components/<Name>Section.vue`. On by default: a section
|
|
4
|
+
* without a component renders through the generic one, which is a fallback
|
|
5
|
+
* rather than a design.
|
|
6
|
+
*/
|
|
7
|
+
component?: boolean;
|
|
8
|
+
}
|
|
9
|
+
interface WrittenFile {
|
|
10
|
+
path: string;
|
|
11
|
+
/** Why it was written, for the command's output. */
|
|
12
|
+
note: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create what is missing for a section: the YAML file, the schema, optionally
|
|
16
|
+
* a component. Existing files are never touched.
|
|
17
|
+
*/
|
|
18
|
+
declare function addSection(id: string, rootDir: string, options?: AddOptions): Promise<WrittenFile[]>;
|
|
19
|
+
|
|
20
|
+
interface EjectableItem {
|
|
21
|
+
/** Name to pass to `eject`, e.g. `VitaSection`, `talks.eta` or `teaching`. */
|
|
22
|
+
name: string;
|
|
23
|
+
kind: 'component' | 'template' | 'class' | 'schema' | 'style';
|
|
24
|
+
/** Absolute source path inside the package. */
|
|
25
|
+
source: string;
|
|
26
|
+
/** Destination relative to the project root. */
|
|
27
|
+
target: string;
|
|
28
|
+
}
|
|
29
|
+
/** Everything that can be copied into a project. */
|
|
30
|
+
declare function listEjectable(): Promise<EjectableItem[]>;
|
|
31
|
+
interface EjectResult {
|
|
32
|
+
item: EjectableItem;
|
|
33
|
+
/** Absolute path written. */
|
|
34
|
+
path: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Copy a package default into the project, where it then wins by the normal
|
|
38
|
+
* resolution rule. Never overwrites without `force`.
|
|
39
|
+
*/
|
|
40
|
+
declare function eject(name: string, rootDir: string, { force }?: {
|
|
41
|
+
force?: boolean | undefined;
|
|
42
|
+
}): Promise<EjectResult>;
|
|
43
|
+
|
|
44
|
+
export { addSection, eject, listEjectable };
|
|
45
|
+
export type { AddOptions, EjectResult, EjectableItem, WrittenFile };
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, copyFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative, basename } from 'node:path';
|
|
4
|
+
import { s as schemas, o as resolveAsset } from './shared/vitakit.BxcnlFGP.mjs';
|
|
5
|
+
import { b as bundledTexDir } from './shared/vitakit.BRhjqpAt.mjs';
|
|
6
|
+
import { parse } from 'yaml';
|
|
7
|
+
import 'node:url';
|
|
8
|
+
import 'zod';
|
|
9
|
+
import 'eta';
|
|
10
|
+
import './shared/index.mjs';
|
|
11
|
+
import './shared/vitakit.BlU-p01e.mjs';
|
|
12
|
+
|
|
13
|
+
const DATE_LIKE = /^\d{4}(-\d{2}){0,2}$/;
|
|
14
|
+
function pascalCase(id) {
|
|
15
|
+
return id.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
16
|
+
}
|
|
17
|
+
function inferField(values, present, total) {
|
|
18
|
+
const optional = present < total ? ".optional()" : "";
|
|
19
|
+
const sample = values[0];
|
|
20
|
+
if (typeof sample === "boolean") return `z.boolean().default(false)`;
|
|
21
|
+
if (typeof sample === "number" && !DATE_LIKE.test(String(sample))) return `z.number()${optional}`;
|
|
22
|
+
if (values.every((value) => DATE_LIKE.test(String(value)))) return `dateRange${optional}`;
|
|
23
|
+
if (typeof sample === "object" && sample !== null && "from" in sample) return `dateRange${optional}`;
|
|
24
|
+
if (typeof sample === "string" && /^https?:\/\//.test(sample)) return `url${optional}`;
|
|
25
|
+
const strings = values.filter((value) => typeof value === "string");
|
|
26
|
+
const unique = [...new Set(strings)];
|
|
27
|
+
if (strings.length === values.length && unique.length > 1 && unique.length <= Math.max(2, values.length / 2) && unique.every((value) => /^[a-z][\w-]*$/.test(value))) {
|
|
28
|
+
return `z.enum([${unique.map((value) => `'${value}'`).join(", ")}])${optional}`;
|
|
29
|
+
}
|
|
30
|
+
if (typeof sample === "string") return `text${optional}`;
|
|
31
|
+
return `z.unknown()${optional}`;
|
|
32
|
+
}
|
|
33
|
+
function inferSchema(entries) {
|
|
34
|
+
const fields = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
37
|
+
if (key === "visibility") continue;
|
|
38
|
+
if (!fields.has(key)) fields.set(key, []);
|
|
39
|
+
if (value !== void 0 && value !== null) fields.get(key).push(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const used = /* @__PURE__ */ new Set(["section"]);
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const [key, values] of fields) {
|
|
45
|
+
const present = entries.filter((entry) => entry[key] !== void 0).length;
|
|
46
|
+
let type = inferField(values, present, entries.length);
|
|
47
|
+
if (/^(title|headline)$/.test(key) && type.startsWith("text")) type = type.replace("text", "richText");
|
|
48
|
+
for (const primitive of ["dateRange", "richText", "text", "url", "z"]) {
|
|
49
|
+
if (new RegExp(`\\b${primitive}\\b`).test(type)) used.add(primitive);
|
|
50
|
+
}
|
|
51
|
+
lines.push(` ${key}: ${type},`);
|
|
52
|
+
}
|
|
53
|
+
return { body: lines.join("\n"), imports: [...used].sort() };
|
|
54
|
+
}
|
|
55
|
+
const SAMPLE_YAML = (id) => `# ${id}: one entry per item, newest first is not required --
|
|
56
|
+
# vitakit sorts by date for you. Edit the fields, then edit schemas/${id}.ts
|
|
57
|
+
# to match.
|
|
58
|
+
- date: 2025-03-14
|
|
59
|
+
title: An example entry
|
|
60
|
+
note: Replace this with something real.
|
|
61
|
+
`;
|
|
62
|
+
function schemaModule(id, body, imports, inferred) {
|
|
63
|
+
return [
|
|
64
|
+
`/**`,
|
|
65
|
+
` * Shape of content/${id}.yml.`,
|
|
66
|
+
` *`,
|
|
67
|
+
inferred ? ` * Drafted from the existing file: check the types, especially which fields
|
|
68
|
+
* are optional and whether any should be an enum.` : ` * Edit freely: this file defines what content/${id}.yml may contain.`,
|
|
69
|
+
` *`,
|
|
70
|
+
` * \`section()\` adds \`visibility\` and validates a list of entries.`,
|
|
71
|
+
` */`,
|
|
72
|
+
`import { ${imports.join(", ")} } from '@anja.codes/vitakit'`,
|
|
73
|
+
``,
|
|
74
|
+
`export default section({`,
|
|
75
|
+
body || ` date: dateRange,
|
|
76
|
+
title: richText,`,
|
|
77
|
+
`})`,
|
|
78
|
+
``
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
function componentModule(id) {
|
|
82
|
+
`${pascalCase(id)}Section`;
|
|
83
|
+
return `<script setup lang="ts">
|
|
84
|
+
/**
|
|
85
|
+
* How ${id} entries look. Yours to change: props in, markup out.
|
|
86
|
+
*
|
|
87
|
+
* \`useSection('${id}')\` returns validated, sorted entries; \`VitaEntry\`,
|
|
88
|
+
* \`VitaDate\` and \`richHtml\` come from vitakit.
|
|
89
|
+
*/
|
|
90
|
+
import { richHtml, type Entry } from '@anja.codes/vitakit/shared'
|
|
91
|
+
|
|
92
|
+
const props = defineProps<{ entries?: Entry[], limit?: number }>()
|
|
93
|
+
|
|
94
|
+
const items = computed(() => {
|
|
95
|
+
const source = props.entries ?? useSection('${id}')
|
|
96
|
+
return props.limit ? source.slice(0, props.limit) : source
|
|
97
|
+
})
|
|
98
|
+
<\/script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<ul v-if="items.length" class="vita-section">
|
|
102
|
+
<VitaEntry v-for="item in items" :key="item.id" :date="item.date as never">
|
|
103
|
+
<p class="vita-entry__title" v-html="richHtml(item.title)" />
|
|
104
|
+
<p v-if="typeof item.note === 'string'" class="vita-entry__note">
|
|
105
|
+
{{ item.note }}
|
|
106
|
+
</p>
|
|
107
|
+
</VitaEntry>
|
|
108
|
+
</ul>
|
|
109
|
+
</template>
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
async function addSection(id, rootDir, options = {}) {
|
|
113
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
|
|
114
|
+
throw new Error(`"${id}" must be lowercase kebab-case, e.g. research-stays`);
|
|
115
|
+
}
|
|
116
|
+
if (id in schemas) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`"${id}" is a built-in section: create content/${id}.yml and it works.
|
|
119
|
+
To change which fields it may contain, copy its schema into your project:
|
|
120
|
+
pnpm vitakit eject ${id}
|
|
121
|
+
and edit schemas/${id}.ts.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const written = [];
|
|
125
|
+
const yamlPath = join(rootDir, "content", `${id}.yml`);
|
|
126
|
+
const schemaPath = join(rootDir, "schemas", `${id}.ts`);
|
|
127
|
+
let entries = [];
|
|
128
|
+
let inferred = false;
|
|
129
|
+
if (existsSync(yamlPath)) {
|
|
130
|
+
const parsed = parse(await readFile(yamlPath, "utf8"));
|
|
131
|
+
if (!Array.isArray(parsed)) throw new Error(`content/${id}.yml must contain a list of entries`);
|
|
132
|
+
entries = parsed.filter((entry) => typeof entry === "object" && entry !== null);
|
|
133
|
+
if (!entries.length) throw new Error(`content/${id}.yml has no entries to infer a schema from`);
|
|
134
|
+
inferred = true;
|
|
135
|
+
} else {
|
|
136
|
+
await mkdir(join(yamlPath, ".."), { recursive: true });
|
|
137
|
+
await writeFile(yamlPath, SAMPLE_YAML(id));
|
|
138
|
+
written.push({ path: yamlPath, note: "your data \u2014 edit this first" });
|
|
139
|
+
entries = [{ date: "2025-03-14", title: "An example entry", note: "Replace this." }];
|
|
140
|
+
}
|
|
141
|
+
if (existsSync(schemaPath)) {
|
|
142
|
+
throw new Error(`schemas/${id}.ts already exists; delete it first to regenerate`);
|
|
143
|
+
}
|
|
144
|
+
const { body, imports } = inferSchema(entries);
|
|
145
|
+
await mkdir(join(schemaPath, ".."), { recursive: true });
|
|
146
|
+
await writeFile(schemaPath, schemaModule(id, body, imports, inferred));
|
|
147
|
+
written.push({ path: schemaPath, note: inferred ? `drafted from content/${id}.yml` : "the shape of an entry" });
|
|
148
|
+
if (options.component !== false) {
|
|
149
|
+
const componentPath = join(rootDir, "app", "components", `${pascalCase(id)}Section.vue`);
|
|
150
|
+
if (!existsSync(componentPath)) {
|
|
151
|
+
await mkdir(join(componentPath, ".."), { recursive: true });
|
|
152
|
+
await writeFile(componentPath, componentModule(id));
|
|
153
|
+
written.push({ path: componentPath, note: "how the entries look" });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return written.map((file) => ({ ...file, path: relative(rootDir, file.path) }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function listEjectable() {
|
|
160
|
+
const items = [];
|
|
161
|
+
const componentsDir = resolveAsset("runtime/components");
|
|
162
|
+
for (const file of (await readdir(componentsDir)).filter((name) => name.endsWith(".vue")).sort()) {
|
|
163
|
+
items.push({
|
|
164
|
+
name: basename(file, ".vue"),
|
|
165
|
+
kind: "component",
|
|
166
|
+
source: join(componentsDir, file),
|
|
167
|
+
target: join("app", "components", file)
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const templatesDir = join(bundledTexDir, "templates");
|
|
171
|
+
for (const file of (await readdir(templatesDir)).filter((name) => name.endsWith(".eta")).sort()) {
|
|
172
|
+
items.push({
|
|
173
|
+
name: file,
|
|
174
|
+
kind: "template",
|
|
175
|
+
source: join(templatesDir, file),
|
|
176
|
+
target: join("cv-tex", "templates", file)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const schemaDir = resolveAsset("schemas");
|
|
180
|
+
for (const file of (await readdir(schemaDir)).filter((name) => name.endsWith(".ts")).sort()) {
|
|
181
|
+
const id = basename(file, ".ts");
|
|
182
|
+
items.push({
|
|
183
|
+
name: id,
|
|
184
|
+
kind: "schema",
|
|
185
|
+
source: join(schemaDir, file),
|
|
186
|
+
target: join("schemas", file)
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
items.push({
|
|
190
|
+
name: "publications.csl",
|
|
191
|
+
kind: "style",
|
|
192
|
+
// The path of `defaultCslStyle`, without loading citation-js to get it.
|
|
193
|
+
source: join(resolveAsset("styles"), "vitakit-math.csl"),
|
|
194
|
+
target: join("content", "publications.csl")
|
|
195
|
+
});
|
|
196
|
+
items.push({
|
|
197
|
+
name: "vitakit-cv.cls",
|
|
198
|
+
kind: "class",
|
|
199
|
+
source: join(bundledTexDir, "vitakit-cv.cls"),
|
|
200
|
+
target: join("cv-tex", "vitakit-cv.cls")
|
|
201
|
+
});
|
|
202
|
+
return items;
|
|
203
|
+
}
|
|
204
|
+
async function eject(name, rootDir, { force = false } = {}) {
|
|
205
|
+
const items = await listEjectable();
|
|
206
|
+
const item = items.find((candidate) => candidate.name === name) ?? items.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
|
|
207
|
+
if (!item) {
|
|
208
|
+
const known = items.map((candidate) => candidate.name).join(", ");
|
|
209
|
+
throw new Error(`unknown item "${name}". Available: ${known}`);
|
|
210
|
+
}
|
|
211
|
+
const path = join(rootDir, item.target);
|
|
212
|
+
if (existsSync(path) && !force) {
|
|
213
|
+
throw new Error(`${relative(rootDir, path)} already exists; pass --force to overwrite`);
|
|
214
|
+
}
|
|
215
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
216
|
+
if (item.kind === "schema") {
|
|
217
|
+
const source = await readFile(item.source, "utf8");
|
|
218
|
+
await writeFile(path, source.replace(/from '\.\.\/[\w-]+'/g, "from '@anja.codes/vitakit'"));
|
|
219
|
+
} else {
|
|
220
|
+
await copyFile(item.source, path);
|
|
221
|
+
}
|
|
222
|
+
return { item, path };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { addSection, eject, listEjectable };
|