@anja.codes/vitakit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -131
- package/dist/check.mjs +12 -5
- package/dist/chunks/authoring.mjs +367 -0
- package/dist/chunks/build.mjs +176 -0
- package/dist/chunks/documents.mjs +143 -0
- package/dist/chunks/draft.mjs +222 -0
- package/dist/chunks/eject.mjs +59 -0
- package/dist/chunks/emit.mjs +200 -0
- package/dist/chunks/eta.mjs +16 -0
- package/dist/chunks/fields.mjs +79 -0
- package/dist/chunks/inventory.mjs +199 -0
- package/dist/chunks/plan.mjs +144 -0
- package/dist/chunks/prompt.mjs +33 -0
- package/dist/chunks/schema-source.mjs +12 -0
- package/dist/chunks/schemas.mjs +109 -0
- package/dist/chunks/sections.mjs +109 -0
- package/dist/chunks/snippets.mjs +199 -0
- package/dist/chunks/validate.mjs +124 -0
- package/dist/chunks/vue.mjs +116 -0
- package/dist/cli.d.mts +33 -40
- package/dist/cli.mjs +7 -224
- package/dist/index.d.mts +372 -42
- package/dist/index.mjs +10 -4
- package/dist/nuxt.mjs +35 -5
- package/dist/runtime/components/VitaSection.vue +94 -38
- package/dist/schemas/education.ts +4 -4
- package/dist/schemas/employment.ts +3 -3
- package/dist/schemas/publications.ts +2 -2
- package/dist/schemas/supervision.ts +3 -3
- package/dist/schemas/talks.ts +1 -1
- package/dist/schemas/teaching.ts +3 -3
- package/dist/shared/index.d.mts +60 -9
- package/dist/shared/index.mjs +3 -57
- package/dist/shared/vitakit.BZZIsWlY.mjs +179 -0
- package/dist/shared/vitakit.BerUaxtF.mjs +121 -0
- package/dist/shared/{vitakit.D8WRg_Xx.d.mts → vitakit.BfpxDtz1.d.mts} +9 -8
- package/dist/shared/{vitakit.CypCF-4Q.mjs → vitakit.By5VWQ1n.mjs} +12 -78
- package/dist/shared/vitakit.CfB3hMHl.mjs +60 -0
- package/dist/shared/vitakit.CoWX2Z4R.mjs +1554 -0
- package/dist/shared/{vitakit.BlU-p01e.mjs → vitakit.CtChKO0I.mjs} +1 -1
- package/dist/shared/vitakit.D-DdJaDZ.mjs +20 -0
- package/dist/shared/vitakit.D10HyDhF.mjs +19 -0
- package/dist/shared/vitakit.DQ6PmGoL.mjs +34 -0
- package/dist/shared/vitakit.DbSeENVS.mjs +58 -0
- package/dist/shared/vitakit.DcQOdM4I.mjs +57 -0
- package/dist/shared/vitakit.Do5HC_MD.mjs +142 -0
- package/dist/shared/vitakit.TSMTC5Jl.mjs +6 -0
- package/dist/tex/templates/_generic.eta +6 -4
- package/dist/tex/templates/education.eta +10 -5
- package/dist/tex/templates/employment.eta +12 -5
- package/dist/tex/templates/publications.eta +4 -2
- package/dist/tex/templates/supervision.eta +10 -2
- package/dist/tex/templates/talks.eta +5 -1
- package/dist/tex/templates/teaching.eta +6 -4
- package/dist/tex/vitakit-cv.cls +21 -6
- package/dist/tex.d.mts +53 -5
- package/dist/tex.mjs +20 -113
- package/package.json +10 -7
- package/src/bin/vitakit.mjs +3 -153
- package/dist/shared/vitakit.BRhjqpAt.mjs +0 -250
- package/dist/shared/vitakit.BxcnlFGP.mjs +0 -325
- package/src/bin/vitakit-build-cv.mjs +0 -50
- package/src/bin/vitakit-check.mjs +0 -57
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
3
|
+
import { p as packageRoot } from './vitakit.DQ6PmGoL.mjs';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
|
|
8
|
+
function detectPackageManager(rootDir = process.cwd(), env = process.env) {
|
|
9
|
+
const userAgent = env.npm_config_user_agent;
|
|
10
|
+
if (userAgent) return userAgent.startsWith("npm/") ? "npm" : "pnpm";
|
|
11
|
+
return existsSync(join(rootDir, "package-lock.json")) && !existsSync(join(rootDir, "pnpm-lock.yaml")) ? "npm" : "pnpm";
|
|
12
|
+
}
|
|
13
|
+
function commands(packageManager = detectPackageManager()) {
|
|
14
|
+
const npm = packageManager === "npm";
|
|
15
|
+
const words = (...parts) => parts.filter(Boolean).join(" ");
|
|
16
|
+
return {
|
|
17
|
+
// npm would take options meant for the script (--tex-only) as its own.
|
|
18
|
+
script: (name, args) => npm ? words("npm run", name, args && `-- ${args}`) : words("pnpm", name, args),
|
|
19
|
+
bin: (name, args) => words(npm ? "npx" : "pnpm", name, args)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function aligned(rows, indent = " ") {
|
|
23
|
+
const width = Math.max(...rows.map(([command]) => command.length)) + 2;
|
|
24
|
+
return rows.map(([command, comment]) => `${indent}${comment ? command.padEnd(width) + comment : command}`).join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class UsageError extends Error {
|
|
28
|
+
constructor(message, command) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.command = command;
|
|
31
|
+
this.name = "UsageError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class CommandError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "CommandError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const COMMON_OPTIONS = {
|
|
41
|
+
"help": { type: "boolean", short: "h", description: "this help" },
|
|
42
|
+
"root": { type: "string", value: "<dir>", description: "the project directory; found from the current one otherwise" },
|
|
43
|
+
"no-color": { type: "boolean", description: "plain output, also when NO_COLOR is set" }
|
|
44
|
+
};
|
|
45
|
+
const JSON_OPTION = { type: "boolean", description: "print one JSON result instead of text" };
|
|
46
|
+
function optionsOf(spec) {
|
|
47
|
+
return { ...COMMON_OPTIONS, ...spec.json ? { json: JSON_OPTION } : {}, ...spec.options };
|
|
48
|
+
}
|
|
49
|
+
function parseCommandArgs(spec, argv) {
|
|
50
|
+
const options = optionsOf(spec);
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = parseArgs({
|
|
54
|
+
args: argv,
|
|
55
|
+
allowPositionals: true,
|
|
56
|
+
strict: true,
|
|
57
|
+
options: Object.fromEntries(Object.entries(options).map(([name, option]) => [name, {
|
|
58
|
+
type: option.type,
|
|
59
|
+
...option.multiple ? { multiple: true } : {},
|
|
60
|
+
...option.short ? { short: option.short } : {}
|
|
61
|
+
}]))
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message.replace(/\. To specify a positional argument.*$/s, "") : String(error);
|
|
65
|
+
throw new UsageError(message.replace(/^Unknown option/, "unknown option"), spec.name);
|
|
66
|
+
}
|
|
67
|
+
if (parsed.values.help) return { positionals: parsed.positionals, values: parsed.values };
|
|
68
|
+
const { positionals } = parsed;
|
|
69
|
+
const variadic = spec.positionals.some((positional) => positional.variadic);
|
|
70
|
+
if (!variadic && positionals.length > spec.positionals.length) {
|
|
71
|
+
throw new UsageError(`unexpected argument "${positionals[spec.positionals.length]}"`, spec.name);
|
|
72
|
+
}
|
|
73
|
+
const missing = spec.positionals.find((positional, index) => positional.required && positionals[index] === void 0);
|
|
74
|
+
if (missing) throw new UsageError(`missing <${missing.name}>`, spec.name);
|
|
75
|
+
return { positionals, values: parsed.values };
|
|
76
|
+
}
|
|
77
|
+
function usageLine(spec, run) {
|
|
78
|
+
const positionals = spec.positionals.map((positional) => {
|
|
79
|
+
const name = `<${positional.name}>${positional.variadic ? "..." : ""}`;
|
|
80
|
+
return positional.required ? name : `[${name}]`;
|
|
81
|
+
});
|
|
82
|
+
return run.bin("vitakit", [spec.name, ...positionals, "[options]"].join(" "));
|
|
83
|
+
}
|
|
84
|
+
function commandHelp(spec, run) {
|
|
85
|
+
const options = optionsOf(spec);
|
|
86
|
+
const rows = Object.entries(options).map(([name, option]) => {
|
|
87
|
+
const flag = `${option.short ? `-${option.short}, ` : ""}--${name}${option.value ? ` ${option.value}` : ""}`;
|
|
88
|
+
return [flag, option.description];
|
|
89
|
+
});
|
|
90
|
+
const width = Math.max(...rows.map(([flag]) => flag.length));
|
|
91
|
+
return [
|
|
92
|
+
`vitakit ${spec.name} \u2014 ${spec.summary}`,
|
|
93
|
+
"",
|
|
94
|
+
"Usage",
|
|
95
|
+
...(spec.usage ? spec.usage(run) : [usageLine(spec, run)]).map((line) => ` ${line}`),
|
|
96
|
+
"",
|
|
97
|
+
spec.help(run).trim(),
|
|
98
|
+
"",
|
|
99
|
+
"Options",
|
|
100
|
+
...rows.map(([flag, description]) => ` ${flag.padEnd(width)} ${description}`),
|
|
101
|
+
""
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PACKAGE = "@anja.codes/vitakit";
|
|
106
|
+
function dependsOnVitakit(dir) {
|
|
107
|
+
const file = join(dir, "package.json");
|
|
108
|
+
if (!existsSync(file)) return false;
|
|
109
|
+
try {
|
|
110
|
+
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
111
|
+
return Boolean(manifest.dependencies?.[PACKAGE] ?? manifest.devDependencies?.[PACKAGE]);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function findProjectRoot(start) {
|
|
117
|
+
let dir = resolve(start);
|
|
118
|
+
for (; ; ) {
|
|
119
|
+
if (dependsOnVitakit(dir)) return dir;
|
|
120
|
+
const parent = dirname(dir);
|
|
121
|
+
if (parent === dir) return void 0;
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function resolveRoot(cwd, explicit) {
|
|
126
|
+
if (explicit !== void 0) {
|
|
127
|
+
const root2 = resolve(cwd, explicit);
|
|
128
|
+
if (!existsSync(root2)) throw new CommandError(`--root ${explicit}: no such directory`);
|
|
129
|
+
return root2;
|
|
130
|
+
}
|
|
131
|
+
const root = findProjectRoot(cwd);
|
|
132
|
+
if (!root) {
|
|
133
|
+
throw new CommandError(
|
|
134
|
+
`not inside a vitakit project: no package.json from here upwards depends on ${PACKAGE}.
|
|
135
|
+
Run this in your site's directory, or pass --root <dir>.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return root;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const FOX = [
|
|
142
|
+
" __..--''/\\ /\\",
|
|
143
|
+
" _.--'' V \\",
|
|
144
|
+
" _.-'' \\",
|
|
145
|
+
" .-' |",
|
|
146
|
+
" _.--~~~~--..__ ,' ,--.__ ^. /",
|
|
147
|
+
" .-' ``-.' `-. `-. /",
|
|
148
|
+
"(_ / \\ ( ( . /",
|
|
149
|
+
" ``--..____...--'' / _.-' \\ \\ \\ `-'",
|
|
150
|
+
" | _.-' `-\\_)_)",
|
|
151
|
+
" | _.-'",
|
|
152
|
+
" | _.-'",
|
|
153
|
+
" / .'",
|
|
154
|
+
" / /",
|
|
155
|
+
" | |",
|
|
156
|
+
" | |",
|
|
157
|
+
" |_|"
|
|
158
|
+
];
|
|
159
|
+
const TEXT_ROW = 10;
|
|
160
|
+
const TEXT_COLUMN = 37;
|
|
161
|
+
const BANNER_WIDTH = 61;
|
|
162
|
+
function banner(version, paint) {
|
|
163
|
+
const text = [paint.bold(`vitakit ${version}`), "", "one leap, two landings:", "your website and", "a typeset CV"];
|
|
164
|
+
return FOX.map((line, index) => {
|
|
165
|
+
const beside = text[index - TEXT_ROW];
|
|
166
|
+
return beside ? paint.fox(line.padEnd(TEXT_COLUMN)) + beside : paint.fox(line);
|
|
167
|
+
}).join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function processIo() {
|
|
171
|
+
return {
|
|
172
|
+
cwd: process.cwd(),
|
|
173
|
+
env: process.env,
|
|
174
|
+
out: (text) => process.stdout.write(text),
|
|
175
|
+
err: (text) => process.stderr.write(text),
|
|
176
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
177
|
+
tty: Boolean(process.stdout.isTTY),
|
|
178
|
+
...process.stdout.columns ? { columns: process.stdout.columns } : {},
|
|
179
|
+
async ask(question) {
|
|
180
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
181
|
+
try {
|
|
182
|
+
return await rl.question(question);
|
|
183
|
+
} finally {
|
|
184
|
+
rl.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function style(color) {
|
|
190
|
+
const wrap = (open, close) => (text) => color ? `\x1B[${open}m${text}\x1B[${close}m` : text;
|
|
191
|
+
return {
|
|
192
|
+
bold: wrap(1, 22),
|
|
193
|
+
dim: wrap(2, 22),
|
|
194
|
+
red: wrap(31, 39),
|
|
195
|
+
green: wrap(32, 39),
|
|
196
|
+
yellow: wrap(33, 39),
|
|
197
|
+
orange: wrap("38;5;208", 39)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function table(rows, indent = " ", gap = " ") {
|
|
201
|
+
const visible = (text) => text.replace(/\u001B\[[\d;]+m/g, "").length;
|
|
202
|
+
const widths = [];
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
row.forEach((cell, index) => {
|
|
205
|
+
widths[index] = Math.max(widths[index] ?? 0, visible(cell));
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return rows.map((row) => indent + row.map(
|
|
209
|
+
(cell, index) => index === row.length - 1 ? cell : cell + " ".repeat(widths[index] - visible(cell))
|
|
210
|
+
).join(gap).trimEnd()).join("\n");
|
|
211
|
+
}
|
|
212
|
+
function block(label, rows, width = 10) {
|
|
213
|
+
const lines = table(rows, "", " ").split("\n");
|
|
214
|
+
return lines.map((line, index) => ` ${(index === 0 ? label : "").padEnd(width)}${line}`).join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const asking = (context, values) => context.io.interactive && values.yes !== true;
|
|
218
|
+
async function finish(context, ws, values, { before = [], after = [] }) {
|
|
219
|
+
const { io, style, root } = context;
|
|
220
|
+
const { applyPlan, confirmPlan, printPlan } = await import('../chunks/plan.mjs');
|
|
221
|
+
const plan = ws.plan();
|
|
222
|
+
const writes = plan.items.some((item) => item.kind === "create" || item.kind === "edit" || item.kind === "replace");
|
|
223
|
+
io.out("\n");
|
|
224
|
+
printPlan(io, style, plan);
|
|
225
|
+
if (before.length) io.out(`
|
|
226
|
+
${before.join("\n")}
|
|
227
|
+
`);
|
|
228
|
+
if (!writes) {
|
|
229
|
+
io.out("\nNothing to write.\n");
|
|
230
|
+
if (after.length) io.out(`
|
|
231
|
+
${after.join("\n")}
|
|
232
|
+
`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
if (values["dry-run"]) {
|
|
236
|
+
io.out(`
|
|
237
|
+
${style.dim("Dry run: nothing was written.")}
|
|
238
|
+
`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
if (!await confirmPlan(io, plan, values.yes === true)) return 1;
|
|
242
|
+
await applyPlan(root, plan);
|
|
243
|
+
io.out(`
|
|
244
|
+
${style.green("\u2713")} done.${after.length ? `
|
|
245
|
+
|
|
246
|
+
${after.join("\n")}` : ""}
|
|
247
|
+
`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const SECTION_OPTIONS = ["page", "document", "title", "after", "like"];
|
|
252
|
+
const FIELD_OPTIONS = ["type", "several", "required", "choice"];
|
|
253
|
+
const PAGE_OPTIONS = ["title", "no-menu"];
|
|
254
|
+
const COMMON = ["help", "root", "no-color", "dry-run", "yes"];
|
|
255
|
+
function onlyOptions(kind, values, allowed) {
|
|
256
|
+
const wrong = Object.keys(values).filter((name) => values[name] !== void 0 && ![...COMMON, ...allowed].includes(name));
|
|
257
|
+
if (wrong.length) throw new UsageError(`--${wrong[0]} does not apply to add ${kind}`, "add");
|
|
258
|
+
}
|
|
259
|
+
const strings$1 = (value) => (Array.isArray(value) ? value : value === void 0 ? [] : [value]).map(String);
|
|
260
|
+
async function planSchema$1(context, ws, inv, id, section, entries, values) {
|
|
261
|
+
const { matchingSchemas, schemaOwner, sharedSchemaModule } = await import('../chunks/authoring.mjs');
|
|
262
|
+
const { draftSchema } = await import('../chunks/draft.mjs');
|
|
263
|
+
const { describeSection } = await import('../chunks/fields.mjs');
|
|
264
|
+
const { confirm } = await import('../chunks/prompt.mjs');
|
|
265
|
+
const notes = [];
|
|
266
|
+
let like = values.like;
|
|
267
|
+
if (like) {
|
|
268
|
+
if (!await schemaOwner(inv, like)) throw new CommandError(`--like ${like}: no section "${like}" with a schema`);
|
|
269
|
+
} else if (section && entries.length) {
|
|
270
|
+
const matches = await matchingSchemas(context.root, inv, id, entries);
|
|
271
|
+
if (matches.length && asking(context, values)) {
|
|
272
|
+
if (await confirm(context.io, `
|
|
273
|
+
The entries of ${section.where} fit the schema of ${matches[0]}. Use the same schema?`)) like = matches[0];
|
|
274
|
+
} else if (matches.length) {
|
|
275
|
+
notes.push(`Its entries fit the schema of ${matches[0]}: --like ${matches[0]} would share it.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const path = `schemas/${id}.ts`;
|
|
279
|
+
if (like) {
|
|
280
|
+
const other = await schemaOwner(inv, like);
|
|
281
|
+
ws.create(path, sharedSchemaModule(id, other), `the same fields as ${like}`, { show: true });
|
|
282
|
+
return { ...other.fields ? { fields: other.fields } : {}, changes: [], notes };
|
|
283
|
+
}
|
|
284
|
+
const file = section?.where ?? `content/${id}.yml`;
|
|
285
|
+
const draft = draftSchema(id, entries, { file });
|
|
286
|
+
ws.create(path, draft.source, section ? `drafted from the ${entries.length} ${entries.length === 1 ? "entry" : "entries"} in ${file}; check its comments` : "date, title and note; change it to fit", { show: true });
|
|
287
|
+
return { fields: describeSection(draft.schema) ?? [], changes: draft.changes, notes };
|
|
288
|
+
}
|
|
289
|
+
async function addSection(context, id, values) {
|
|
290
|
+
const { io, run, root } = context;
|
|
291
|
+
const { sectionNameProblem } = await import('../chunks/sections.mjs');
|
|
292
|
+
const problem = sectionNameProblem(id);
|
|
293
|
+
if (problem) throw new CommandError(problem);
|
|
294
|
+
if (id === "publications") throw new CommandError("publications come from content/publications.bib; `vitakit customize publications` changes their groups");
|
|
295
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
296
|
+
const { Workspace } = await import('../chunks/plan.mjs');
|
|
297
|
+
const authoring = await import('../chunks/authoring.mjs');
|
|
298
|
+
const { schemas } = await import('../chunks/schemas.mjs');
|
|
299
|
+
const { describeSection } = await import('../chunks/fields.mjs');
|
|
300
|
+
const { countsText } = await Promise.resolve().then(function () { return describe; });
|
|
301
|
+
const prompt = await import('../chunks/prompt.mjs');
|
|
302
|
+
const inv = await inventory(root);
|
|
303
|
+
const ws = new Workspace(root);
|
|
304
|
+
const section = inv.sections.find((candidate) => candidate.id === id);
|
|
305
|
+
const builtIn = Object.hasOwn(schemas, id);
|
|
306
|
+
const interactive = asking(context, values);
|
|
307
|
+
let entries = [];
|
|
308
|
+
let schemaResult = { changes: [], notes: [] };
|
|
309
|
+
if (section) {
|
|
310
|
+
try {
|
|
311
|
+
entries = authoring.rawEntries(await ws.read(section.where) ?? "");
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw new CommandError(`${section.where}: ${error instanceof Error ? error.message : String(error)}`);
|
|
314
|
+
}
|
|
315
|
+
ws.keep(section.where, countsText(section) || "your entries");
|
|
316
|
+
}
|
|
317
|
+
if (section?.schema.origin === "local") {
|
|
318
|
+
ws.keep(section.schema.file, section.schema.sharedWith ? `the schema of ${section.schema.sharedWith}` : "your schema");
|
|
319
|
+
schemaResult = { ...section.fields ? { fields: section.fields } : {}, changes: [], notes: [] };
|
|
320
|
+
} else if (builtIn) {
|
|
321
|
+
schemaResult = { fields: describeSection(schemas[id]) ?? [], changes: [], notes: [] };
|
|
322
|
+
} else {
|
|
323
|
+
if (!section) {
|
|
324
|
+
const like = values.like;
|
|
325
|
+
const other = like ? await authoring.schemaOwner(inv, like) : void 0;
|
|
326
|
+
if (other?.fields) ws.create(`content/${id}.yml`, authoring.dataExample(id, other.fields), "empty for now, with an example entry as a comment");
|
|
327
|
+
else {
|
|
328
|
+
ws.create(`content/${id}.yml`, authoring.dataStarter(id), "one example entry, to replace");
|
|
329
|
+
entries = authoring.STARTER_ENTRIES;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
schemaResult = await planSchema$1(context, ws, inv, id, section, entries, values);
|
|
333
|
+
}
|
|
334
|
+
if (!section && builtIn) {
|
|
335
|
+
ws.create(`content/${id}.yml`, authoring.dataExample(id, schemaResult.fields ?? []), `empty for now; vitakit already knows the ${id} fields`);
|
|
336
|
+
}
|
|
337
|
+
if (section && entries.length && (section.schema.origin === "built-in" || section.schema.origin === "local")) {
|
|
338
|
+
const missing = await authoring.planMissingFields(root, ws, section, entries);
|
|
339
|
+
if (missing.note) schemaResult.notes.push(missing.note);
|
|
340
|
+
if (missing.fields.length) {
|
|
341
|
+
const added = missing.fields.map((name) => ({ path: name, type: "custom", optional: true, several: false }));
|
|
342
|
+
schemaResult = { ...schemaResult, fields: [...schemaResult.fields ?? [], ...added], changes: missing.changes };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const { webPlacementsOf, pdfPlacementsOf } = await Promise.resolve().then(function () { return describe; });
|
|
346
|
+
const shownOn = new Set(webPlacementsOf(inv, id).map((placement) => placement.file));
|
|
347
|
+
const inDocuments = new Set(pdfPlacementsOf(inv, id).map((placement) => placement.document));
|
|
348
|
+
const vuePages = inv.pages.filter((page) => page.vue);
|
|
349
|
+
let title = values.title;
|
|
350
|
+
const after = values.after;
|
|
351
|
+
const pages = strings$1(values.page).map((route) => ({ route: route.endsWith("/") ? route : `${route}/`, ...after ? { after } : {} }));
|
|
352
|
+
const documents = strings$1(values.document).map((name) => ({ name, ...after ? { after } : {} }));
|
|
353
|
+
if (interactive && !pages.length && !documents.length) {
|
|
354
|
+
const candidates = vuePages.filter((page) => !shownOn.has(page.vue));
|
|
355
|
+
const newRoute = `/${id}/`;
|
|
356
|
+
const choices = [
|
|
357
|
+
...candidates.map((page) => ({ label: `${page.route} (${page.vue})`, value: page.route })),
|
|
358
|
+
...inv.pages.some((page) => page.route === newRoute) ? [] : [{ label: `a new page, ${newRoute}`, value: newRoute }],
|
|
359
|
+
{ label: shownOn.size ? `nowhere else (already on ${[...shownOn].join(", ")})` : "not on the website, for now", value: "" }
|
|
360
|
+
];
|
|
361
|
+
const preferred = Math.max(0, choices.findIndex((choice) => choice.value === "/cv/"));
|
|
362
|
+
const route = await prompt.choose(io, `Show ${id} on which page of your website?`, choices, preferred);
|
|
363
|
+
if (route) {
|
|
364
|
+
const existing = inv.web.placements.filter((placement) => placement.route === route && placement.component !== "code");
|
|
365
|
+
let position;
|
|
366
|
+
if (existing.length) {
|
|
367
|
+
position = await prompt.choose(io, "After which section?", [
|
|
368
|
+
...existing.map((placement) => ({ label: placement.section, value: placement.section }))
|
|
369
|
+
], existing.length - 1);
|
|
370
|
+
}
|
|
371
|
+
pages.push({ route, ...position ? { after: position } : {} });
|
|
372
|
+
}
|
|
373
|
+
for (const document of inv.documents.filter((candidate) => !inDocuments.has(candidate.name))) {
|
|
374
|
+
if (!await prompt.confirm(io, `
|
|
375
|
+
Include ${id} in the PDF ${document.tex}?`, true)) continue;
|
|
376
|
+
const existing = document.placements;
|
|
377
|
+
const position = existing.length ? await prompt.choose(io, "After which section?", existing.map((placement) => ({ label: placement.section, value: placement.section })), existing.length - 1) : void 0;
|
|
378
|
+
documents.push({ name: document.name, ...position ? { after: position } : {} });
|
|
379
|
+
}
|
|
380
|
+
if ((pages.length || documents.length) && title === void 0) title = await prompt.input(io, "\nHeading", prompt.headingFor(id));
|
|
381
|
+
}
|
|
382
|
+
title ??= prompt.headingFor(id);
|
|
383
|
+
for (const page of pages) {
|
|
384
|
+
let path = vuePages.find((candidate) => candidate.route === page.route)?.vue;
|
|
385
|
+
if (!path) {
|
|
386
|
+
const name = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)\/$/.exec(page.route)?.[1];
|
|
387
|
+
if (!name) throw new CommandError(`--page ${page.route}: no Vue page has this route; a new page needs a route like /talks/`);
|
|
388
|
+
path = await authoring.planCreatePage(ws, inv, name, { title: page.route === `/${id}/` ? title : prompt.headingFor(name), menu: true });
|
|
389
|
+
}
|
|
390
|
+
await authoring.planPlaceOnPage(ws, inv, path, { id, title, ...page.after ? { after: page.after } : {} });
|
|
391
|
+
}
|
|
392
|
+
for (const document of documents) {
|
|
393
|
+
if (!inv.documents.some((candidate) => candidate.name === document.name)) {
|
|
394
|
+
throw new CommandError(`--document ${document.name}: no cv-tex/${document.name}.tex; the documents are ${inv.documents.map((candidate) => candidate.name).join(", ") || "(none)"}`);
|
|
395
|
+
}
|
|
396
|
+
await authoring.planPlaceInDocument(ws, document.name, { id, title, ...document.after ? { after: document.after } : {} });
|
|
397
|
+
}
|
|
398
|
+
const before = [...schemaResult.notes];
|
|
399
|
+
if (schemaResult.changes.length) {
|
|
400
|
+
before.push(`The drafted schema does not accept every entry yet. In ${section?.where ?? `content/${id}.yml`}, change:`);
|
|
401
|
+
before.push(...schemaResult.changes.slice(0, 12).map((change) => ` ${change}`));
|
|
402
|
+
if (schemaResult.changes.length > 12) before.push(` \u2026 and ${schemaResult.changes.length - 12} more`);
|
|
403
|
+
}
|
|
404
|
+
if (!pages.length && !shownOn.size) before.push(`Not on the website: \`${run.bin("vitakit", `add section ${id} --page /cv/`)}\` adds it to a page later.`);
|
|
405
|
+
if (!documents.length && !inDocuments.size && inv.documents.length) before.push(`Not in a PDF: \`${run.bin("vitakit", `add section ${id} --document ${inv.documents[0].name}`)}\` adds it later.`);
|
|
406
|
+
const next = [];
|
|
407
|
+
if (!section) next.push(`Write your entries in content/${id}.yml.`);
|
|
408
|
+
if (schemaResult.changes.length) next.push(`Then check them: \`${run.bin("vitakit", `validate ${id}`)}\``);
|
|
409
|
+
const hidden = authoring.fieldsNotShown(schemaResult.fields);
|
|
410
|
+
if (hidden.length && (pages.length || documents.length || shownOn.size || inDocuments.size)) {
|
|
411
|
+
next.push(`Accepted, but not shown by the standard layout: ${hidden.join(", ")}. To show them: \`${run.bin("vitakit", `customize ${id} --web --pdf ${hidden.map((name) => `--field ${name}`).join(" ")}`)}\``);
|
|
412
|
+
}
|
|
413
|
+
return finish(context, ws, values, { before, after: next });
|
|
414
|
+
}
|
|
415
|
+
async function addField(context, id, name, values) {
|
|
416
|
+
const { io, run, root } = context;
|
|
417
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
418
|
+
const { Workspace } = await import('../chunks/plan.mjs');
|
|
419
|
+
const authoring = await import('../chunks/authoring.mjs');
|
|
420
|
+
const { schemas } = await import('../chunks/schemas.mjs');
|
|
421
|
+
const { describeSection, FIELD_TYPE_NAMES } = await import('../chunks/fields.mjs');
|
|
422
|
+
const prompt = await import('../chunks/prompt.mjs');
|
|
423
|
+
if (!/^[A-Za-z_][\w-]*$/.test(name)) throw new CommandError(`"${name}" cannot be a field name; use letters and digits, like evaluationScore`);
|
|
424
|
+
if (name === "visibility" || name === "id") throw new CommandError(`"${name}" is a field every entry has already`);
|
|
425
|
+
const inv = await inventory(root);
|
|
426
|
+
let section = inv.sections.find((candidate) => candidate.id === id);
|
|
427
|
+
const builtIn = Object.hasOwn(schemas, id);
|
|
428
|
+
if (!section && builtIn) {
|
|
429
|
+
section = { id, where: `content/${id}.yml`, schema: { origin: "built-in" }, fields: describeSection(schemas[id]) ?? [], problems: [] };
|
|
430
|
+
}
|
|
431
|
+
if (!section) throw new CommandError(`no section called "${id}"; the sections are ${inv.sections.map((candidate) => candidate.id).join(", ") || "(none)"}`);
|
|
432
|
+
if (section.schema.origin === "none") throw new CommandError(`${id} has no schema yet; \`${run.bin("vitakit", `add section ${id}`)}\` drafts one from its entries`);
|
|
433
|
+
if (section.schema.sharedWith) {
|
|
434
|
+
throw new CommandError(`${id} uses the schema of ${section.schema.sharedWith} (schemas/${id}.ts). Add the field there, for both: \`${run.bin("vitakit", `add field ${section.schema.sharedWith} ${name}`)}\``);
|
|
435
|
+
}
|
|
436
|
+
const existing = section.fields?.find((field) => field.path === name);
|
|
437
|
+
if (existing) {
|
|
438
|
+
const { fieldSummary } = await import('../chunks/fields.mjs');
|
|
439
|
+
io.out(`${id} has a field ${name} already: ${fieldSummary(existing)}. Nothing to do.
|
|
440
|
+
`);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
const interactive = asking(context, values);
|
|
444
|
+
let type = values.type;
|
|
445
|
+
const known = authoring.FIELD_TYPES.map((entry) => entry.type);
|
|
446
|
+
if (type !== void 0 && !known.includes(type)) throw new UsageError(`--type ${type}: one of ${known.join(", ")}`, "add");
|
|
447
|
+
if (type === void 0) {
|
|
448
|
+
if (!interactive) throw new UsageError(`which type? --type ${known.join("|")}`, "add");
|
|
449
|
+
type = await prompt.choose(io, `What kind of value is "${name}"?`, authoring.FIELD_TYPES.map((entry) => ({ label: entry.label, value: entry.type })));
|
|
450
|
+
}
|
|
451
|
+
let choices = strings$1(values.choice);
|
|
452
|
+
if (type === "choice" && !choices.length) {
|
|
453
|
+
if (!interactive) throw new UsageError("a choice needs its values: --choice <value>, once per value", "add");
|
|
454
|
+
choices = (await prompt.input(io, "The allowed values, separated by commas")).split(",").map((choice) => choice.trim()).filter(Boolean);
|
|
455
|
+
}
|
|
456
|
+
const several = values.several === true || values.several === void 0 && interactive && await prompt.confirm(io, "Can an entry have several?", false);
|
|
457
|
+
const required = values.required === true || values.required === void 0 && interactive && await prompt.confirm(io, "Should every entry have it?", false);
|
|
458
|
+
const ws = new Workspace(root);
|
|
459
|
+
const outcome = await authoring.planAddField(ws, section, name, { type, several, required, choices });
|
|
460
|
+
const before = [];
|
|
461
|
+
const entries = section.entries ?? 0;
|
|
462
|
+
if (required && entries) before.push(`Required: the ${entries} entries in ${section.where} need it before they are valid again.`);
|
|
463
|
+
const example = authoring.exampleValue({ type, several, choices });
|
|
464
|
+
const after = [
|
|
465
|
+
`Next: give entries in ${section.where} the new field, e.g.`,
|
|
466
|
+
` ${name}: ${example}`,
|
|
467
|
+
...outcome === "pasted" ? [] : [`It is accepted now, not shown yet (${FIELD_TYPE_NAMES[type]}). To show it: \`${run.bin("vitakit", `customize ${id} --web --pdf --field ${name}`)}\``]
|
|
468
|
+
];
|
|
469
|
+
return finish(context, ws, values, { before, after });
|
|
470
|
+
}
|
|
471
|
+
async function addPage(context, name, values) {
|
|
472
|
+
const { io, run, root } = context;
|
|
473
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
474
|
+
const { Workspace } = await import('../chunks/plan.mjs');
|
|
475
|
+
const authoring = await import('../chunks/authoring.mjs');
|
|
476
|
+
const prompt = await import('../chunks/prompt.mjs');
|
|
477
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) throw new CommandError(`"${name}" must be lowercase kebab-case, e.g. research-interests; it becomes the address /${name}/`);
|
|
478
|
+
const inv = await inventory(root);
|
|
479
|
+
const interactive = asking(context, values);
|
|
480
|
+
const title = values.title ?? (interactive ? await prompt.input(io, "Title", prompt.headingFor(name)) : prompt.headingFor(name));
|
|
481
|
+
const menu = values["no-menu"] !== true && (!interactive || await prompt.confirm(io, "Add it to the menu?", true));
|
|
482
|
+
const ws = new Workspace(root);
|
|
483
|
+
await authoring.planCreatePage(ws, inv, name, { title, menu });
|
|
484
|
+
return finish(context, ws, values, {
|
|
485
|
+
after: [
|
|
486
|
+
`The page is /${name}/. Write its text in content/pages/${name}.md;`,
|
|
487
|
+
`add sections to it with \`${run.bin("vitakit", `add section <name> --page /${name}/`)}\`.`
|
|
488
|
+
]
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const add = {
|
|
492
|
+
name: "add",
|
|
493
|
+
summary: "add a section, a field of a section, or a website page",
|
|
494
|
+
positionals: [{ name: "what", required: true }, { name: "name" }, { name: "field" }],
|
|
495
|
+
options: {
|
|
496
|
+
"page": { type: "string", multiple: true, value: "<route>", description: "section: show it on this page, e.g. /cv/ (repeatable)" },
|
|
497
|
+
"document": { type: "string", multiple: true, value: "<name>", description: "section: include it in this PDF document, e.g. cv (repeatable)" },
|
|
498
|
+
"after": { type: "string", value: "<section>", description: "section: place it after this section" },
|
|
499
|
+
"title": { type: "string", value: "<text>", description: "section: its heading; page: its title" },
|
|
500
|
+
"like": { type: "string", value: "<section>", description: "section: use the schema of another section" },
|
|
501
|
+
"type": { type: "string", value: "<type>", description: "field: text, formatted-text, number, yes-no, date, web-address, link or choice" },
|
|
502
|
+
"several": { type: "boolean", description: "field: an entry may have a list of values" },
|
|
503
|
+
"required": { type: "boolean", description: "field: every entry must have it" },
|
|
504
|
+
"choice": { type: "string", multiple: true, value: "<value>", description: "field: an allowed value of a choice (repeatable)" },
|
|
505
|
+
"no-menu": { type: "boolean", description: "page: do not add it to the menu" },
|
|
506
|
+
"dry-run": { type: "boolean", description: "show what would be written, write nothing" },
|
|
507
|
+
"yes": { type: "boolean", description: "ask nothing: use the options given and write" }
|
|
508
|
+
},
|
|
509
|
+
project: true,
|
|
510
|
+
usage: (run) => [
|
|
511
|
+
run.bin("vitakit", "add section <name> [options]"),
|
|
512
|
+
run.bin("vitakit", "add field <section> <name> [options]"),
|
|
513
|
+
run.bin("vitakit", "add page <name> [options]")
|
|
514
|
+
],
|
|
515
|
+
help: (run) => `
|
|
516
|
+
A section is a file content/<name>.yml. \`add section\` creates what is missing:
|
|
517
|
+
the file, a schema drafted from your entries (or one of another section, with
|
|
518
|
+
--like), and its place on a website page and in a PDF document -- one line in
|
|
519
|
+
each, after the section you choose.
|
|
520
|
+
|
|
521
|
+
${run.bin("vitakit", "add section grants")}
|
|
522
|
+
${run.bin("vitakit", "add section grants --page /cv/ --document cv --after education --yes")}
|
|
523
|
+
${run.bin("vitakit", "add section talks-public --like talks")}
|
|
524
|
+
|
|
525
|
+
\`add field\` lets a section accept one more field. For a built-in section it
|
|
526
|
+
creates schemas/<section>.ts, which keeps all of vitakit's fields:
|
|
527
|
+
|
|
528
|
+
${run.bin("vitakit", "add field teaching evaluation --type number")}
|
|
529
|
+
${run.bin("vitakit", "add field talks audience --type choice --choice school --choice public")}
|
|
530
|
+
|
|
531
|
+
\`add page\` creates a website page that can show sections, with its text in
|
|
532
|
+
content/pages/<name>.md, and adds it to the menu:
|
|
533
|
+
|
|
534
|
+
${run.bin("vitakit", "add page talks --title Talks")}
|
|
535
|
+
|
|
536
|
+
Everything is shown before it is written, and asked; nothing existing is
|
|
537
|
+
replaced. A file that cannot be changed safely gets a line to paste instead.`,
|
|
538
|
+
async run(context, { positionals, values }) {
|
|
539
|
+
const [what, name, field] = positionals;
|
|
540
|
+
if (what === "section" || what === "page") {
|
|
541
|
+
if (!name) throw new UsageError(`missing <name>: \`add ${what} <name>\``, "add");
|
|
542
|
+
if (field !== void 0) throw new UsageError(`unexpected argument "${field}"`, "add");
|
|
543
|
+
onlyOptions(what, values, what === "section" ? SECTION_OPTIONS : PAGE_OPTIONS);
|
|
544
|
+
return what === "section" ? addSection(context, name, values) : addPage(context, name, values);
|
|
545
|
+
}
|
|
546
|
+
if (what === "field") {
|
|
547
|
+
if (!name || !field) throw new UsageError("`add field <section> <name>`, e.g. add field teaching evaluation", "add");
|
|
548
|
+
onlyOptions(what, values, FIELD_OPTIONS);
|
|
549
|
+
return addField(context, name, field, values);
|
|
550
|
+
}
|
|
551
|
+
throw new UsageError(`add what? \`add section <name>\`, \`add field <section> <name>\` or \`add page <name>\`${what ? `, not "${what}"` : ""}`, "add");
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const strings = (value) => (Array.isArray(value) ? value : value === void 0 ? [] : [value]).map(String);
|
|
556
|
+
const pascal = (id) => id.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
557
|
+
async function planSchema(context, ws, inv, id, values, notes) {
|
|
558
|
+
const { run } = context;
|
|
559
|
+
const { extensionModule } = await import('../chunks/authoring.mjs');
|
|
560
|
+
const { builtinSchemaSource } = await import('../chunks/schema-source.mjs');
|
|
561
|
+
const { schemas } = await import('../chunks/schemas.mjs');
|
|
562
|
+
const path = `schemas/${id}.ts`;
|
|
563
|
+
if (id === "publications") {
|
|
564
|
+
if (ws.exists(path)) {
|
|
565
|
+
notes.after.push(`${path} is yours already: its groups, badges and fields decide how content/publications.bib is grouped and linked.`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
ws.create(path, await builtinSchemaSource("publications"), "vitakit's publication groups, badges and fields, to change");
|
|
569
|
+
notes.before.push("The website, `vitakit verify` and the PDF all read it from now on.");
|
|
570
|
+
notes.after.push(`Edit ${path}: change groups, add badges, pass on BibTeX fields. Check it: \`${run.bin("vitakit", "validate")}\``);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const section = inv.sections.find((candidate) => candidate.id === id);
|
|
574
|
+
if (section?.schema.origin === "local") {
|
|
575
|
+
notes.after.push(`${path} is yours already${section.schema.sharedWith ? `; it uses the schema of ${section.schema.sharedWith}` : ""}. Edit it, or add a field with \`${run.bin("vitakit", `add field ${id} <name>`)}\`.`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!Object.hasOwn(schemas, id)) {
|
|
579
|
+
throw new CommandError(`${id} has no schema yet; \`${run.bin("vitakit", `add section ${id}`)}\` drafts one from its entries`);
|
|
580
|
+
}
|
|
581
|
+
if (values.copy) {
|
|
582
|
+
ws.create(path, await builtinSchemaSource(id), `vitakit's complete ${id} definition, yours from now on`);
|
|
583
|
+
notes.before.push(
|
|
584
|
+
"A complete copy: change or remove any rule. Improvements to vitakit's",
|
|
585
|
+
`${id} fields will not reach you; without --copy, you would keep them and only add.`
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
ws.create(path, extensionModule(id, [], []), `keeps vitakit's ${id} fields; yours go below them`, { show: true });
|
|
589
|
+
notes.before.push(
|
|
590
|
+
`Your fields are added to vitakit's, which stay as they are and keep improving.`,
|
|
591
|
+
`To remove or rewrite built-in rules instead: \`${run.bin("vitakit", `customize ${id} --schema --copy`)}\``
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
notes.after.push(`Add a field: \`${run.bin("vitakit", `add field ${id} <name>`)}\`. Check: \`${run.bin("vitakit", `validate ${id}`)}\``);
|
|
595
|
+
}
|
|
596
|
+
async function planWeb(context, ws, inv, section, fields, list, values, notes) {
|
|
597
|
+
const { io, run } = context;
|
|
598
|
+
const { entryComponent, listComponent } = await import('../chunks/snippets.mjs');
|
|
599
|
+
const { useComponentForSection } = await import('../chunks/vue.mjs').then(function (n) { return n.v; });
|
|
600
|
+
const { planPlaceOnPage } = await import('../chunks/authoring.mjs');
|
|
601
|
+
const prompt = await import('../chunks/prompt.mjs');
|
|
602
|
+
const id = section.id;
|
|
603
|
+
const interactive = asking(context, values);
|
|
604
|
+
const current = [...new Set(inv.web.placements.filter((placement) => placement.section === id && inv.web.components.has(placement.component)).map((placement) => placement.component))];
|
|
605
|
+
if (current.length === 1 && !values.use && !values.component && !values.force && values.list === void 0) {
|
|
606
|
+
const { webFieldSnippet } = await import('../chunks/snippets.mjs');
|
|
607
|
+
const file = inv.web.components.get(current[0]);
|
|
608
|
+
const existing = await ws.read(file) ?? "";
|
|
609
|
+
const missing = fields.map((field) => webFieldSnippet(field)).filter((snippet) => !existing.includes(snippet.markup));
|
|
610
|
+
if (!fields.length) notes.after.push(`${id} is shown by ${file} already, which is yours. --force starts over, keeping it as a backup.`);
|
|
611
|
+
else if (!missing.length) ws.keep(file, `shows ${fields.map((field) => field.path).join(", ")} already`);
|
|
612
|
+
else {
|
|
613
|
+
const imports = [...new Set(missing.flatMap((snippet) => snippet.imports))];
|
|
614
|
+
ws.paste(
|
|
615
|
+
file,
|
|
616
|
+
[...missing.map((snippet) => snippet.markup), ...imports.length ? ["", `import { ${imports.join(", ")} } from '@anja.codes/vitakit/shared'`] : []].join("\n"),
|
|
617
|
+
"is yours already; add these lines where the entry's lines are, e.g. in its #note slot"
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
let component = values.use;
|
|
623
|
+
if (component) {
|
|
624
|
+
if (!inv.web.components.has(component)) {
|
|
625
|
+
throw new CommandError(`--use ${component}: no app/components/${component}.vue; the components are ${[...inv.web.components.keys()].join(", ") || "(none)"}`);
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
const fallback = `${pascal(id)}${list ? "List" : "Section"}`;
|
|
629
|
+
component = values.component ?? (interactive ? await prompt.input(io, "Name of the component", fallback) : fallback);
|
|
630
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(component)) throw new CommandError(`"${component}" cannot be a component name; use PascalCase, like TalkList`);
|
|
631
|
+
if (component.startsWith("Vita")) throw new CommandError(`"${component}" would replace vitakit's own component; choose a name that does not start with Vita`);
|
|
632
|
+
const path = `app/components/${component}.vue`;
|
|
633
|
+
const region = values.region ?? "note";
|
|
634
|
+
if (region !== "note" && region !== "meta") throw new UsageError("--region note or --region meta", "customize");
|
|
635
|
+
let groupBy = values["group-by"];
|
|
636
|
+
if (list && groupBy === void 0 && interactive) {
|
|
637
|
+
groupBy = await prompt.input(io, "Group the entries by a field, e.g. term? (Enter for no groups)") || void 0;
|
|
638
|
+
}
|
|
639
|
+
if (groupBy && !section.fields?.some((field) => field.path === groupBy)) {
|
|
640
|
+
throw new CommandError(`--group-by ${groupBy}: ${id} has no such field; its fields are ${section.fields?.map((field) => field.path).join(", ")}`);
|
|
641
|
+
}
|
|
642
|
+
const source = list ? listComponent(id, component, section.fields ?? [], groupBy) : entryComponent(id, component, fields, region);
|
|
643
|
+
const reason = list ? `${id} as your own list${groupBy ? `, grouped by ${groupBy}` : ""}, with a line for each field` : `${id} through VitaSection, with your own ${region} line${fields.length ? ` showing ${fields.map((field) => field.path).join(", ")}` : ""}`;
|
|
644
|
+
if (ws.exists(path)) {
|
|
645
|
+
if (!values.force) throw new CommandError(`${path} exists. \`--use ${component}\` shows ${id} with it; --force replaces it, keeping yours as a backup.`);
|
|
646
|
+
ws.replace(path, source, `${reason}; yours is kept next to it as a .backup`, { show: true });
|
|
647
|
+
} else {
|
|
648
|
+
ws.create(path, source, reason, { show: true });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const placements = inv.web.placements.filter((placement) => placement.section === id);
|
|
652
|
+
const files = [...new Set(placements.filter((placement) => placement.component !== "code").map((placement) => placement.file))];
|
|
653
|
+
const known = [...inv.web.components.keys(), component];
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
const source = await ws.read(file);
|
|
656
|
+
const result = useComponentForSection(source, id, component, known);
|
|
657
|
+
if (result.ok && result.value.changed.length) await ws.edit(file, result.value.source, `shows ${id} with ${component}`);
|
|
658
|
+
else if (result.ok) ws.keep(file, `shows ${id} with ${component} already`);
|
|
659
|
+
else ws.paste(file, `<${component} id="${id}" />`, `cannot be changed safely (${result.reason}); use this where ${id} should appear`);
|
|
660
|
+
}
|
|
661
|
+
for (const placement of placements.filter((candidate) => candidate.component === "code")) {
|
|
662
|
+
notes.before.push(`${placement.file}:${placement.line} uses useSection('${id}') in code; that is left as it is.`);
|
|
663
|
+
}
|
|
664
|
+
let routes = strings(values.page);
|
|
665
|
+
if (!files.length && !routes.length && interactive) {
|
|
666
|
+
const pages = inv.pages.filter((page) => page.vue);
|
|
667
|
+
const route = await prompt.choose(io, `${id} is on no page yet. Show it on which page?`, [
|
|
668
|
+
...pages.map((page) => ({ label: `${page.route} (${page.vue})`, value: page.route })),
|
|
669
|
+
{ label: "not now", value: "" }
|
|
670
|
+
], Math.max(0, pages.findIndex((page) => page.route === "/cv/")));
|
|
671
|
+
if (route) routes = [route];
|
|
672
|
+
}
|
|
673
|
+
for (const route of routes) {
|
|
674
|
+
const page = inv.pages.find((candidate) => candidate.route === (route.endsWith("/") ? route : `${route}/`));
|
|
675
|
+
if (!page?.vue) throw new CommandError(`--page ${route}: no Vue page has this route`);
|
|
676
|
+
await planPlaceOnPage(ws, inv, page.vue, { id, title: prompt.headingFor(id), component });
|
|
677
|
+
}
|
|
678
|
+
if (!files.length && !routes.length) {
|
|
679
|
+
notes.before.push(`${id} is on no page. Show it with <${component} id="${id}" title="${prompt.headingFor(id)}" />, or --page /cv/.`);
|
|
680
|
+
}
|
|
681
|
+
notes.after.push(`Edit app/components/${component}.vue; \`${run.script("dev")}\` shows every change as you save.`);
|
|
682
|
+
}
|
|
683
|
+
async function planPdf(context, ws, inv, id, fields, values, notes) {
|
|
684
|
+
const { io, run, root } = context;
|
|
685
|
+
const { resolveTemplate } = await import('../chunks/emit.mjs');
|
|
686
|
+
const { etaFieldSnippet } = await import('../chunks/snippets.mjs');
|
|
687
|
+
const { addToEntryLoop } = await import('../chunks/eta.mjs');
|
|
688
|
+
const prompt = await import('../chunks/prompt.mjs');
|
|
689
|
+
const templateDir = join(root, "cv-tex", "templates");
|
|
690
|
+
const path = `cv-tex/templates/${id}.eta`;
|
|
691
|
+
const lines = fields.map((field) => etaFieldSnippet(field));
|
|
692
|
+
if (!inv.documents.length) throw new CommandError("there are no PDF documents in cv-tex/");
|
|
693
|
+
if (id === "publications" && fields.length) throw new CommandError("publications are formatted by biblatex; --field does not apply");
|
|
694
|
+
let from = values.from;
|
|
695
|
+
if (!from && !ws.exists(path) && asking(context, values)) {
|
|
696
|
+
const section = inv.sections.find((candidate) => candidate.id === id);
|
|
697
|
+
const relatives = inv.sections.filter((candidate) => candidate.id !== id && (candidate.id === section?.schema.sharedWith || candidate.schema.sharedWith === id || section?.schema.sharedWith !== void 0 && candidate.schema.sharedWith === section.schema.sharedWith));
|
|
698
|
+
const withTemplate = relatives.find((candidate) => ws.exists(`cv-tex/templates/${candidate.id}.eta`));
|
|
699
|
+
if (withTemplate && await prompt.confirm(io, `
|
|
700
|
+
${id} has the fields of ${withTemplate.id}, which has its own template. Start from a copy of it?`)) from = withTemplate.id;
|
|
701
|
+
}
|
|
702
|
+
if (ws.exists(path) && !values.force) {
|
|
703
|
+
const existing = await ws.read(path) ?? "";
|
|
704
|
+
const missing = lines.filter((line) => !existing.includes(line));
|
|
705
|
+
if (lines.length && !missing.length) {
|
|
706
|
+
ws.keep(path, `shows ${fields.map((field) => field.path).join(", ")} already`);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (!missing.length) {
|
|
710
|
+
notes.after.push(`${path} is yours already. \`${run.bin("vitakit", `explain ${id} --field <name>`)}\` shows lines to add for a field; --force starts over, keeping yours as a backup.`);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
ws.paste(path, missing.join("\n"), "is yours already; add these lines inside its loop over the entries, where the notes are");
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
let source;
|
|
717
|
+
let reason;
|
|
718
|
+
if (from) {
|
|
719
|
+
const copied = await ws.read(`cv-tex/templates/${from}.eta`);
|
|
720
|
+
if (copied === void 0) throw new CommandError(`--from ${from}: there is no cv-tex/templates/${from}.eta to copy`);
|
|
721
|
+
source = `<%/* A copy of ${from}.eta: the two are separate files from now on. */%>
|
|
722
|
+
${copied}`;
|
|
723
|
+
reason = `a copy of your ${from}.eta`;
|
|
724
|
+
} else {
|
|
725
|
+
const template = await resolveTemplate(id, templateDir, { exclude: join(templateDir, `${id}.eta`) });
|
|
726
|
+
source = template.source;
|
|
727
|
+
reason = template.origin === "local" ? "a copy of your _generic.eta" : template.generic ? "a copy of vitakit's generic template" : `a copy of vitakit's ${id}.eta`;
|
|
728
|
+
}
|
|
729
|
+
if (lines.length) {
|
|
730
|
+
const added = addToEntryLoop(source, lines);
|
|
731
|
+
if (added.ok) {
|
|
732
|
+
source = added.value;
|
|
733
|
+
reason += `, with ${fields.map((field) => field.path).join(", ")}`;
|
|
734
|
+
} else {
|
|
735
|
+
ws.paste(path, lines.join("\n"), `add these lines inside the loop over the entries (${added.reason})`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (ws.exists(path)) ws.replace(path, source, `${reason}; yours is kept next to it as a .backup`, { show: true });
|
|
739
|
+
else ws.create(path, source, reason, { show: true });
|
|
740
|
+
const included = inv.documents.filter((document) => document.placements.some((placement) => placement.section === id));
|
|
741
|
+
if (!included.length) notes.before.push(`No PDF document includes ${id} yet: \`${run.bin("vitakit", `add section ${id} --document ${inv.documents[0].name}`)}\``);
|
|
742
|
+
if (id === "publications") {
|
|
743
|
+
notes.before.push("This template groups the references; how each one is formatted is up to biblatex, in cv-tex/preamble.tex.");
|
|
744
|
+
}
|
|
745
|
+
notes.after.push(`Edit ${path}; \`${run.bin("vitakit", `validate ${id}`)}\` checks it without LaTeX, \`${run.bin("vitakit", "pdf")}\` builds.`);
|
|
746
|
+
}
|
|
747
|
+
async function selectFields(context, section, values, ask) {
|
|
748
|
+
const all = section?.fields ?? [];
|
|
749
|
+
const requested = strings(values.field);
|
|
750
|
+
const unknown = requested.filter((name) => !all.some((field) => field.path === name));
|
|
751
|
+
if (unknown.length) {
|
|
752
|
+
throw new CommandError(`${section?.id ?? "this section"} has no field ${unknown.join(", ")}; its fields are ${all.map((field) => field.path).join(", ") || "(unknown)"}`);
|
|
753
|
+
}
|
|
754
|
+
if (requested.length || !ask || !all.length) return all.filter((field) => requested.includes(field.path));
|
|
755
|
+
const { fieldsNotShown } = await import('../chunks/authoring.mjs');
|
|
756
|
+
const { input } = await import('../chunks/prompt.mjs');
|
|
757
|
+
const hidden = fieldsNotShown(all);
|
|
758
|
+
context.io.out(`
|
|
759
|
+
Fields of ${section.id}: ${all.map((field) => field.path).join(", ")}${hidden.length ? `
|
|
760
|
+
Not shown by the standard layout: ${hidden.join(", ")}` : ""}
|
|
761
|
+
`);
|
|
762
|
+
const answer = await input(context.io, "Which fields should it show? (separated by commas, - for none)", hidden.join(", ") || "-");
|
|
763
|
+
const chosen = answer === "-" ? [] : answer.split(",").map((name) => name.trim()).filter(Boolean);
|
|
764
|
+
const missing = chosen.filter((name) => !all.some((field) => field.path === name));
|
|
765
|
+
if (missing.length) throw new CommandError(`${section.id} has no field ${missing.join(", ")}`);
|
|
766
|
+
return all.filter((field) => chosen.includes(field.path));
|
|
767
|
+
}
|
|
768
|
+
const customize = {
|
|
769
|
+
name: "customize",
|
|
770
|
+
summary: "make one section yours: how it looks on the website and in the PDF, what it may contain",
|
|
771
|
+
positionals: [{ name: "section", required: true }],
|
|
772
|
+
options: {
|
|
773
|
+
"web": { type: "boolean", description: "how it looks on the website: a component of your own" },
|
|
774
|
+
"pdf": { type: "boolean", description: "how it looks in the PDF: cv-tex/templates/<section>.eta" },
|
|
775
|
+
"schema": { type: "boolean", description: "what its entries may contain: schemas/<section>.ts" },
|
|
776
|
+
"field": { type: "string", multiple: true, value: "<name>", description: "show this field (repeatable)" },
|
|
777
|
+
"list": { type: "boolean", description: "web: your own list markup, for grouping or numbering" },
|
|
778
|
+
"group-by": { type: "string", value: "<field>", description: "web, with --list: one group per value of this field, e.g. term" },
|
|
779
|
+
"region": { type: "string", value: "note|meta", description: "web: the line of each entry the fields go on (note)" },
|
|
780
|
+
"component": { type: "string", value: "<Name>", description: "web: the component's name; <Section>Section by default" },
|
|
781
|
+
"use": { type: "string", value: "<Name>", description: "web: show it with a component you have already" },
|
|
782
|
+
"page": { type: "string", multiple: true, value: "<route>", description: "web: also show it on this page" },
|
|
783
|
+
"from": { type: "string", value: "<section>", description: "pdf: start from a copy of that section's template" },
|
|
784
|
+
"copy": { type: "boolean", description: "schema: a complete copy of a built-in schema, instead of additions" },
|
|
785
|
+
"force": { type: "boolean", description: "replace your component or template; yours is kept as a backup" },
|
|
786
|
+
"dry-run": { type: "boolean", description: "show what would be written, write nothing" },
|
|
787
|
+
"yes": { type: "boolean", description: "ask nothing: use the options given and write" }
|
|
788
|
+
},
|
|
789
|
+
project: true,
|
|
790
|
+
help: (run) => `
|
|
791
|
+
${run.bin("vitakit", "customize grants")} asks what to change
|
|
792
|
+
${run.bin("vitakit", "customize grants --web --pdf --field amount")} show a field in both
|
|
793
|
+
${run.bin("vitakit", "customize talks --web --list --component TalkList")}
|
|
794
|
+
${run.bin("vitakit", "customize talks-public --web --use TalkList")} reuse a component
|
|
795
|
+
${run.bin("vitakit", "customize teaching --schema")} add fields to a built-in section
|
|
796
|
+
${run.bin("vitakit", "customize publications --schema")} groups, badges and fields
|
|
797
|
+
|
|
798
|
+
On the website, a section is shown by a component. --web creates one of your
|
|
799
|
+
own in app/components/ -- either around VitaSection, which keeps vitakit's list
|
|
800
|
+
and changes one line of every entry, or (--list) with a list of its own -- and
|
|
801
|
+
uses it everywhere the section appears. One component can show several
|
|
802
|
+
sections with the same fields (--use).
|
|
803
|
+
|
|
804
|
+
In the PDF, a section is written by a template. --pdf copies the one in use to
|
|
805
|
+
cv-tex/templates/<section>.eta, where yours wins.
|
|
806
|
+
|
|
807
|
+
With --field, the new files show those fields. Files you have already are
|
|
808
|
+
never replaced without --force; for them, \`${run.bin("vitakit", "explain <section> --field <name>")}\`
|
|
809
|
+
prints lines to add.`,
|
|
810
|
+
async run(context, { positionals, values }) {
|
|
811
|
+
const id = positionals[0];
|
|
812
|
+
const { io, root } = context;
|
|
813
|
+
const interactive = asking(context, values);
|
|
814
|
+
let web = values.web === true;
|
|
815
|
+
let pdf = values.pdf === true;
|
|
816
|
+
let schema = values.schema === true;
|
|
817
|
+
const webOnly = ["list", "group-by", "region", "component", "use", "page"].filter((name) => values[name] !== void 0);
|
|
818
|
+
if (webOnly.length && !web) throw new UsageError(`--${webOnly[0]} goes with --web`, "customize");
|
|
819
|
+
if (values["group-by"] !== void 0 && !values.list) throw new UsageError("--group-by goes with --list", "customize");
|
|
820
|
+
if (values.from !== void 0 && !pdf) throw new UsageError("--from goes with --pdf", "customize");
|
|
821
|
+
if (values.copy && !schema) throw new UsageError("--copy goes with --schema", "customize");
|
|
822
|
+
if (!web && !pdf && !schema && id === "publications") schema = true;
|
|
823
|
+
if (!web && !pdf && !schema) {
|
|
824
|
+
if (!interactive) throw new UsageError(`what about ${id}? --web, --pdf or --schema`, "customize");
|
|
825
|
+
const { choose } = await import('../chunks/prompt.mjs');
|
|
826
|
+
const choice = await choose(io, `What do you want to change about ${id}?`, [
|
|
827
|
+
{ label: "how it looks on the website", value: "web" },
|
|
828
|
+
{ label: "how it looks in the PDF", value: "pdf" },
|
|
829
|
+
{ label: "how it looks on the website and in the PDF", value: "both" },
|
|
830
|
+
{ label: "which fields its entries may have", value: "schema" }
|
|
831
|
+
]);
|
|
832
|
+
web = choice === "web" || choice === "both";
|
|
833
|
+
pdf = choice === "pdf" || choice === "both";
|
|
834
|
+
schema = choice === "schema";
|
|
835
|
+
}
|
|
836
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
837
|
+
const { Workspace } = await import('../chunks/plan.mjs');
|
|
838
|
+
const inv = await inventory(root);
|
|
839
|
+
const ws = new Workspace(root);
|
|
840
|
+
const notes = { before: [], after: [] };
|
|
841
|
+
if (id === "publications" && web) {
|
|
842
|
+
throw new CommandError("publications are shown by VitaPublications and VitaPublication: `vitakit eject VitaPublication` changes how one reference looks, `vitakit eject VitaPublications` the whole list");
|
|
843
|
+
}
|
|
844
|
+
const section = inv.sections.find((candidate) => candidate.id === id);
|
|
845
|
+
if (!section && id !== "publications" && (web || pdf || !(await import('../chunks/schemas.mjs')).schemas[id])) {
|
|
846
|
+
throw new CommandError(`no section called "${id}"; the sections are ${inv.sections.map((candidate) => candidate.id).join(", ") || "(none)"}`);
|
|
847
|
+
}
|
|
848
|
+
if (section?.problems.length && (web || pdf)) {
|
|
849
|
+
notes.before.push(`${section.where} has problems; \`vitakit validate ${id}\` lists them. The new files work once it is valid.`);
|
|
850
|
+
}
|
|
851
|
+
if (schema) await planSchema(context, ws, inv, id, values, notes);
|
|
852
|
+
let list = values.list === true;
|
|
853
|
+
if (web && !values.use && values.list === void 0 && interactive) {
|
|
854
|
+
const { choose } = await import('../chunks/prompt.mjs');
|
|
855
|
+
list = await choose(io, "What do you want to change on the website?", [
|
|
856
|
+
{ label: "how each entry reads (title, details, links); vitakit keeps the list", value: false },
|
|
857
|
+
{ label: "how the list is organised (grouping, numbering, badges); the list is yours", value: true }
|
|
858
|
+
]);
|
|
859
|
+
}
|
|
860
|
+
const fields = web || pdf ? await selectFields(context, section, values, interactive && (pdf || web && !list && !values.use)) : [];
|
|
861
|
+
if (web) await planWeb(context, ws, inv, section, fields, list, values, notes);
|
|
862
|
+
if (pdf) await planPdf(context, ws, inv, id, fields, values, notes);
|
|
863
|
+
return finish(context, ws, values, notes);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const eject = {
|
|
868
|
+
name: "eject",
|
|
869
|
+
summary: "take over a shared vitakit file: a component, the citation style, the LaTeX class",
|
|
870
|
+
positionals: [{ name: "asset" }],
|
|
871
|
+
options: {
|
|
872
|
+
"list": { type: "boolean", description: "show what can be ejected, and what each one affects" },
|
|
873
|
+
"force": { type: "boolean", description: "replace your existing copy; the old one is kept as a backup" },
|
|
874
|
+
"dry-run": { type: "boolean", description: "show what would be written, write nothing" },
|
|
875
|
+
"yes": { type: "boolean", description: "do not ask for confirmation" }
|
|
876
|
+
},
|
|
877
|
+
project: true,
|
|
878
|
+
help: (run) => `
|
|
879
|
+
Copies a file vitakit shares between all sections or all documents into your
|
|
880
|
+
project, where it then wins. It is yours from then on: later improvements to it
|
|
881
|
+
in vitakit will not reach you, so eject deliberately.
|
|
882
|
+
|
|
883
|
+
${run.bin("vitakit", "eject --list")}
|
|
884
|
+
${run.bin("vitakit", "eject VitaPublication")} how one reference looks on the website
|
|
885
|
+
${run.bin("vitakit", "eject publications.csl")} the website's citation style
|
|
886
|
+
${run.bin("vitakit", "eject vitakit-cv.cls")} the LaTeX document class of every PDF
|
|
887
|
+
|
|
888
|
+
To change one section, prefer \`${run.bin("vitakit", "customize <section>")}\`; CSS and
|
|
889
|
+
cv-tex/preamble.tex cover most styling without taking anything over.`,
|
|
890
|
+
async run({ io, style, run, root }, { positionals, values }) {
|
|
891
|
+
const { ejectableAssets } = await import('../chunks/eject.mjs');
|
|
892
|
+
const assets = await ejectableAssets();
|
|
893
|
+
const name = positionals[0];
|
|
894
|
+
if (values.list || name === void 0) {
|
|
895
|
+
if (!values.list) throw new UsageError("which file? See `--list`", "eject");
|
|
896
|
+
io.out(`${table(assets.map((asset2) => [asset2.name, `\u2192 ${asset2.target}`, asset2.scope]))}
|
|
897
|
+
|
|
898
|
+
Copy one with \`${run.bin("vitakit", "eject <name>")}\`.
|
|
899
|
+
`);
|
|
900
|
+
return 0;
|
|
901
|
+
}
|
|
902
|
+
const asset = assets.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
|
|
903
|
+
if (!asset) {
|
|
904
|
+
throw new CommandError(`"${name}" cannot be ejected. Ejectable: ${assets.map((candidate) => candidate.name).join(", ")}.${/^[a-z0-9-]+(\.eta)?$/.test(name) ? `
|
|
905
|
+
For one section, use \`${run.bin("vitakit", `customize ${name.replace(/\.eta$/, "")}`)}\`.` : ""}`);
|
|
906
|
+
}
|
|
907
|
+
const { applyPlan, printPlan, confirmPlan } = await import('../chunks/plan.mjs');
|
|
908
|
+
const plan = await (await import('../chunks/eject.mjs')).ejectPlan(root, asset, { force: values.force === true });
|
|
909
|
+
printPlan(io, style, plan);
|
|
910
|
+
if (values["dry-run"]) return 0;
|
|
911
|
+
if (!await confirmPlan(io, plan, values.yes === true)) return 1;
|
|
912
|
+
await applyPlan(root, plan);
|
|
913
|
+
io.out(`${style.green("\u2713")} done. ${asset.after}
|
|
914
|
+
`);
|
|
915
|
+
return 0;
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
function webPlacementsOf(inventory, section) {
|
|
920
|
+
return inventory.web.placements.filter((placement) => placement.section === section);
|
|
921
|
+
}
|
|
922
|
+
function pdfPlacementsOf(inventory, section) {
|
|
923
|
+
return inventory.documents.flatMap((document) => document.placements.filter((placement) => placement.section === section));
|
|
924
|
+
}
|
|
925
|
+
function webCell(inventory, section) {
|
|
926
|
+
const placements = webPlacementsOf(inventory, section);
|
|
927
|
+
if (!placements.length) return inventory.web.dynamic.length ? "unknown" : "\u2013";
|
|
928
|
+
const counts = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const placement of placements) {
|
|
930
|
+
const key = placement.route ?? placement.file.replace(/^app\/components\//, "").replace(/\.vue$/, "");
|
|
931
|
+
const current = counts.get(key) ?? { count: 0, where: false };
|
|
932
|
+
counts.set(key, { count: current.count + 1, where: current.where || placement.where !== void 0 });
|
|
933
|
+
}
|
|
934
|
+
return [...counts].map(([key, { count, where }]) => `${key}${count > 1 ? ` (\xD7${count}${where ? ", where" : ""})` : where ? " (where)" : ""}`).join(" ");
|
|
935
|
+
}
|
|
936
|
+
function pdfCell(inventory, section) {
|
|
937
|
+
const documents = [...new Set(pdfPlacementsOf(inventory, section).map((placement) => placement.document))];
|
|
938
|
+
return documents.length ? documents.join(", ") : "\u2013";
|
|
939
|
+
}
|
|
940
|
+
function readableIssue(line) {
|
|
941
|
+
return line.replace(/^(\s*)- (\d+)(?:\.([\w.-]+))?: /, (_match, indent, index, field) => `${indent}entry ${Number(index) + 1}${field ? `, ${field}` : ""}: `).replace(/^(\s*)- /, "$1");
|
|
942
|
+
}
|
|
943
|
+
function messageLines(message) {
|
|
944
|
+
const lines = message.split("\n").filter((line) => line.trim() && line.trim() !== "invalid");
|
|
945
|
+
const indent = Math.min(...lines.map((line) => /^\s*/.exec(line)[0].length));
|
|
946
|
+
return lines.map((line) => readableIssue(line.slice(indent)));
|
|
947
|
+
}
|
|
948
|
+
function firstLine(text) {
|
|
949
|
+
const lines = messageLines(text);
|
|
950
|
+
const first = lines[0] ?? text;
|
|
951
|
+
return first.endsWith(":") ? first.slice(0, -1) : `${first}${lines.length > 1 ? ` (+${lines.length - 1} more)` : ""}`;
|
|
952
|
+
}
|
|
953
|
+
function countsText(section) {
|
|
954
|
+
if (section.counts) {
|
|
955
|
+
const total = section.counts.public + section.counts.cv + section.counts.private;
|
|
956
|
+
const parts = ["public", "cv", "private"].filter((level) => section.counts[level] > 0).map((level) => `${section.counts[level]} ${level}`);
|
|
957
|
+
return `${total} ${total === 1 ? "entry" : "entries"}${parts.length > 1 || parts.length === 1 && !section.counts.public ? `: ${parts.join(", ")}` : ""}`;
|
|
958
|
+
}
|
|
959
|
+
if (section.entries !== void 0) return `${section.entries} ${section.entries === 1 ? "entry" : "entries"}`;
|
|
960
|
+
return "";
|
|
961
|
+
}
|
|
962
|
+
function problemLines(problems, indent = " ") {
|
|
963
|
+
return problems.flatMap((problem) => [
|
|
964
|
+
`${indent}${problem.where}`,
|
|
965
|
+
...messageLines(problem.message).map((line) => `${indent} ${line}`)
|
|
966
|
+
]);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const describe = {
|
|
970
|
+
__proto__: null,
|
|
971
|
+
countsText: countsText,
|
|
972
|
+
firstLine: firstLine,
|
|
973
|
+
messageLines: messageLines,
|
|
974
|
+
pdfCell: pdfCell,
|
|
975
|
+
pdfPlacementsOf: pdfPlacementsOf,
|
|
976
|
+
problemLines: problemLines,
|
|
977
|
+
readableIssue: readableIssue,
|
|
978
|
+
webCell: webCell,
|
|
979
|
+
webPlacementsOf: webPlacementsOf
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const STARTER = (id) => [
|
|
983
|
+
`import { section, dateRange, richText, text } from '@anja.codes/vitakit'`,
|
|
984
|
+
``,
|
|
985
|
+
`export default section({`,
|
|
986
|
+
` date: dateRange,`,
|
|
987
|
+
` title: richText,`,
|
|
988
|
+
` note: text.optional(),`,
|
|
989
|
+
`})`
|
|
990
|
+
].map((line) => ` ${line}`).join("\n").replace(/^/, ` schemas/${id}.ts, for example:
|
|
991
|
+
|
|
992
|
+
`);
|
|
993
|
+
function schemaText(section) {
|
|
994
|
+
const { schema } = section;
|
|
995
|
+
if (schema.origin === "none") return "none yet";
|
|
996
|
+
if (schema.origin === "built-in") return "built-in";
|
|
997
|
+
if (schema.sharedWith) return `${schema.file}, the same as ${schema.sharedWith}`;
|
|
998
|
+
return schema.extendsBuiltIn ? `built-in, extended in ${schema.file}` : `${schema.file} (yours)`;
|
|
999
|
+
}
|
|
1000
|
+
async function fieldSnippets(context, inv, section, names) {
|
|
1001
|
+
const { style, run, root } = context;
|
|
1002
|
+
const { webFieldSnippet, etaFieldSnippet } = await import('../chunks/snippets.mjs');
|
|
1003
|
+
const { existsSync } = await import('node:fs');
|
|
1004
|
+
const { join: join2 } = await import('node:path');
|
|
1005
|
+
const id = section.id;
|
|
1006
|
+
const unknown = names.filter((name) => !section.fields?.some((field) => field.path === name));
|
|
1007
|
+
if (unknown.length) {
|
|
1008
|
+
throw new CommandError(`${id} has no field ${unknown.join(", ")}; its fields are ${section.fields?.map((field) => field.path).join(", ") || "(unknown)"}`);
|
|
1009
|
+
}
|
|
1010
|
+
const fields = names.map((name) => section.fields.find((field) => field.path === name));
|
|
1011
|
+
const web = fields.map((field) => webFieldSnippet(field));
|
|
1012
|
+
const imports = [...new Set(web.flatMap((snippet) => snippet.imports))];
|
|
1013
|
+
const lines = ["", style.bold(`Showing ${names.join(", ")}`), ""];
|
|
1014
|
+
const components = [...new Set(webPlacementsOf(inv, id).filter((placement) => placement.component !== "code" && placement.component !== "VitaSection").map((placement) => placement.component))];
|
|
1015
|
+
const indented = (snippets) => snippets.map((snippet) => ` ${snippet}`);
|
|
1016
|
+
if (components.length) {
|
|
1017
|
+
for (const component of components) {
|
|
1018
|
+
lines.push(block("website", [[`${inv.web.components.get(component)}: add where the entry's lines are, e.g. in its #note slot`]]));
|
|
1019
|
+
}
|
|
1020
|
+
} else {
|
|
1021
|
+
lines.push(block("website", [[`${id} is shown by VitaSection. \`${run.bin("vitakit", `customize ${id} --web --field ${names.join(" --field ")}`)}\``]]));
|
|
1022
|
+
lines.push(block("", [["creates a component that shows them; or, on the page, give VitaSection a slot:"]]));
|
|
1023
|
+
lines.push(
|
|
1024
|
+
"",
|
|
1025
|
+
` <VitaSection id="${id}">`,
|
|
1026
|
+
' <template #note="{ entry, display }">',
|
|
1027
|
+
' <p v-if="display.note !== undefined" class="vita-entry__note">{{ display.note }}</p>'
|
|
1028
|
+
);
|
|
1029
|
+
lines.push(...web.map((snippet) => ` ${snippet.markup}`), " </template>", " </VitaSection>");
|
|
1030
|
+
}
|
|
1031
|
+
if (components.length) lines.push("", ...indented(web.map((snippet) => snippet.markup)));
|
|
1032
|
+
if (imports.length) lines.push("", ` import { ${imports.join(", ")} } from '@anja.codes/vitakit/shared'`);
|
|
1033
|
+
lines.push("");
|
|
1034
|
+
if (existsSync(join2(root, "cv-tex", "templates", `${id}.eta`))) {
|
|
1035
|
+
lines.push(block("pdf", [[`cv-tex/templates/${id}.eta (yours): add inside its loop over the entries, after the notes`]]));
|
|
1036
|
+
} else if (inv.documents.length) {
|
|
1037
|
+
lines.push(block("pdf", [[`vitakit's template. \`${run.bin("vitakit", `customize ${id} --pdf --field ${names.join(" --field ")}`)}\` copies it with these lines:`]]));
|
|
1038
|
+
}
|
|
1039
|
+
if (inv.documents.length) lines.push("", ...indented(fields.map((field) => etaFieldSnippet(field))));
|
|
1040
|
+
return lines;
|
|
1041
|
+
}
|
|
1042
|
+
async function explainSection(context, inv, section, fields = []) {
|
|
1043
|
+
const { style, run } = context;
|
|
1044
|
+
const lines = [style.bold(section.id), ""];
|
|
1045
|
+
lines.push(block("data", [[`${section.where}${countsText(section) ? ` \xB7 ${countsText(section)}` : ""}`]]));
|
|
1046
|
+
lines.push(block("schema", [[schemaText(section)]]));
|
|
1047
|
+
if (section.fields) {
|
|
1048
|
+
const { fieldSummary } = await import('../chunks/fields.mjs');
|
|
1049
|
+
lines.push(block("", section.fields.flatMap((field) => [
|
|
1050
|
+
[` ${field.path}`, fieldSummary(field)],
|
|
1051
|
+
...(field.fields ?? []).map((child) => [` ${child.path}`, fieldSummary(child)])
|
|
1052
|
+
])));
|
|
1053
|
+
} else if (section.schema.origin === "none") {
|
|
1054
|
+
lines.push("", ` There is no built-in schema for "${section.id}". \`${context.run.bin("vitakit", `add section ${section.id}`)}\``, " drafts one from its entries; or write", STARTER(section.id));
|
|
1055
|
+
}
|
|
1056
|
+
lines.push("");
|
|
1057
|
+
const web = webPlacementsOf(inv, section.id);
|
|
1058
|
+
if (web.length) {
|
|
1059
|
+
lines.push(block("website", web.map((placement) => [
|
|
1060
|
+
placement.route ?? "",
|
|
1061
|
+
`${placement.file}:${placement.line}`,
|
|
1062
|
+
placement.component === "code" ? "useSection, in code" : `<${placement.component}${placement.title !== void 0 ? ` title="${placement.title}"` : ""}${placement.where ? ` :where="${placement.where}"` : ""}${placement.limit ? ` limit ${placement.limit}` : ""}>`
|
|
1063
|
+
])));
|
|
1064
|
+
} else {
|
|
1065
|
+
lines.push(block("website", [[inv.web.dynamic.length ? "not found on any page; some pages choose sections in code (see status)" : "not on any page"]]));
|
|
1066
|
+
}
|
|
1067
|
+
if (inv.documents.length) {
|
|
1068
|
+
const { explainSelection } = await import('../chunks/documents.mjs');
|
|
1069
|
+
const { resolveTemplate } = await import('../chunks/emit.mjs');
|
|
1070
|
+
const template = section.problems.length ? void 0 : await resolveTemplate(section.id, join(inv.root, "cv-tex", "templates"));
|
|
1071
|
+
const templateText = template ? template.origin === "local" ? `cv-tex/templates/${template.generic ? "_generic" : section.id}.eta (yours)` : template.generic ? `vitakit's generic template (there is no ${section.id}.eta)` : `vitakit's ${section.id}.eta` : void 0;
|
|
1072
|
+
const rows = [];
|
|
1073
|
+
for (const document of inv.documents) {
|
|
1074
|
+
const placements = document.placements.filter((placement) => placement.section === section.id);
|
|
1075
|
+
if (!placements.length) {
|
|
1076
|
+
rows.push([document.name, "not included"]);
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
for (const placement of placements) {
|
|
1080
|
+
rows.push([document.name, `${placement.file}:${placement.line} \\vitasection${placement.heading !== void 0 ? `[${placement.heading}]` : ""}{${section.id}}`]);
|
|
1081
|
+
}
|
|
1082
|
+
if (templateText) rows.push(["", `template: ${templateText}`]);
|
|
1083
|
+
const entries = inv.content.sections[section.id];
|
|
1084
|
+
if (entries && !section.problems.length) {
|
|
1085
|
+
const { total, steps } = explainSelection(entries, document.options, section.id);
|
|
1086
|
+
const kept = steps[steps.length - 1]?.kept ?? total;
|
|
1087
|
+
const removed = steps.filter((step) => step.removed > 0).map((step) => `${step.rule} \u2212${step.removed}`);
|
|
1088
|
+
rows.push(["", `${kept} of ${total} entries${removed.length ? ` (${removed.join(", ")})` : ""}`]);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
lines.push("", block("pdf", rows));
|
|
1092
|
+
} else {
|
|
1093
|
+
lines.push("", block("pdf", [["no documents in cv-tex/"]]));
|
|
1094
|
+
}
|
|
1095
|
+
if (fields.length) lines.push(...await fieldSnippets(context, inv, section, fields));
|
|
1096
|
+
const problems = section.problems.filter((problem) => !(section.schema.origin === "none" && problem.message.startsWith("no schema")));
|
|
1097
|
+
if (problems.length) lines.push("", style.bold("Problems"), ...problemLines(problems));
|
|
1098
|
+
lines.push("", `Check it: \`${run.bin("vitakit", `validate ${section.id}`)}\``);
|
|
1099
|
+
return lines;
|
|
1100
|
+
}
|
|
1101
|
+
async function explainDocument(context, inv, document) {
|
|
1102
|
+
const { style, run } = context;
|
|
1103
|
+
const { explainSelection } = await import('../chunks/documents.mjs');
|
|
1104
|
+
const lines = [style.bold(`${document.name}`), ""];
|
|
1105
|
+
lines.push(block("source", [[document.tex]]));
|
|
1106
|
+
lines.push(block("settings", [[document.yml ? `${document.yml} \xB7 visibility: ${document.options.visibility}` : `none (no cv-tex/${document.name}.yml) \xB7 visibility: ${document.options.visibility}`]]), "");
|
|
1107
|
+
if (document.placements.length) {
|
|
1108
|
+
lines.push(block("includes", document.placements.map((placement) => {
|
|
1109
|
+
const section = inv.sections.find((candidate) => candidate.id === placement.section);
|
|
1110
|
+
let detail = "";
|
|
1111
|
+
if (placement.section === "publications") {
|
|
1112
|
+
detail = inv.publications ? `${inv.publications.entries} entries in ${inv.publications.groups} groups` : "no content/publications.bib";
|
|
1113
|
+
} else if (section?.problems.length) {
|
|
1114
|
+
detail = style.red("has problems");
|
|
1115
|
+
} else if (section) {
|
|
1116
|
+
const { total, steps } = explainSelection(inv.content.sections[section.id] ?? [], document.options, section.id);
|
|
1117
|
+
detail = `${steps[steps.length - 1]?.kept ?? total} of ${total} entries`;
|
|
1118
|
+
} else {
|
|
1119
|
+
detail = style.red("no such section");
|
|
1120
|
+
}
|
|
1121
|
+
return [
|
|
1122
|
+
`line ${placement.line}`,
|
|
1123
|
+
placement.section,
|
|
1124
|
+
placement.heading ?? "",
|
|
1125
|
+
detail
|
|
1126
|
+
];
|
|
1127
|
+
})));
|
|
1128
|
+
} else {
|
|
1129
|
+
lines.push(block("includes", [["no \\vitasection or \\vitainput lines found"]]));
|
|
1130
|
+
}
|
|
1131
|
+
const pdf = document.pdf ? `${document.pdf.file}${document.pdf.mtimeMs < inv.sourcesMtimeMs ? ", older than its sources" : ""}` : "not built yet";
|
|
1132
|
+
lines.push("", block("pdf", [[pdf]]));
|
|
1133
|
+
if (document.problems.length) lines.push("", style.bold("Problems"), ...problemLines(document.problems));
|
|
1134
|
+
lines.push("", `Build it: \`${run.bin("vitakit", `pdf ${document.name}`)}\`; per section: \`${run.bin("vitakit", "explain <section>")}\``);
|
|
1135
|
+
return lines;
|
|
1136
|
+
}
|
|
1137
|
+
function explainRoute(context, inv, page, route) {
|
|
1138
|
+
const { style } = context;
|
|
1139
|
+
const lines = [style.bold(route), ""];
|
|
1140
|
+
const rows = [];
|
|
1141
|
+
if (page?.markdown) rows.push(["text", `${page.markdown}${page.title ? ` \xB7 "${page.title}"` : ""}`]);
|
|
1142
|
+
if (page?.vue) rows.push(["page", page.vue]);
|
|
1143
|
+
else if (page?.markdown && inv.catchAll) rows.push(["page", `${inv.catchAll} (shows Markdown pages)`]);
|
|
1144
|
+
if (!page) rows.push(["page", "no Markdown or Vue page has this route"]);
|
|
1145
|
+
else rows.push(["menu", page.inMenu ? "in the menu (content/profile.yml)" : "not in the menu (content/profile.yml, nav)"]);
|
|
1146
|
+
lines.push(...rows.map(([label, text]) => block(label, [[text]])));
|
|
1147
|
+
const placements = inv.web.placements.filter((placement) => placement.route === route);
|
|
1148
|
+
if (placements.length) {
|
|
1149
|
+
lines.push("", block("sections", placements.map((placement) => [
|
|
1150
|
+
placement.section,
|
|
1151
|
+
`line ${placement.line}`,
|
|
1152
|
+
placement.component === "code" ? "useSection, in code" : `<${placement.component}${placement.title !== void 0 ? ` title="${placement.title}"` : ""}>`
|
|
1153
|
+
])));
|
|
1154
|
+
}
|
|
1155
|
+
const dynamic = inv.web.dynamic.filter((usage) => usage.file === page?.vue);
|
|
1156
|
+
if (dynamic.length) {
|
|
1157
|
+
lines.push("", "Chosen in code, so not listed:", ...dynamic.map((usage) => ` line ${usage.line} ${usage.usage}`));
|
|
1158
|
+
}
|
|
1159
|
+
return lines;
|
|
1160
|
+
}
|
|
1161
|
+
const explain = {
|
|
1162
|
+
name: "explain",
|
|
1163
|
+
summary: "where a section, PDF document or page comes from, and where it goes",
|
|
1164
|
+
positionals: [{ name: "name", required: true }],
|
|
1165
|
+
options: {
|
|
1166
|
+
section: { type: "boolean", description: "the name is a section (when a document has the same name)" },
|
|
1167
|
+
document: { type: "boolean", description: "the name is a PDF document (when a section has the same name)" },
|
|
1168
|
+
field: { type: "string", multiple: true, value: "<name>", description: "a section: lines that show this field, on the website and in the PDF" }
|
|
1169
|
+
},
|
|
1170
|
+
project: true,
|
|
1171
|
+
help: (run) => `
|
|
1172
|
+
${run.bin("vitakit", "explain talks")} a section: its file, fields, pages and PDFs
|
|
1173
|
+
${run.bin("vitakit", "explain cv")} a PDF document: what it includes, from where
|
|
1174
|
+
${run.bin("vitakit", "explain /cv/")} a website page: its files and sections
|
|
1175
|
+
${run.bin("vitakit", "explain teaching --field evaluation")} lines that show a field
|
|
1176
|
+
|
|
1177
|
+
For a section it shows which entries each PDF document selects, and which
|
|
1178
|
+
setting removed the others -- the answer to "why is this not in my PDF?".
|
|
1179
|
+
Never prints entry contents, only counts.`,
|
|
1180
|
+
async run(context, { positionals, values }) {
|
|
1181
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
1182
|
+
const inv = await inventory(context.root);
|
|
1183
|
+
const name = positionals[0];
|
|
1184
|
+
let lines;
|
|
1185
|
+
if (name.startsWith("/")) {
|
|
1186
|
+
const route = name.endsWith("/") ? name : `${name}/`;
|
|
1187
|
+
lines = explainRoute(context, inv, inv.pages.find((page) => page.route === route), route);
|
|
1188
|
+
} else {
|
|
1189
|
+
const section = inv.sections.find((candidate) => candidate.id === name);
|
|
1190
|
+
const document = inv.documents.find((candidate) => candidate.name === name);
|
|
1191
|
+
if (values.section && values.document) throw new UsageError("use --section or --document, not both", "explain");
|
|
1192
|
+
if (section && document && !values.section && !values.document) {
|
|
1193
|
+
throw new UsageError(`"${name}" is both a section and a PDF document; add --section or --document`, "explain");
|
|
1194
|
+
}
|
|
1195
|
+
const fields = values.field ?? [];
|
|
1196
|
+
if (fields.length && !(section && !values.document)) throw new UsageError("--field goes with a section", "explain");
|
|
1197
|
+
if (section && !values.document) lines = await explainSection(context, inv, section, fields);
|
|
1198
|
+
else if (document && !values.section) lines = await explainDocument(context, inv, document);
|
|
1199
|
+
else {
|
|
1200
|
+
const known = [
|
|
1201
|
+
inv.sections.length ? `sections: ${inv.sections.map((candidate) => candidate.id).join(", ")}` : "",
|
|
1202
|
+
inv.documents.length ? `documents: ${inv.documents.map((candidate) => candidate.name).join(", ")}` : ""
|
|
1203
|
+
].filter(Boolean).join("; ");
|
|
1204
|
+
throw new CommandError(`nothing called "${name}"${known ? `; ${known}` : ""}. A page is explained by its route, e.g. /cv/.`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
context.io.out(`${lines.join("\n")}
|
|
1208
|
+
`);
|
|
1209
|
+
return 0;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const pdf = {
|
|
1214
|
+
name: "pdf",
|
|
1215
|
+
summary: "build the PDF documents in cv-tex/",
|
|
1216
|
+
positionals: [{ name: "document", variadic: true }],
|
|
1217
|
+
options: {
|
|
1218
|
+
"tex-only": { type: "boolean", description: "write the .tex files but do not run LaTeX; needs no TeX installation" },
|
|
1219
|
+
"verbose": { type: "boolean", description: "show LaTeX's full output while it runs" }
|
|
1220
|
+
},
|
|
1221
|
+
project: true,
|
|
1222
|
+
help: (run) => `
|
|
1223
|
+
Renders content/ into cv-tex/generated/<document>/, one .tex file per section,
|
|
1224
|
+
then runs latexmk. The finished PDF is written next to its source, as
|
|
1225
|
+
cv-tex/<document>.pdf; everything else stays in the generated directory, which
|
|
1226
|
+
is vitakit's -- do not edit anything there.
|
|
1227
|
+
|
|
1228
|
+
${run.bin("vitakit", "pdf")} every document in cv-tex/
|
|
1229
|
+
${run.bin("vitakit", "pdf cv")} just cv.tex
|
|
1230
|
+
${run.bin("vitakit", "pdf cv application")} several
|
|
1231
|
+
${run.bin("vitakit", "pdf --tex-only")} only the .tex files
|
|
1232
|
+
|
|
1233
|
+
A document is a .tex file you own; a copy of cv.tex is a new document. What it
|
|
1234
|
+
contains is decided by an optional cv-tex/<document>.yml. Needs latexmk and
|
|
1235
|
+
biber, from any current TeX distribution.`,
|
|
1236
|
+
async run({ io, style, run, root }, { positionals, values }) {
|
|
1237
|
+
const { buildCv, MissingToolError } = await import('../chunks/build.mjs');
|
|
1238
|
+
const texOnly = values["tex-only"] === true;
|
|
1239
|
+
const local = (path) => relative(root, path);
|
|
1240
|
+
try {
|
|
1241
|
+
await buildCv({
|
|
1242
|
+
rootDir: root,
|
|
1243
|
+
only: positionals,
|
|
1244
|
+
noPdf: texOnly,
|
|
1245
|
+
latexOutput: values.verbose ? "inherit" : "capture",
|
|
1246
|
+
onProgress(event) {
|
|
1247
|
+
if (event.step === "tex") io.out(`${event.document}: wrote ${local(event.dir)}/ (${event.files} files)${texOnly ? "" : ", running latexmk\u2026"}
|
|
1248
|
+
`);
|
|
1249
|
+
else io.out(`${event.document}: ${style.green("\u2713")} ${local(event.file)}
|
|
1250
|
+
`);
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
if (error instanceof MissingToolError) {
|
|
1255
|
+
throw new CommandError(`${error.message}.
|
|
1256
|
+
Without one, \`${run.bin("vitakit", "pdf --tex-only")}\` writes the .tex files, e.g. for Overleaf or CI.`);
|
|
1257
|
+
}
|
|
1258
|
+
if (error instanceof Error) error.message = error.message.replaceAll(`${root}/`, "");
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1261
|
+
return 0;
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const status = {
|
|
1266
|
+
name: "status",
|
|
1267
|
+
summary: "what is in this project, what is broken, where sections appear",
|
|
1268
|
+
positionals: [],
|
|
1269
|
+
options: {},
|
|
1270
|
+
json: true,
|
|
1271
|
+
project: true,
|
|
1272
|
+
help: (run) => `
|
|
1273
|
+
Lists your sections and where each one appears: on which website pages, and in
|
|
1274
|
+
which PDF documents. A section that appears nowhere is fine -- that is your
|
|
1275
|
+
choice -- and is shown as "\u2013". Problems are things a build would fail on.
|
|
1276
|
+
|
|
1277
|
+
Reads your files only: no website build, no LaTeX. Entry contents are never
|
|
1278
|
+
printed, only how many there are.
|
|
1279
|
+
|
|
1280
|
+
Next: \`${run.bin("vitakit", "explain <section>")}\` for the details of one section,
|
|
1281
|
+
\`${run.bin("vitakit", "validate")}\` to check everything before building.`,
|
|
1282
|
+
async run({ io, style, run, root, json }) {
|
|
1283
|
+
const { inventory } = await import('../chunks/inventory.mjs');
|
|
1284
|
+
const inv = await inventory(root);
|
|
1285
|
+
if (json) {
|
|
1286
|
+
io.out(`${JSON.stringify({
|
|
1287
|
+
root,
|
|
1288
|
+
sections: inv.sections.map((section) => ({
|
|
1289
|
+
id: section.id,
|
|
1290
|
+
where: section.where,
|
|
1291
|
+
schema: section.schema,
|
|
1292
|
+
entries: section.entries,
|
|
1293
|
+
counts: section.counts,
|
|
1294
|
+
website: webPlacementsOf(inv, section.id),
|
|
1295
|
+
pdf: pdfPlacementsOf(inv, section.id),
|
|
1296
|
+
problems: section.problems
|
|
1297
|
+
})),
|
|
1298
|
+
unknownWebsiteUsages: inv.web.dynamic,
|
|
1299
|
+
publications: inv.publications ?? null,
|
|
1300
|
+
pages: inv.pages,
|
|
1301
|
+
documents: inv.documents.map((document) => ({
|
|
1302
|
+
name: document.name,
|
|
1303
|
+
tex: document.tex,
|
|
1304
|
+
yml: document.yml ?? null,
|
|
1305
|
+
pdf: document.pdf ? { file: document.pdf.file, upToDate: document.pdf.mtimeMs >= inv.sourcesMtimeMs } : null,
|
|
1306
|
+
problems: document.problems
|
|
1307
|
+
})),
|
|
1308
|
+
problems: inv.problems
|
|
1309
|
+
}, null, 2)}
|
|
1310
|
+
`);
|
|
1311
|
+
return 0;
|
|
1312
|
+
}
|
|
1313
|
+
const lines = [];
|
|
1314
|
+
if (inv.sections.length) {
|
|
1315
|
+
const rows = [[style.bold("Sections"), "", style.bold("website"), style.bold("pdf")]];
|
|
1316
|
+
for (const section of inv.sections) {
|
|
1317
|
+
const mark = section.schema.origin === "none" ? style.yellow("?") : section.problems.length ? style.red("\u2716") : " ";
|
|
1318
|
+
const state = section.schema.origin === "none" ? style.yellow("no schema yet") : section.problems.length ? style.red(`${countsText(section) || "unreadable"}, has problems`) : countsText(section);
|
|
1319
|
+
rows.push([`${mark} ${section.id}`, state, webCell(inv, section.id), pdfCell(inv, section.id)]);
|
|
1320
|
+
}
|
|
1321
|
+
lines.push(table(rows, ""), "");
|
|
1322
|
+
} else {
|
|
1323
|
+
lines.push(`${style.bold("Sections")} none yet: a section is a file content/<name>.yml`, "");
|
|
1324
|
+
}
|
|
1325
|
+
const facts = [];
|
|
1326
|
+
if (inv.publications) {
|
|
1327
|
+
facts.push([style.bold("Publications"), `${inv.publications.file} \xB7 ${inv.publications.entries} entries in ${inv.publications.groups} groups`]);
|
|
1328
|
+
}
|
|
1329
|
+
const pages = inv.pages.filter((page) => page.markdown || page.vue);
|
|
1330
|
+
if (pages.length) {
|
|
1331
|
+
const outside = pages.filter((page) => !page.inMenu).map((page) => page.route);
|
|
1332
|
+
facts.push([style.bold("Pages"), `${pages.map((page) => page.route).join(" ")}${outside.length === 0 ? " (all in the menu)" : ` (not in the menu: ${outside.join(" ")})`}`]);
|
|
1333
|
+
}
|
|
1334
|
+
for (const [index, document] of inv.documents.entries()) {
|
|
1335
|
+
const pdf = document.pdf ? `${document.name}.pdf${document.pdf.mtimeMs < inv.sourcesMtimeMs ? " (older than its sources)" : ""}` : "not built yet";
|
|
1336
|
+
facts.push([index === 0 ? style.bold("PDF") : "", `${document.tex} \u2192 ${pdf}`]);
|
|
1337
|
+
}
|
|
1338
|
+
if (!inv.documents.length) facts.push([style.bold("PDF"), "no documents in cv-tex/"]);
|
|
1339
|
+
lines.push(table(facts, ""), "");
|
|
1340
|
+
if (inv.web.dynamic.length) {
|
|
1341
|
+
lines.push(`Not resolvable without running the site, so "unknown" above:`);
|
|
1342
|
+
lines.push(...inv.web.dynamic.map((usage) => ` ${usage.file}:${usage.line} ${usage.usage}`), "");
|
|
1343
|
+
}
|
|
1344
|
+
const missingSchemas = inv.sections.filter((section) => section.schema.origin === "none");
|
|
1345
|
+
const count = inv.problems.length;
|
|
1346
|
+
if (count) {
|
|
1347
|
+
lines.push(style.bold("Problems"));
|
|
1348
|
+
lines.push(...inv.problems.map((problem) => ` ${style.red("\u2716")} ${problem.where}: ${firstLine(problem.message)}`), "");
|
|
1349
|
+
}
|
|
1350
|
+
if (count) {
|
|
1351
|
+
lines.push(`${count} ${count === 1 ? "problem" : "problems"}. Details: \`${run.bin("vitakit", "validate")}\`${missingSchemas.length ? `; to draft a schema from a section's entries: \`${run.bin("vitakit", `add section ${missingSchemas[0].id}`)}\`` : ""}`);
|
|
1352
|
+
} else {
|
|
1353
|
+
lines.push(style.green("No problems."));
|
|
1354
|
+
}
|
|
1355
|
+
io.out(`${lines.join("\n")}
|
|
1356
|
+
`);
|
|
1357
|
+
return 0;
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
const validate = {
|
|
1362
|
+
name: "validate",
|
|
1363
|
+
summary: "check content, schemas, pages and PDF settings; no build, no LaTeX",
|
|
1364
|
+
positionals: [{ name: "section", variadic: true }],
|
|
1365
|
+
options: {},
|
|
1366
|
+
json: true,
|
|
1367
|
+
project: true,
|
|
1368
|
+
help: (run) => `
|
|
1369
|
+
Checks everything a build would check, without building: every file in
|
|
1370
|
+
content/, the schemas, page settings, which sections pages and PDF documents
|
|
1371
|
+
include, cv-tex/<document>.yml, and every PDF template, rendered in memory.
|
|
1372
|
+
Reports all problems at once and exits with 1 if there is any.
|
|
1373
|
+
|
|
1374
|
+
${run.bin("vitakit", "validate")} everything
|
|
1375
|
+
${run.bin("vitakit", "validate talks")} only what concerns talks
|
|
1376
|
+
|
|
1377
|
+
Not a replacement for \`${run.script("check")}\`, which builds the website and checks
|
|
1378
|
+
what was built, or for building the PDF, which runs LaTeX.`,
|
|
1379
|
+
async run({ io, style, run, root, json }, { positionals }) {
|
|
1380
|
+
const { validateProject } = await import('../chunks/validate.mjs');
|
|
1381
|
+
const { problems, inventory: inv } = await validateProject(root, { sections: positionals });
|
|
1382
|
+
for (const problem of problems) {
|
|
1383
|
+
if (problem.message.startsWith("no schema for this file") && problem.section) {
|
|
1384
|
+
problem.message = `no schema yet; \`${run.bin("vitakit", `add section ${problem.section}`)}\` drafts one from its entries`;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const unknown = positionals.filter((id) => !inv.sections.some((section) => section.id === id));
|
|
1388
|
+
if (unknown.length) {
|
|
1389
|
+
throw new CommandError(`no section called ${unknown.join(", ")}; the sections are ${inv.sections.map((section) => section.id).join(", ") || "(none)"}`);
|
|
1390
|
+
}
|
|
1391
|
+
if (json) {
|
|
1392
|
+
io.out(`${JSON.stringify({ ok: problems.length === 0, problems }, null, 2)}
|
|
1393
|
+
`);
|
|
1394
|
+
return problems.length ? 1 : 0;
|
|
1395
|
+
}
|
|
1396
|
+
if (!problems.length) {
|
|
1397
|
+
const checked = positionals.length ? positionals : inv.sections.map((section) => section.id);
|
|
1398
|
+
const parts = [
|
|
1399
|
+
`${checked.length} ${checked.length === 1 ? "section" : "sections"}`,
|
|
1400
|
+
...!positionals.length && inv.publications ? [`${inv.publications.entries} publications`] : [],
|
|
1401
|
+
...!positionals.length ? [`${inv.pages.filter((page) => page.markdown || page.vue).length} pages`] : [],
|
|
1402
|
+
`${inv.documents.length} PDF ${inv.documents.length === 1 ? "document" : "documents"}`
|
|
1403
|
+
];
|
|
1404
|
+
io.out(`${style.green("\u2713")} ${parts.join(", ")}: no problems
|
|
1405
|
+
`);
|
|
1406
|
+
return 0;
|
|
1407
|
+
}
|
|
1408
|
+
const lines = problemLines(problems).map((line) => line.startsWith(" ") ? line : `${style.red("\u2716")}${line.slice(1)}`);
|
|
1409
|
+
io.out(`${lines.join("\n")}
|
|
1410
|
+
|
|
1411
|
+
${problems.length} ${problems.length === 1 ? "problem" : "problems"}
|
|
1412
|
+
`);
|
|
1413
|
+
return 1;
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const verify = {
|
|
1418
|
+
name: "verify",
|
|
1419
|
+
summary: "check the generated website, after nuxt generate",
|
|
1420
|
+
positionals: [],
|
|
1421
|
+
options: {
|
|
1422
|
+
"out": { type: "string", value: "<dir>", description: "the generated site; .output/public by default" },
|
|
1423
|
+
"no-leak-check": { type: "boolean", description: "skip the check for cv and private entries (see below)" }
|
|
1424
|
+
},
|
|
1425
|
+
project: true,
|
|
1426
|
+
help: (run) => `
|
|
1427
|
+
Checks the built HTML, not your content, because a schema cannot catch a
|
|
1428
|
+
renderer that silently did nothing:
|
|
1429
|
+
|
|
1430
|
+
- every page in content/pages/ was generated
|
|
1431
|
+
- mathematics was typeset, and KaTeX reported no errors
|
|
1432
|
+
- the site was built for the deployment base path (NUXT_APP_BASE_URL), and
|
|
1433
|
+
every link and asset (href, src, srcset, poster, \u2026) stays inside it
|
|
1434
|
+
- every link into the site leads to a file or page that was generated
|
|
1435
|
+
- no entry marked \`visibility: cv\` or \`private\` appears anywhere in the site
|
|
1436
|
+
- the bibliography is fully formatted wherever it is shown
|
|
1437
|
+
|
|
1438
|
+
\`${run.script("check")}\` builds the site and runs this. The leak check compares the
|
|
1439
|
+
titles of cv and private entries with the text of every page, so a title that
|
|
1440
|
+
is legitimately public elsewhere looks like a leak; --no-leak-check skips it.`,
|
|
1441
|
+
async run({ io, style, run, root }, { values }) {
|
|
1442
|
+
const { checkStatic } = await import('../check.mjs');
|
|
1443
|
+
const { checked, failures } = await checkStatic({
|
|
1444
|
+
rootDir: root,
|
|
1445
|
+
outDir: values.out,
|
|
1446
|
+
skipLeakCheck: values["no-leak-check"] === true
|
|
1447
|
+
});
|
|
1448
|
+
for (const line of checked) io.out(` ${style.green("ok")} ${line}
|
|
1449
|
+
`);
|
|
1450
|
+
if (failures.length) {
|
|
1451
|
+
io.err(`
|
|
1452
|
+
${failures.length} ${failures.length === 1 ? "problem" : "problems"}:
|
|
1453
|
+
`);
|
|
1454
|
+
for (const failure of failures) {
|
|
1455
|
+
io.err(` ${style.red("\u2716")} ${failure.replace("run `nuxt generate` first", `build it first with \`${run.script("generate")}\``)}
|
|
1456
|
+
`);
|
|
1457
|
+
}
|
|
1458
|
+
return 1;
|
|
1459
|
+
}
|
|
1460
|
+
io.out(`
|
|
1461
|
+
${style.green("The generated site looks correct.")}
|
|
1462
|
+
`);
|
|
1463
|
+
return 0;
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
const COMMANDS = [status, explain, validate, add, customize, pdf, verify, eject];
|
|
1468
|
+
const GROUPS = [
|
|
1469
|
+
{ title: "Look", names: ["status", "explain", "validate"] },
|
|
1470
|
+
{ title: "Build and check", names: ["pdf", "verify"] },
|
|
1471
|
+
{ title: "Change", names: ["add", "customize", "eject"] }
|
|
1472
|
+
];
|
|
1473
|
+
|
|
1474
|
+
function version() {
|
|
1475
|
+
return JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version;
|
|
1476
|
+
}
|
|
1477
|
+
function overview(run) {
|
|
1478
|
+
const width = Math.max(...COMMANDS.map((command) => command.name.length));
|
|
1479
|
+
const groups = GROUPS.map(({ title, names }) => [
|
|
1480
|
+
title,
|
|
1481
|
+
...names.map((name) => {
|
|
1482
|
+
const command = COMMANDS.find((candidate) => candidate.name === name);
|
|
1483
|
+
return ` ${command.name.padEnd(width)} ${command.summary}`;
|
|
1484
|
+
})
|
|
1485
|
+
].join("\n"));
|
|
1486
|
+
return [
|
|
1487
|
+
"vitakit \u2014 your website and CV from the files in content/",
|
|
1488
|
+
"",
|
|
1489
|
+
"Usage",
|
|
1490
|
+
` ${run.bin("vitakit", "<command> [options]")}`,
|
|
1491
|
+
"",
|
|
1492
|
+
...groups.flatMap((group) => [group, ""]),
|
|
1493
|
+
`Start with \`${run.bin("vitakit", "status")}\`. Every command has --help.`,
|
|
1494
|
+
""
|
|
1495
|
+
].join("\n");
|
|
1496
|
+
}
|
|
1497
|
+
const MOVED = {
|
|
1498
|
+
list: "eject --list"
|
|
1499
|
+
};
|
|
1500
|
+
async function main(argv, io = processIo()) {
|
|
1501
|
+
const [name, ...rest] = argv;
|
|
1502
|
+
const run = commands(detectPackageManager(io.cwd, io.env));
|
|
1503
|
+
const color = io.tty && !io.env.NO_COLOR && !argv.includes("--no-color");
|
|
1504
|
+
if (name === void 0 || name === "help" || name === "--help" || name === "-h") {
|
|
1505
|
+
const topic = name === "help" ? rest[0] : void 0;
|
|
1506
|
+
const command = topic ? COMMANDS.find((candidate) => candidate.name === topic) : void 0;
|
|
1507
|
+
if (command) {
|
|
1508
|
+
io.out(commandHelp(command, run));
|
|
1509
|
+
return 0;
|
|
1510
|
+
}
|
|
1511
|
+
const paint = style(color);
|
|
1512
|
+
const room = io.columns === void 0 || io.columns >= BANNER_WIDTH;
|
|
1513
|
+
io.out(`${room ? `${banner(version(), { fox: paint.orange, bold: paint.bold })}
|
|
1514
|
+
|
|
1515
|
+
` : ""}${overview(run)}`);
|
|
1516
|
+
return topic ? 2 : 0;
|
|
1517
|
+
}
|
|
1518
|
+
if (name === "--version" || name === "-v") {
|
|
1519
|
+
io.out(`${version()}
|
|
1520
|
+
`);
|
|
1521
|
+
return 0;
|
|
1522
|
+
}
|
|
1523
|
+
const spec = COMMANDS.find((candidate) => candidate.name === name);
|
|
1524
|
+
try {
|
|
1525
|
+
if (!spec) {
|
|
1526
|
+
const moved = MOVED[name];
|
|
1527
|
+
throw new UsageError(moved ? `"${name}" is now \`${run.bin("vitakit", moved)}\`` : `unknown command "${name}"`);
|
|
1528
|
+
}
|
|
1529
|
+
const args = parseCommandArgs(spec, rest);
|
|
1530
|
+
if (args.values.help) {
|
|
1531
|
+
io.out(commandHelp(spec, run));
|
|
1532
|
+
return 0;
|
|
1533
|
+
}
|
|
1534
|
+
const root = spec.project ? resolveRoot(io.cwd, args.values.root) : io.cwd;
|
|
1535
|
+
const projectRun = spec.project ? commands(detectPackageManager(root, io.env)) : run;
|
|
1536
|
+
return await spec.run({ io, style: style(color), run: projectRun, root, json: args.values.json === true }, args);
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
if (error instanceof UsageError) {
|
|
1539
|
+
const help = error.command ? run.bin("vitakit", `${error.command} --help`) : run.bin("vitakit", "--help");
|
|
1540
|
+
io.err(`vitakit: ${error.message}
|
|
1541
|
+
See \`${help}\`.
|
|
1542
|
+
`);
|
|
1543
|
+
return 2;
|
|
1544
|
+
}
|
|
1545
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1546
|
+
io.err(`vitakit: ${message}
|
|
1547
|
+
`);
|
|
1548
|
+
if (!(error instanceof CommandError) && io.env.VITAKIT_DEBUG && error instanceof Error) io.err(`${error.stack}
|
|
1549
|
+
`);
|
|
1550
|
+
return 1;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
export { CommandError as C, aligned as a, commands as c, detectPackageManager as d, main as m };
|