@takuhon/cli 0.11.0 → 0.13.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/admin-bundle/assets/index-C3RPKgaS.js +9 -0
- package/admin-bundle/assets/{style-Cyep5p4h.css → style-B1PQ2qaY.css} +1 -1
- package/admin-bundle/index.html +2 -2
- package/dist/{chunk-JI2AH5Y3.js → chunk-TNTKQL7R.js} +87 -6
- package/dist/chunk-TNTKQL7R.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1021 -178
- package/dist/index.js.map +1 -1
- package/dist/init.js +1 -1
- package/package.json +5 -4
- package/admin-bundle/assets/index-C2-9nYVF.js +0 -9
- package/dist/chunk-JI2AH5Y3.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,18 +1,344 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
ADMIN_DIST_DIRNAME,
|
|
3
4
|
resolveAdminBundleDir
|
|
4
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-TNTKQL7R.js";
|
|
5
6
|
|
|
6
7
|
// src/index.ts
|
|
7
|
-
import { readFileSync as
|
|
8
|
+
import { readFileSync as readFileSync15, realpathSync } from "fs";
|
|
8
9
|
import { stdin, stdout } from "process";
|
|
9
10
|
import { createInterface } from "readline/promises";
|
|
10
11
|
import { fileURLToPath } from "url";
|
|
11
12
|
|
|
13
|
+
// src/activity-command.ts
|
|
14
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
15
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
16
|
+
import { fetchActivitySnapshot, isEmptySnapshot } from "@takuhon/activity";
|
|
17
|
+
|
|
18
|
+
// src/file-activity-storage.ts
|
|
19
|
+
import { readFileSync } from "fs";
|
|
20
|
+
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
21
|
+
import { isActivitySnapshot } from "@takuhon/core";
|
|
22
|
+
|
|
23
|
+
// src/backup.ts
|
|
24
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
25
|
+
import { basename, dirname, join } from "path";
|
|
26
|
+
var BACKUP_DIR_NAME = ".takuhon-backups";
|
|
27
|
+
var BackupError = class extends Error {
|
|
28
|
+
constructor(message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "BackupError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
function compactTimestamp(date, withMillis = false) {
|
|
34
|
+
const iso = date.toISOString();
|
|
35
|
+
const trimmed = withMillis ? iso : iso.replace(/\.\d{3}Z$/, "Z");
|
|
36
|
+
return trimmed.replace(/[-:]/g, "");
|
|
37
|
+
}
|
|
38
|
+
function migrateBackupName(version, date, withMillis = false) {
|
|
39
|
+
return `takuhon-backup-v${version}-${compactTimestamp(date, withMillis)}.json`;
|
|
40
|
+
}
|
|
41
|
+
function preRestoreName(date, withMillis = false) {
|
|
42
|
+
return `pre-restore-${compactTimestamp(date, withMillis)}.json`;
|
|
43
|
+
}
|
|
44
|
+
function preImportName(date, withMillis = false) {
|
|
45
|
+
return `pre-import-${compactTimestamp(date, withMillis)}.json`;
|
|
46
|
+
}
|
|
47
|
+
function preAdminSaveName(date, withMillis = false) {
|
|
48
|
+
return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
|
|
49
|
+
}
|
|
50
|
+
function backupDirFor(targetPath) {
|
|
51
|
+
return join(dirname(targetPath), BACKUP_DIR_NAME);
|
|
52
|
+
}
|
|
53
|
+
function createBackup(params) {
|
|
54
|
+
const dir = backupDirFor(params.targetPath);
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
const primary = join(dir, params.name(false));
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(primary, params.content, { flag: "wx" });
|
|
59
|
+
return primary;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!isAlreadyExists(error)) throw error;
|
|
62
|
+
}
|
|
63
|
+
const fallback = join(dir, params.name(true));
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(fallback, params.content, { flag: "wx" });
|
|
66
|
+
return fallback;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isAlreadyExists(error)) {
|
|
69
|
+
throw new BackupError(
|
|
70
|
+
`backup target already exists and could not be disambiguated: ${fallback}`,
|
|
71
|
+
{ cause: error }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isAlreadyExists(error) {
|
|
78
|
+
return typeof error === "object" && error !== null && error.code === "EEXIST";
|
|
79
|
+
}
|
|
80
|
+
function writeFileAtomic(target, content) {
|
|
81
|
+
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(tmp, content, "utf8");
|
|
84
|
+
renameSync(tmp, target);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
rmSync(tmp, { force: true });
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/file-activity-storage.ts
|
|
92
|
+
var ACTIVITY_FILENAME = "activity.json";
|
|
93
|
+
function isNotFound(err) {
|
|
94
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
95
|
+
}
|
|
96
|
+
function activityPathFor(profilePath) {
|
|
97
|
+
return join2(dirname2(resolve(profilePath)), ACTIVITY_FILENAME);
|
|
98
|
+
}
|
|
99
|
+
function readSnapshotFile(path) {
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = readFileSync(path, "utf8");
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (isNotFound(err)) return null;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return isActivitySnapshot(parsed) ? parsed : null;
|
|
114
|
+
}
|
|
115
|
+
function readActivitySnapshotSync(profilePath) {
|
|
116
|
+
return readSnapshotFile(activityPathFor(profilePath));
|
|
117
|
+
}
|
|
118
|
+
var FileActivityStorage = class {
|
|
119
|
+
/** Absolute path of the `activity.json` this storage reads and writes. */
|
|
120
|
+
path;
|
|
121
|
+
constructor(profilePath) {
|
|
122
|
+
this.path = activityPathFor(profilePath);
|
|
123
|
+
}
|
|
124
|
+
// The filesystem work is synchronous, but the ActivityStorage contract is
|
|
125
|
+
// async. Each method runs its body inside `Promise.resolve().then(...)` so a
|
|
126
|
+
// synchronous throw becomes a rejected promise rather than an exception the
|
|
127
|
+
// caller must catch outside `await` (same pattern as FileStorage).
|
|
128
|
+
getActivitySnapshot() {
|
|
129
|
+
return Promise.resolve().then(() => readSnapshotFile(this.path));
|
|
130
|
+
}
|
|
131
|
+
saveActivitySnapshot(snapshot) {
|
|
132
|
+
return Promise.resolve().then(() => {
|
|
133
|
+
writeFileAtomic(this.path, `${JSON.stringify(snapshot, null, 2)}
|
|
134
|
+
`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// src/activity-command.ts
|
|
140
|
+
var DEFAULT_PATH = "takuhon.json";
|
|
141
|
+
var GITHUB_TOKEN_ENV = "TAKUHON_GITHUB_TOKEN";
|
|
142
|
+
var WAKATIME_KEY_ENV = "TAKUHON_WAKATIME_KEY";
|
|
143
|
+
var SYNC_USAGE = `Usage: takuhon activity sync [path]
|
|
144
|
+
|
|
145
|
+
Fetch the GitHub / WakaTime activity configured under settings.activity in a
|
|
146
|
+
takuhon.json and store the result as activity.json beside it \u2014 the sibling
|
|
147
|
+
snapshot the renderer reads, kept outside the canonical profile. With no path,
|
|
148
|
+
syncs ./takuhon.json in the current working directory.
|
|
149
|
+
|
|
150
|
+
Secrets are read from the environment, never from flags or the profile:
|
|
151
|
+
${GITHUB_TOKEN_ENV} Optional. Raises the GitHub rate limit and unlocks the
|
|
152
|
+
contribution calendar (which is GraphQL/token-only).
|
|
153
|
+
Languages work without it.
|
|
154
|
+
${WAKATIME_KEY_ENV} Required to read WakaTime coding time.
|
|
155
|
+
|
|
156
|
+
A sync that gathers no data keeps the last-known activity.json \u2014 a good
|
|
157
|
+
snapshot is never overwritten with an empty one.
|
|
158
|
+
|
|
159
|
+
Exit codes: 0 = synced, 1 = no data gathered (last-known snapshot kept),
|
|
160
|
+
2 = bad arguments / profile missing / unreadable / not JSON / activity not
|
|
161
|
+
configured or not enabled in settings.activity.
|
|
162
|
+
`;
|
|
163
|
+
var SHOW_USAGE = `Usage: takuhon activity show [path]
|
|
164
|
+
|
|
165
|
+
Print the stored activity snapshot (the activity.json beside the profile) as
|
|
166
|
+
JSON. With no path, reads beside ./takuhon.json in the current working
|
|
167
|
+
directory.
|
|
168
|
+
|
|
169
|
+
Exit codes: 0 = printed, 1 = no (or invalid) snapshot stored, 2 = bad
|
|
170
|
+
arguments.
|
|
171
|
+
`;
|
|
172
|
+
function defaultSecrets() {
|
|
173
|
+
return {
|
|
174
|
+
githubToken: process.env[GITHUB_TOKEN_ENV],
|
|
175
|
+
wakatimeKey: process.env[WAKATIME_KEY_ENV]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function parsePathArg(args, sub) {
|
|
179
|
+
let path;
|
|
180
|
+
for (const arg of args) {
|
|
181
|
+
if (arg.startsWith("-")) {
|
|
182
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`activity ${sub}\`.` };
|
|
183
|
+
}
|
|
184
|
+
if (path !== void 0) {
|
|
185
|
+
return { error: `takuhon: \`activity ${sub}\` takes at most one path argument.` };
|
|
186
|
+
}
|
|
187
|
+
path = arg;
|
|
188
|
+
}
|
|
189
|
+
return { path: path ?? DEFAULT_PATH };
|
|
190
|
+
}
|
|
191
|
+
function activityDisplayPath(profilePath) {
|
|
192
|
+
return join3(dirname3(profilePath), ACTIVITY_FILENAME);
|
|
193
|
+
}
|
|
194
|
+
function readActivityConfig(path) {
|
|
195
|
+
const fail = (code, stderr) => ({
|
|
196
|
+
outcome: { code, stdout: "", stderr }
|
|
197
|
+
});
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = readFileSync2(path, "utf8");
|
|
201
|
+
} catch {
|
|
202
|
+
return fail(
|
|
203
|
+
2,
|
|
204
|
+
`takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
205
|
+
`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
let data;
|
|
209
|
+
try {
|
|
210
|
+
data = JSON.parse(raw);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
213
|
+
return fail(2, `takuhon: '${path}' is not valid JSON: ${detail}
|
|
214
|
+
`);
|
|
215
|
+
}
|
|
216
|
+
const settings = typeof data === "object" && data !== null ? data.settings : void 0;
|
|
217
|
+
const config = settings?.activity;
|
|
218
|
+
if (config === void 0) {
|
|
219
|
+
return fail(
|
|
220
|
+
2,
|
|
221
|
+
`takuhon: '${path}' has no settings.activity; nothing to sync.
|
|
222
|
+
Opt in by adding settings.activity with "enabled": true and a github/wakatime username.
|
|
223
|
+
`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (config.enabled !== true) {
|
|
227
|
+
return fail(
|
|
228
|
+
2,
|
|
229
|
+
`takuhon: activity is not enabled in '${path}'; nothing to sync.
|
|
230
|
+
Set settings.activity.enabled to true to opt in.
|
|
231
|
+
`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const hasGithub = config.github?.username !== void 0 && config.github.username !== "";
|
|
235
|
+
const hasWakatime = config.wakatime?.username !== void 0 && config.wakatime.username !== "";
|
|
236
|
+
if (!hasGithub && !hasWakatime) {
|
|
237
|
+
return fail(
|
|
238
|
+
2,
|
|
239
|
+
`takuhon: settings.activity in '${path}' names no github or wakatime username; nothing to sync.
|
|
240
|
+
`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return { config };
|
|
244
|
+
}
|
|
245
|
+
function redactSecrets(text, secrets) {
|
|
246
|
+
let out = text;
|
|
247
|
+
for (const secret of [secrets.githubToken, secrets.wakatimeKey]) {
|
|
248
|
+
if (secret !== void 0 && secret !== "") out = out.split(secret).join("***");
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
async function runActivitySync(args = [], deps = {}) {
|
|
253
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
254
|
+
return { code: 0, stdout: SYNC_USAGE, stderr: "" };
|
|
255
|
+
}
|
|
256
|
+
const parsed = parsePathArg(args, "sync");
|
|
257
|
+
if ("error" in parsed) {
|
|
258
|
+
return {
|
|
259
|
+
code: 2,
|
|
260
|
+
stdout: "",
|
|
261
|
+
stderr: `${parsed.error}
|
|
262
|
+
Run \`takuhon activity sync --help\` for usage.
|
|
263
|
+
`
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const read = readActivityConfig(parsed.path);
|
|
267
|
+
if ("outcome" in read) return read.outcome;
|
|
268
|
+
const { config } = read;
|
|
269
|
+
const getSecrets = deps.getSecrets ?? defaultSecrets;
|
|
270
|
+
const secrets = getSecrets();
|
|
271
|
+
const notes = [];
|
|
272
|
+
const githubUser = config.github?.username;
|
|
273
|
+
if (githubUser !== void 0 && githubUser !== "" && config.github?.showContributions !== false && !secrets.githubToken) {
|
|
274
|
+
notes.push(
|
|
275
|
+
`takuhon: ${GITHUB_TOKEN_ENV} is not set; skipping GitHub contributions (languages need no token).
|
|
276
|
+
`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const wakatimeUser = config.wakatime?.username;
|
|
280
|
+
if (wakatimeUser !== void 0 && wakatimeUser !== "" && config.wakatime?.showCodingTime !== false && !secrets.wakatimeKey) {
|
|
281
|
+
notes.push(`takuhon: ${WAKATIME_KEY_ENV} is not set; skipping WakaTime coding time.
|
|
282
|
+
`);
|
|
283
|
+
}
|
|
284
|
+
const snapshot = await fetchActivitySnapshot(config, secrets, {
|
|
285
|
+
fetch: deps.fetch,
|
|
286
|
+
now: deps.now,
|
|
287
|
+
onError: (source, err) => {
|
|
288
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
289
|
+
notes.push(`takuhon: ${source} failed: ${redactSecrets(detail, secrets)}
|
|
290
|
+
`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const storage = new FileActivityStorage(parsed.path);
|
|
294
|
+
const displayPath = activityDisplayPath(parsed.path);
|
|
295
|
+
const stderr = notes.join("");
|
|
296
|
+
if (isEmptySnapshot(snapshot)) {
|
|
297
|
+
const existing = await storage.getActivitySnapshot();
|
|
298
|
+
const kept = existing !== null ? `keeping the last-known snapshot at '${displayPath}'` : "nothing written";
|
|
299
|
+
return {
|
|
300
|
+
code: 1,
|
|
301
|
+
stdout: "",
|
|
302
|
+
stderr: `${stderr}takuhon: the sync gathered no activity data; ${kept}.
|
|
303
|
+
`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
await storage.saveActivitySnapshot(snapshot);
|
|
307
|
+
const gathered = ["languages", "contributions", "codingTime", "rank"].filter((field) => snapshot[field] !== void 0).join(", ");
|
|
308
|
+
return { code: 0, stdout: `synced activity -> ${displayPath} (${gathered})
|
|
309
|
+
`, stderr };
|
|
310
|
+
}
|
|
311
|
+
async function runActivityShow(args = []) {
|
|
312
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
313
|
+
return { code: 0, stdout: SHOW_USAGE, stderr: "" };
|
|
314
|
+
}
|
|
315
|
+
const parsed = parsePathArg(args, "show");
|
|
316
|
+
if ("error" in parsed) {
|
|
317
|
+
return {
|
|
318
|
+
code: 2,
|
|
319
|
+
stdout: "",
|
|
320
|
+
stderr: `${parsed.error}
|
|
321
|
+
Run \`takuhon activity show --help\` for usage.
|
|
322
|
+
`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const snapshot = await new FileActivityStorage(parsed.path).getActivitySnapshot();
|
|
326
|
+
if (snapshot === null) {
|
|
327
|
+
return {
|
|
328
|
+
code: 1,
|
|
329
|
+
stdout: "",
|
|
330
|
+
stderr: `takuhon: no activity snapshot at '${activityDisplayPath(parsed.path)}'. Run \`takuhon activity sync\` first.
|
|
331
|
+
`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return { code: 0, stdout: `${JSON.stringify(snapshot, null, 2)}
|
|
335
|
+
`, stderr: "" };
|
|
336
|
+
}
|
|
337
|
+
|
|
12
338
|
// src/admin-command.ts
|
|
13
|
-
import { randomBytes } from "crypto";
|
|
14
|
-
import { readFileSync as
|
|
15
|
-
import { extname, join as
|
|
339
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
340
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
341
|
+
import { extname as extname2, join as join5, resolve as resolve3, sep as sep2 } from "path";
|
|
16
342
|
import { serve } from "@hono/node-server";
|
|
17
343
|
import {
|
|
18
344
|
adminAssetSecurityHeaders,
|
|
@@ -23,24 +349,38 @@ import {
|
|
|
23
349
|
import { Hono } from "hono";
|
|
24
350
|
|
|
25
351
|
// src/dev-command.ts
|
|
26
|
-
import { readFileSync } from "fs";
|
|
352
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
27
353
|
import { createServer } from "http";
|
|
28
354
|
import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
|
|
29
355
|
|
|
30
356
|
// src/build-html.ts
|
|
31
|
-
import { generateJsonLd } from "@takuhon/core";
|
|
357
|
+
import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
|
|
358
|
+
|
|
359
|
+
// src/html-helpers.ts
|
|
32
360
|
function escapeHtml(value) {
|
|
33
361
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
34
362
|
}
|
|
35
|
-
function escapeJsonLd(json) {
|
|
36
|
-
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
37
|
-
}
|
|
38
363
|
function safeUrl(url) {
|
|
39
364
|
const trimmed = url.trim();
|
|
40
365
|
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)?.[1]?.toLowerCase();
|
|
41
366
|
if (scheme === void 0) return trimmed;
|
|
42
367
|
return scheme === "http" || scheme === "https" || scheme === "mailto" ? trimmed : void 0;
|
|
43
368
|
}
|
|
369
|
+
function dateRange(start, end, isCurrent) {
|
|
370
|
+
const left = start ?? "";
|
|
371
|
+
const right = isCurrent === true || end === null ? "Present" : end ?? "";
|
|
372
|
+
if (left && right) return `${left} \u2013 ${right}`;
|
|
373
|
+
return left || right;
|
|
374
|
+
}
|
|
375
|
+
function nonEmpty(values, separator) {
|
|
376
|
+
const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
|
|
377
|
+
return joined.length > 0 ? joined : void 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/build-html.ts
|
|
381
|
+
function escapeJsonLd(json) {
|
|
382
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
383
|
+
}
|
|
44
384
|
var CSS = `:root{--fg:#1a1a1a;--muted:#666;--accent:#0b5fff;--line:#e5e5e5}
|
|
45
385
|
*{box-sizing:border-box}
|
|
46
386
|
body{margin:0;color:var(--fg);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#fff}
|
|
@@ -64,17 +404,8 @@ ul{padding:0;margin:0;list-style:none}
|
|
|
64
404
|
.rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
|
|
65
405
|
.rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
|
|
66
406
|
nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
|
|
407
|
+
.activity svg{max-width:100%;height:auto}
|
|
67
408
|
footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
|
|
68
|
-
function dateRange(start, end, isCurrent) {
|
|
69
|
-
const left = start ?? "";
|
|
70
|
-
const right = isCurrent === true || end === null ? "Present" : end ?? "";
|
|
71
|
-
if (left && right) return `${left} \u2013 ${right}`;
|
|
72
|
-
return left || right;
|
|
73
|
-
}
|
|
74
|
-
function nonEmpty(values, separator) {
|
|
75
|
-
const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
|
|
76
|
-
return joined.length > 0 ? joined : void 0;
|
|
77
|
-
}
|
|
78
409
|
function renderEntry(entry) {
|
|
79
410
|
const href = entry.url ? safeUrl(entry.url) : void 0;
|
|
80
411
|
const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
|
|
@@ -151,6 +482,12 @@ function renderContact(contact) {
|
|
|
151
482
|
if (items.length === 0) return "";
|
|
152
483
|
return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
|
|
153
484
|
}
|
|
485
|
+
function renderActivity(snapshot) {
|
|
486
|
+
if (!snapshot) return "";
|
|
487
|
+
const svg = renderActivitySvg(snapshot);
|
|
488
|
+
if (svg === "") return "";
|
|
489
|
+
return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
|
|
490
|
+
}
|
|
154
491
|
function renderJsonLdScript(data) {
|
|
155
492
|
const payload = JSON.stringify(generateJsonLd(data));
|
|
156
493
|
return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
|
|
@@ -202,6 +539,7 @@ function renderProfileHtml(input) {
|
|
|
202
539
|
}))
|
|
203
540
|
),
|
|
204
541
|
renderSkills(d.skills),
|
|
542
|
+
renderActivity(input.activitySnapshot),
|
|
205
543
|
entryList(
|
|
206
544
|
"Education",
|
|
207
545
|
d.education.map((e) => {
|
|
@@ -314,13 +652,226 @@ ${footer ? `${footer}
|
|
|
314
652
|
}
|
|
315
653
|
|
|
316
654
|
// src/site.ts
|
|
317
|
-
import { resolveLocale } from "@takuhon/core";
|
|
655
|
+
import { deriveCv, resolveLocale } from "@takuhon/core";
|
|
656
|
+
|
|
657
|
+
// src/cv-html.ts
|
|
658
|
+
var SECTION_TITLES = {
|
|
659
|
+
experience: "Experience",
|
|
660
|
+
education: "Education",
|
|
661
|
+
skills: "Skills",
|
|
662
|
+
certifications: "Certifications",
|
|
663
|
+
publications: "Publications",
|
|
664
|
+
honors: "Honors & Awards",
|
|
665
|
+
courses: "Courses",
|
|
666
|
+
patents: "Patents",
|
|
667
|
+
languages: "Languages",
|
|
668
|
+
volunteering: "Volunteering",
|
|
669
|
+
memberships: "Memberships"
|
|
670
|
+
};
|
|
671
|
+
var CSS2 = `:root{--fg:#1a1a1a;--muted:#555;--accent:#0b5fff;--line:#d9d9d9}
|
|
672
|
+
*{box-sizing:border-box}
|
|
673
|
+
body{margin:0;background:#f3f4f6;color:var(--fg);font:13px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
674
|
+
main{background:#fff;width:210mm;min-height:297mm;margin:1.5rem auto;padding:18mm 16mm;box-shadow:0 1px 6px rgba(0,0,0,.15)}
|
|
675
|
+
h1{font-size:1.7rem;margin:0}
|
|
676
|
+
.tagline{font-size:1.05rem;color:var(--muted);margin:.15rem 0 0}
|
|
677
|
+
.contact{color:var(--muted);font-size:.85rem;margin:.4rem 0 0;display:flex;flex-wrap:wrap;gap:.25rem 1rem}
|
|
678
|
+
.contact a{color:var(--accent)}
|
|
679
|
+
.bio{margin:.75rem 0 0}
|
|
680
|
+
header{border-bottom:2px solid var(--fg);padding-bottom:.6rem;margin-bottom:.4rem}
|
|
681
|
+
section{margin-top:1.1rem}
|
|
682
|
+
h2{font-size:.95rem;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border-bottom:1px solid var(--line);margin:0 0 .5rem;padding-bottom:.15rem}
|
|
683
|
+
ul{padding:0;margin:0;list-style:none}
|
|
684
|
+
.entry{margin:0 0 .7rem;break-inside:avoid}
|
|
685
|
+
.entry .row{display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
|
|
686
|
+
.entry h3{font-size:.95rem;margin:0}
|
|
687
|
+
.entry .dates{color:var(--muted);font-size:.8rem;white-space:nowrap}
|
|
688
|
+
.entry .sub{color:var(--muted);margin:.05rem 0}
|
|
689
|
+
.entry p{margin:.2rem 0 0}
|
|
690
|
+
.entry a{color:var(--accent)}
|
|
691
|
+
.chips{display:flex;flex-wrap:wrap;gap:.3rem}
|
|
692
|
+
.chips li{border:1px solid var(--line);border-radius:1rem;padding:.05rem .55rem;font-size:.8rem}
|
|
693
|
+
@media print{
|
|
694
|
+
body{background:#fff}
|
|
695
|
+
main{width:auto;min-height:0;margin:0;padding:0;box-shadow:none}
|
|
696
|
+
a{color:var(--fg)}
|
|
697
|
+
}
|
|
698
|
+
@page{size:A4;margin:14mm}`;
|
|
699
|
+
function renderEntry2(entry) {
|
|
700
|
+
const href = entry.url ? safeUrl(entry.url) : void 0;
|
|
701
|
+
const title = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
|
|
702
|
+
const dates = entry.dates ? `<span class="dates">${escapeHtml(entry.dates)}</span>` : "";
|
|
703
|
+
const parts = [`<div class="row"><h3>${title}</h3>${dates}</div>`];
|
|
704
|
+
if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
|
|
705
|
+
if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
|
|
706
|
+
return `<li class="entry">${parts.join("")}</li>`;
|
|
707
|
+
}
|
|
708
|
+
function entryListSection(title, entries) {
|
|
709
|
+
return `<section><h2>${escapeHtml(title)}</h2><ul>${entries.map(renderEntry2).join("")}</ul></section>`;
|
|
710
|
+
}
|
|
711
|
+
function chipSection(title, labels) {
|
|
712
|
+
const chips = labels.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
713
|
+
return `<section><h2>${escapeHtml(title)}</h2><ul class="chips">${chips}</ul></section>`;
|
|
714
|
+
}
|
|
715
|
+
function languageLabel(l) {
|
|
716
|
+
return `${l.displayName ?? l.language} \u2014 ${l.proficiency}`;
|
|
717
|
+
}
|
|
718
|
+
function renderSection(section) {
|
|
719
|
+
const title = SECTION_TITLES[section.kind];
|
|
720
|
+
switch (section.kind) {
|
|
721
|
+
case "experience":
|
|
722
|
+
return entryListSection(
|
|
723
|
+
title,
|
|
724
|
+
section.entries.map((c) => ({
|
|
725
|
+
heading: c.role,
|
|
726
|
+
sub: c.organization,
|
|
727
|
+
dates: dateRange(c.startDate, c.endDate, c.isCurrent),
|
|
728
|
+
body: c.description,
|
|
729
|
+
url: c.url
|
|
730
|
+
}))
|
|
731
|
+
);
|
|
732
|
+
case "education":
|
|
733
|
+
return entryListSection(
|
|
734
|
+
title,
|
|
735
|
+
section.entries.map((e) => {
|
|
736
|
+
const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
|
|
737
|
+
return {
|
|
738
|
+
heading: degree ?? e.institution,
|
|
739
|
+
sub: degree ? e.institution : void 0,
|
|
740
|
+
dates: dateRange(e.startDate, e.endDate, e.isCurrent),
|
|
741
|
+
body: e.description,
|
|
742
|
+
url: e.url
|
|
743
|
+
};
|
|
744
|
+
})
|
|
745
|
+
);
|
|
746
|
+
case "skills":
|
|
747
|
+
return chipSection(
|
|
748
|
+
title,
|
|
749
|
+
section.entries.map((s) => s.label)
|
|
750
|
+
);
|
|
751
|
+
case "languages":
|
|
752
|
+
return chipSection(title, section.entries.map(languageLabel));
|
|
753
|
+
case "certifications":
|
|
754
|
+
return entryListSection(
|
|
755
|
+
title,
|
|
756
|
+
section.entries.map((c) => ({
|
|
757
|
+
heading: c.title,
|
|
758
|
+
sub: c.issuingOrganization,
|
|
759
|
+
dates: dateRange(c.issueDate, c.expirationDate),
|
|
760
|
+
url: c.url
|
|
761
|
+
}))
|
|
762
|
+
);
|
|
763
|
+
case "publications":
|
|
764
|
+
return entryListSection(
|
|
765
|
+
title,
|
|
766
|
+
section.entries.map((x) => ({
|
|
767
|
+
heading: x.title,
|
|
768
|
+
sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
|
|
769
|
+
dates: dateRange(x.date),
|
|
770
|
+
body: x.description,
|
|
771
|
+
url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
|
|
772
|
+
}))
|
|
773
|
+
);
|
|
774
|
+
case "honors":
|
|
775
|
+
return entryListSection(
|
|
776
|
+
title,
|
|
777
|
+
section.entries.map((x) => ({
|
|
778
|
+
heading: x.title,
|
|
779
|
+
sub: x.issuer,
|
|
780
|
+
dates: dateRange(x.date),
|
|
781
|
+
body: x.description,
|
|
782
|
+
url: x.url
|
|
783
|
+
}))
|
|
784
|
+
);
|
|
785
|
+
case "courses":
|
|
786
|
+
return entryListSection(
|
|
787
|
+
title,
|
|
788
|
+
section.entries.map((x) => ({
|
|
789
|
+
heading: x.title,
|
|
790
|
+
sub: x.provider,
|
|
791
|
+
dates: dateRange(x.completionDate),
|
|
792
|
+
body: x.description,
|
|
793
|
+
url: x.certificateUrl
|
|
794
|
+
}))
|
|
795
|
+
);
|
|
796
|
+
case "patents":
|
|
797
|
+
return entryListSection(
|
|
798
|
+
title,
|
|
799
|
+
section.entries.map((x) => ({
|
|
800
|
+
heading: x.title,
|
|
801
|
+
sub: nonEmpty([x.patentNumber, x.office, x.status], " \xB7 "),
|
|
802
|
+
dates: dateRange(x.filingDate ?? x.grantDate),
|
|
803
|
+
body: x.description,
|
|
804
|
+
url: x.url
|
|
805
|
+
}))
|
|
806
|
+
);
|
|
807
|
+
case "volunteering":
|
|
808
|
+
return entryListSection(
|
|
809
|
+
title,
|
|
810
|
+
section.entries.map((x) => ({
|
|
811
|
+
heading: x.role,
|
|
812
|
+
sub: nonEmpty([x.organization, x.cause], " \xB7 "),
|
|
813
|
+
dates: dateRange(x.startDate, x.endDate, x.isCurrent),
|
|
814
|
+
body: x.description,
|
|
815
|
+
url: x.url
|
|
816
|
+
}))
|
|
817
|
+
);
|
|
818
|
+
case "memberships":
|
|
819
|
+
return entryListSection(
|
|
820
|
+
title,
|
|
821
|
+
section.entries.map((x) => ({
|
|
822
|
+
heading: x.role ?? x.organization,
|
|
823
|
+
sub: x.role ? x.organization : void 0,
|
|
824
|
+
dates: dateRange(x.startDate, x.endDate, x.isCurrent),
|
|
825
|
+
body: x.description,
|
|
826
|
+
url: x.url
|
|
827
|
+
}))
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function renderHeader2(cv) {
|
|
832
|
+
const h = cv.header;
|
|
833
|
+
const parts = [`<h1>${escapeHtml(h.displayName)}</h1>`];
|
|
834
|
+
if (h.tagline) parts.push(`<p class="tagline">${escapeHtml(h.tagline)}</p>`);
|
|
835
|
+
const contact = [];
|
|
836
|
+
if (h.location) contact.push(`<span>${escapeHtml(h.location)}</span>`);
|
|
837
|
+
if (h.email) {
|
|
838
|
+
contact.push(`<a href="mailto:${escapeHtml(h.email)}">${escapeHtml(h.email)}</a>`);
|
|
839
|
+
}
|
|
840
|
+
const formHref = h.formUrl ? safeUrl(h.formUrl) : void 0;
|
|
841
|
+
if (formHref) contact.push(`<a href="${escapeHtml(formHref)}">Contact</a>`);
|
|
842
|
+
if (contact.length > 0) parts.push(`<div class="contact">${contact.join("")}</div>`);
|
|
843
|
+
if (h.bio) parts.push(`<p class="bio">${escapeHtml(h.bio)}</p>`);
|
|
844
|
+
return `<header>${parts.join("")}</header>`;
|
|
845
|
+
}
|
|
846
|
+
function renderCvHtml(cv) {
|
|
847
|
+
const title = `${cv.header.displayName} \u2014 CV`;
|
|
848
|
+
const body = [renderHeader2(cv), ...cv.sections.map(renderSection)].join("\n");
|
|
849
|
+
return `<!DOCTYPE html>
|
|
850
|
+
<html lang="${escapeHtml(cv.resolvedLocale)}">
|
|
851
|
+
<head>
|
|
852
|
+
<meta charset="utf-8">
|
|
853
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
854
|
+
<title>${escapeHtml(title)}</title>
|
|
855
|
+
<style>${CSS2}</style>
|
|
856
|
+
</head>
|
|
857
|
+
<body>
|
|
858
|
+
<main>
|
|
859
|
+
${body}
|
|
860
|
+
</main>
|
|
861
|
+
</body>
|
|
862
|
+
</html>
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/site.ts
|
|
318
867
|
function generateSite(profile, options = {}) {
|
|
319
868
|
const { baseUrl } = options;
|
|
320
869
|
const defaultLocale = profile.settings.defaultLocale;
|
|
321
870
|
const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
|
|
322
871
|
const jsonLd = profile.settings.enableJsonLd !== false;
|
|
323
|
-
|
|
872
|
+
const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
|
|
873
|
+
const pages = [];
|
|
874
|
+
for (const locale of locales) {
|
|
324
875
|
const localized = resolveLocale(profile, locale);
|
|
325
876
|
const isDefault = locale === defaultLocale;
|
|
326
877
|
const localeNav = locales.map((to) => ({
|
|
@@ -330,13 +881,28 @@ function generateSite(profile, options = {}) {
|
|
|
330
881
|
}));
|
|
331
882
|
const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
|
|
332
883
|
const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
|
|
333
|
-
const html = renderProfileHtml({
|
|
334
|
-
|
|
884
|
+
const html = renderProfileHtml({
|
|
885
|
+
localized,
|
|
886
|
+
canonicalUrl,
|
|
887
|
+
alternates,
|
|
888
|
+
localeNav,
|
|
889
|
+
jsonLd,
|
|
890
|
+
activitySnapshot
|
|
891
|
+
});
|
|
892
|
+
pages.push({
|
|
335
893
|
route: isDefault ? "/" : `/${locale}/`,
|
|
336
894
|
file: isDefault ? "index.html" : `${locale}/index.html`,
|
|
337
895
|
html
|
|
338
|
-
};
|
|
339
|
-
|
|
896
|
+
});
|
|
897
|
+
if (options.cv === true) {
|
|
898
|
+
pages.push({
|
|
899
|
+
route: isDefault ? "/cv/" : `/${locale}/cv/`,
|
|
900
|
+
file: isDefault ? "cv.html" : `${locale}/cv.html`,
|
|
901
|
+
html: renderCvHtml(deriveCv(localized))
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return pages;
|
|
340
906
|
}
|
|
341
907
|
function absoluteUrl(baseUrl, locale, defaultLocale) {
|
|
342
908
|
return locale === defaultLocale ? `${baseUrl}/` : `${baseUrl}/${locale}/`;
|
|
@@ -360,14 +926,14 @@ function localeHref(from, to, defaultLocale) {
|
|
|
360
926
|
}
|
|
361
927
|
|
|
362
928
|
// src/dev-command.ts
|
|
363
|
-
var
|
|
929
|
+
var DEFAULT_PATH2 = "takuhon.json";
|
|
364
930
|
var DEFAULT_PORT = 4321;
|
|
365
931
|
var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
|
|
366
932
|
|
|
367
|
-
Serve a takuhon.json as a local static preview (one page per locale
|
|
368
|
-
surface \`takuhon build\` produces. With
|
|
369
|
-
is re-read and re-rendered on every
|
|
370
|
-
to see changes. Stop with Ctrl-C.
|
|
933
|
+
Serve a takuhon.json as a local static preview (one page per locale, plus a
|
|
934
|
+
print-ready CV page at /cv) \u2014 the same surface \`takuhon build\` produces. With
|
|
935
|
+
no path, serves ./takuhon.json. The file is re-read and re-rendered on every
|
|
936
|
+
request, so edit it and reload the browser to see changes. Stop with Ctrl-C.
|
|
371
937
|
|
|
372
938
|
Options:
|
|
373
939
|
--port <n> Port to listen on (default: ${DEFAULT_PORT}).
|
|
@@ -383,7 +949,7 @@ unreadable / port in use.
|
|
|
383
949
|
function loadSiteState(path, baseUrl) {
|
|
384
950
|
let raw;
|
|
385
951
|
try {
|
|
386
|
-
raw =
|
|
952
|
+
raw = readFileSync3(path, "utf8");
|
|
387
953
|
} catch {
|
|
388
954
|
return { ok: false, status: 500, message: `cannot read '${path}'.` };
|
|
389
955
|
}
|
|
@@ -405,7 +971,10 @@ ${lines.join("\n")}`
|
|
|
405
971
|
};
|
|
406
972
|
}
|
|
407
973
|
const filtered = applyPublicPrivacyFilter(normalize(result.data));
|
|
408
|
-
const
|
|
974
|
+
const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
|
|
975
|
+
const pages = new Map(
|
|
976
|
+
generateSite(filtered, { baseUrl, activitySnapshot, cv: true }).map((p) => [p.route, p.html])
|
|
977
|
+
);
|
|
409
978
|
return { ok: true, pages };
|
|
410
979
|
}
|
|
411
980
|
function resolveRoute(urlPath) {
|
|
@@ -470,7 +1039,7 @@ Run \`takuhon dev --help\` for usage.
|
|
|
470
1039
|
return 2;
|
|
471
1040
|
}
|
|
472
1041
|
try {
|
|
473
|
-
|
|
1042
|
+
readFileSync3(parsed.path, "utf8");
|
|
474
1043
|
} catch {
|
|
475
1044
|
err(
|
|
476
1045
|
`takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
@@ -479,14 +1048,14 @@ Run \`takuhon dev --help\` for usage.
|
|
|
479
1048
|
return 2;
|
|
480
1049
|
}
|
|
481
1050
|
const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
|
|
482
|
-
return await new Promise((
|
|
1051
|
+
return await new Promise((resolve7) => {
|
|
483
1052
|
let closing = false;
|
|
484
1053
|
const shutdown = () => {
|
|
485
1054
|
if (closing) return;
|
|
486
1055
|
closing = true;
|
|
487
1056
|
process.removeListener("SIGINT", shutdown);
|
|
488
1057
|
process.removeListener("SIGTERM", shutdown);
|
|
489
|
-
server.close(() =>
|
|
1058
|
+
server.close(() => resolve7(0));
|
|
490
1059
|
server.closeAllConnections();
|
|
491
1060
|
};
|
|
492
1061
|
server.once("error", (error) => {
|
|
@@ -497,7 +1066,7 @@ Run \`takuhon dev --help\` for usage.
|
|
|
497
1066
|
err(`takuhon: ${error.message}
|
|
498
1067
|
`);
|
|
499
1068
|
}
|
|
500
|
-
|
|
1069
|
+
resolve7(2);
|
|
501
1070
|
});
|
|
502
1071
|
server.listen(parsed.port, "127.0.0.1", () => {
|
|
503
1072
|
out(
|
|
@@ -566,7 +1135,7 @@ function parseArgs(args) {
|
|
|
566
1135
|
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
567
1136
|
}
|
|
568
1137
|
return {
|
|
569
|
-
path: path ??
|
|
1138
|
+
path: path ?? DEFAULT_PATH2,
|
|
570
1139
|
port,
|
|
571
1140
|
// Drop any trailing slash so URL joins are predictable.
|
|
572
1141
|
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
@@ -629,91 +1198,177 @@ function renderNotFoundPage(route, routes) {
|
|
|
629
1198
|
);
|
|
630
1199
|
}
|
|
631
1200
|
|
|
632
|
-
// src/file-storage.ts
|
|
633
|
-
import {
|
|
634
|
-
import {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
function
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
1201
|
+
// src/file-asset-storage.ts
|
|
1202
|
+
import { randomBytes } from "crypto";
|
|
1203
|
+
import {
|
|
1204
|
+
existsSync,
|
|
1205
|
+
mkdirSync as mkdirSync2,
|
|
1206
|
+
readdirSync,
|
|
1207
|
+
readFileSync as readFileSync4,
|
|
1208
|
+
renameSync as renameSync2,
|
|
1209
|
+
rmSync as rmSync2,
|
|
1210
|
+
statSync,
|
|
1211
|
+
writeFileSync as writeFileSync2
|
|
1212
|
+
} from "fs";
|
|
1213
|
+
import { basename as basename2, dirname as dirname4, extname, join as join4, resolve as resolve2, sep } from "path";
|
|
1214
|
+
import {
|
|
1215
|
+
ACCEPTED_IMAGE_MIME_TYPES,
|
|
1216
|
+
detectImageMime,
|
|
1217
|
+
IMAGE_EXTENSIONS,
|
|
1218
|
+
NotFoundError,
|
|
1219
|
+
readImageInfo
|
|
1220
|
+
} from "@takuhon/core";
|
|
1221
|
+
var ASSET_DIRNAME = "assets";
|
|
1222
|
+
var ASSET_KEY_PREFIX = `${ASSET_DIRNAME}/`;
|
|
1223
|
+
var DEFAULT_EXTENSION = "bin";
|
|
1224
|
+
var MIME_BY_EXTENSION = new Map(
|
|
1225
|
+
Object.entries(IMAGE_EXTENSIONS).map(([mime, ext]) => [ext, mime])
|
|
1226
|
+
);
|
|
1227
|
+
function asAcceptedMime(value) {
|
|
1228
|
+
if (value === void 0) return null;
|
|
1229
|
+
return ACCEPTED_IMAGE_MIME_TYPES.includes(value) ? value : null;
|
|
1230
|
+
}
|
|
1231
|
+
function contentTypeForFile(name) {
|
|
1232
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
1233
|
+
return MIME_BY_EXTENSION.get(ext) ?? "application/octet-stream";
|
|
1234
|
+
}
|
|
1235
|
+
function timestampSeconds() {
|
|
1236
|
+
return Math.floor(Date.now() / 1e3);
|
|
1237
|
+
}
|
|
1238
|
+
function shortHash() {
|
|
1239
|
+
return randomBytes(2).toString("hex");
|
|
663
1240
|
}
|
|
664
|
-
function
|
|
665
|
-
return
|
|
1241
|
+
function isNotFound2(err) {
|
|
1242
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
666
1243
|
}
|
|
667
|
-
function
|
|
668
|
-
const
|
|
669
|
-
mkdirSync(dir, { recursive: true });
|
|
670
|
-
const primary = join(dir, params.name(false));
|
|
671
|
-
try {
|
|
672
|
-
writeFileSync(primary, params.content, { flag: "wx" });
|
|
673
|
-
return primary;
|
|
674
|
-
} catch (error) {
|
|
675
|
-
if (!isAlreadyExists(error)) throw error;
|
|
676
|
-
}
|
|
677
|
-
const fallback = join(dir, params.name(true));
|
|
1244
|
+
function writeFileAtomicBinary(target, bytes) {
|
|
1245
|
+
const tmp = join4(dirname4(target), `.${basename2(target)}.${String(process.pid)}.tmp`);
|
|
678
1246
|
try {
|
|
679
|
-
|
|
680
|
-
|
|
1247
|
+
writeFileSync2(tmp, bytes);
|
|
1248
|
+
renameSync2(tmp, target);
|
|
681
1249
|
} catch (error) {
|
|
682
|
-
|
|
683
|
-
throw new BackupError(
|
|
684
|
-
`backup target already exists and could not be disambiguated: ${fallback}`,
|
|
685
|
-
{ cause: error }
|
|
686
|
-
);
|
|
687
|
-
}
|
|
1250
|
+
rmSync2(tmp, { force: true });
|
|
688
1251
|
throw error;
|
|
689
1252
|
}
|
|
690
1253
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1254
|
+
var FileTakuhonAssetStorage = class {
|
|
1255
|
+
profilePath;
|
|
1256
|
+
publicBaseUrl;
|
|
1257
|
+
constructor(profilePath, options = {}) {
|
|
1258
|
+
this.profilePath = profilePath;
|
|
1259
|
+
this.publicBaseUrl = options.publicBaseUrl ?? "";
|
|
1260
|
+
}
|
|
1261
|
+
// Genuinely async (awaits the Blob), so no `Promise.resolve().then()` wrap is
|
|
1262
|
+
// needed here — unlike the synchronous methods below.
|
|
1263
|
+
async putAsset(file, options) {
|
|
1264
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1265
|
+
const mime = asAcceptedMime(options?.contentType) ?? detectImageMime(bytes);
|
|
1266
|
+
const ext = mime !== null ? IMAGE_EXTENSIONS[mime] : DEFAULT_EXTENSION;
|
|
1267
|
+
const contentType2 = mime ?? options?.contentType ?? file.type ?? "application/octet-stream";
|
|
1268
|
+
const key = `${ASSET_KEY_PREFIX}${String(timestampSeconds())}-${shortHash()}.${ext}`;
|
|
1269
|
+
const full = join4(this.projectDir, key);
|
|
1270
|
+
mkdirSync2(dirname4(full), { recursive: true });
|
|
1271
|
+
writeFileAtomicBinary(full, bytes);
|
|
1272
|
+
const info = mime !== null ? readImageInfo(bytes, mime) : null;
|
|
1273
|
+
const url = `/${key}`;
|
|
1274
|
+
return {
|
|
1275
|
+
id: key,
|
|
1276
|
+
url,
|
|
1277
|
+
publicUrl: this.absoluteUrl(url),
|
|
1278
|
+
mimeType: contentType2,
|
|
1279
|
+
size: bytes.length,
|
|
1280
|
+
width: info?.width,
|
|
1281
|
+
height: info?.height,
|
|
1282
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1283
|
+
};
|
|
702
1284
|
}
|
|
703
|
-
|
|
1285
|
+
getPublicUrl(assetId) {
|
|
1286
|
+
return Promise.resolve().then(() => {
|
|
1287
|
+
const full = this.resolveKey(assetId);
|
|
1288
|
+
if (full === null || !existsSync(full)) {
|
|
1289
|
+
throw new NotFoundError(`No asset is stored for "${assetId}".`);
|
|
1290
|
+
}
|
|
1291
|
+
return this.absoluteUrl(`/${assetId}`);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
deleteAsset(assetId) {
|
|
1295
|
+
return Promise.resolve().then(() => {
|
|
1296
|
+
const full = this.resolveKey(assetId);
|
|
1297
|
+
if (full !== null) rmSync2(full, { force: true });
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
listAssets() {
|
|
1301
|
+
return Promise.resolve().then(() => {
|
|
1302
|
+
const root = join4(this.projectDir, ASSET_DIRNAME);
|
|
1303
|
+
let names;
|
|
1304
|
+
try {
|
|
1305
|
+
names = readdirSync(root);
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
if (isNotFound2(err)) return [];
|
|
1308
|
+
throw err;
|
|
1309
|
+
}
|
|
1310
|
+
return names.filter((name) => statSync(join4(root, name)).isFile()).sort().map((name) => {
|
|
1311
|
+
const key = `${ASSET_KEY_PREFIX}${name}`;
|
|
1312
|
+
const url = `/${key}`;
|
|
1313
|
+
return {
|
|
1314
|
+
id: key,
|
|
1315
|
+
url,
|
|
1316
|
+
publicUrl: this.absoluteUrl(url),
|
|
1317
|
+
mimeType: contentTypeForFile(name),
|
|
1318
|
+
size: statSync(join4(root, name)).size,
|
|
1319
|
+
createdAt: statSync(join4(root, name)).mtime.toISOString()
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Local-server helper (not part of {@link TakuhonAssetStorage}): read an asset
|
|
1326
|
+
* for static delivery, or `null` when it is absent or the key escapes the
|
|
1327
|
+
* `assets/` directory. The traversal guard lives here so the delivery route
|
|
1328
|
+
* and the persistence methods share one notion of "where assets live".
|
|
1329
|
+
*/
|
|
1330
|
+
readForServing(key) {
|
|
1331
|
+
const full = this.resolveKey(key);
|
|
1332
|
+
if (full === null) return null;
|
|
1333
|
+
let bytes;
|
|
1334
|
+
try {
|
|
1335
|
+
bytes = readFileSync4(full);
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
if (isNotFound2(err)) return null;
|
|
1338
|
+
throw err;
|
|
1339
|
+
}
|
|
1340
|
+
return { bytes, contentType: contentTypeForFile(full) };
|
|
1341
|
+
}
|
|
1342
|
+
get projectDir() {
|
|
1343
|
+
return dirname4(resolve2(this.profilePath));
|
|
1344
|
+
}
|
|
1345
|
+
/** Resolve an `assets/...` key to an absolute path, or null if out of bounds. */
|
|
1346
|
+
resolveKey(key) {
|
|
1347
|
+
const root = resolve2(this.projectDir, ASSET_DIRNAME);
|
|
1348
|
+
const full = resolve2(this.projectDir, key);
|
|
1349
|
+
if (full !== root && !full.startsWith(root + sep)) return null;
|
|
1350
|
+
return full;
|
|
1351
|
+
}
|
|
1352
|
+
absoluteUrl(path) {
|
|
1353
|
+
return this.publicBaseUrl !== "" ? `${this.publicBaseUrl}${path}` : path;
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
704
1356
|
|
|
705
1357
|
// src/file-storage.ts
|
|
1358
|
+
import { createHash } from "crypto";
|
|
1359
|
+
import { readFileSync as readFileSync5, rmSync as rmSync3 } from "fs";
|
|
1360
|
+
import { ConflictError, NotFoundError as NotFoundError2 } from "@takuhon/core";
|
|
706
1361
|
function sha256Hex(content) {
|
|
707
1362
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
708
1363
|
}
|
|
709
|
-
function
|
|
1364
|
+
function isNotFound3(err) {
|
|
710
1365
|
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
711
1366
|
}
|
|
712
1367
|
function readMaybe(path) {
|
|
713
1368
|
try {
|
|
714
|
-
return
|
|
1369
|
+
return readFileSync5(path, "utf8");
|
|
715
1370
|
} catch (err) {
|
|
716
|
-
if (
|
|
1371
|
+
if (isNotFound3(err)) return void 0;
|
|
717
1372
|
throw err;
|
|
718
1373
|
}
|
|
719
1374
|
}
|
|
@@ -733,7 +1388,7 @@ var FileStorage = class {
|
|
|
733
1388
|
return Promise.resolve().then(() => {
|
|
734
1389
|
const raw = readMaybe(this.path);
|
|
735
1390
|
if (raw === void 0) {
|
|
736
|
-
throw new
|
|
1391
|
+
throw new NotFoundError2("No profile is stored yet.");
|
|
737
1392
|
}
|
|
738
1393
|
let data;
|
|
739
1394
|
try {
|
|
@@ -780,13 +1435,13 @@ var FileStorage = class {
|
|
|
780
1435
|
content: current,
|
|
781
1436
|
name: (withMillis) => preAdminSaveName(stamp, withMillis)
|
|
782
1437
|
});
|
|
783
|
-
|
|
1438
|
+
rmSync3(this.path, { force: true });
|
|
784
1439
|
});
|
|
785
1440
|
}
|
|
786
1441
|
};
|
|
787
1442
|
|
|
788
1443
|
// src/admin-command.ts
|
|
789
|
-
var
|
|
1444
|
+
var DEFAULT_PATH3 = "takuhon.json";
|
|
790
1445
|
var DEFAULT_PORT2 = 4322;
|
|
791
1446
|
var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
|
|
792
1447
|
|
|
@@ -813,9 +1468,9 @@ var CONTENT_TYPES = {
|
|
|
813
1468
|
".map": "application/json; charset=utf-8"
|
|
814
1469
|
};
|
|
815
1470
|
function contentTypeFor(file) {
|
|
816
|
-
return CONTENT_TYPES[
|
|
1471
|
+
return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
|
|
817
1472
|
}
|
|
818
|
-
function
|
|
1473
|
+
function isNotFound4(err) {
|
|
819
1474
|
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
820
1475
|
}
|
|
821
1476
|
function localAdminHeaders() {
|
|
@@ -828,7 +1483,15 @@ function localAdminHeaders() {
|
|
|
828
1483
|
return headers;
|
|
829
1484
|
}
|
|
830
1485
|
var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
831
|
-
function
|
|
1486
|
+
function escapeHtmlAttr(value) {
|
|
1487
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1488
|
+
}
|
|
1489
|
+
function injectLocalToken(html, token) {
|
|
1490
|
+
const meta = `<meta name="takuhon-local-token" content="${escapeHtmlAttr(token)}" />`;
|
|
1491
|
+
if (html.includes("</head>")) return html.replace("</head>", `${meta}</head>`);
|
|
1492
|
+
return meta + html;
|
|
1493
|
+
}
|
|
1494
|
+
function serveAdminBundle(bundleDir, method, pathname, token) {
|
|
832
1495
|
if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
|
|
833
1496
|
const rest = pathname.slice("/admin".length);
|
|
834
1497
|
let rel;
|
|
@@ -838,34 +1501,57 @@ function serveAdminBundle(bundleDir, method, pathname) {
|
|
|
838
1501
|
rel = "";
|
|
839
1502
|
}
|
|
840
1503
|
if (rel === "") rel = "index.html";
|
|
841
|
-
const root =
|
|
842
|
-
let full =
|
|
843
|
-
if (full !== root && !full.startsWith(root +
|
|
1504
|
+
const root = resolve3(bundleDir);
|
|
1505
|
+
let full = resolve3(root, rel);
|
|
1506
|
+
if (full !== root && !full.startsWith(root + sep2)) return plain(403, "Forbidden\n");
|
|
844
1507
|
let body;
|
|
845
1508
|
try {
|
|
846
|
-
body =
|
|
1509
|
+
body = readFileSync6(full);
|
|
847
1510
|
} catch (err) {
|
|
848
|
-
if (!
|
|
849
|
-
if (
|
|
850
|
-
full =
|
|
1511
|
+
if (!isNotFound4(err)) throw err;
|
|
1512
|
+
if (extname2(rel) !== "") return plain(404, "Not Found\n");
|
|
1513
|
+
full = join5(root, "index.html");
|
|
851
1514
|
try {
|
|
852
|
-
body =
|
|
1515
|
+
body = readFileSync6(full);
|
|
853
1516
|
} catch {
|
|
854
1517
|
return plain(404, "Not Found\n");
|
|
855
1518
|
}
|
|
856
1519
|
}
|
|
1520
|
+
const contentType2 = contentTypeFor(full);
|
|
857
1521
|
const headers = new Headers(localAdminHeaders());
|
|
858
|
-
headers.set("content-type",
|
|
1522
|
+
headers.set("content-type", contentType2);
|
|
1523
|
+
if (contentType2.startsWith("text/html")) {
|
|
1524
|
+
const injected = injectLocalToken(body.toString("utf8"), token);
|
|
1525
|
+
return new Response(method === "HEAD" ? null : injected, { status: 200, headers });
|
|
1526
|
+
}
|
|
859
1527
|
return new Response(method === "HEAD" ? null : body, { status: 200, headers });
|
|
860
1528
|
}
|
|
1529
|
+
function serveLocalAsset(assetStorage, method, pathname) {
|
|
1530
|
+
if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
|
|
1531
|
+
let key;
|
|
1532
|
+
try {
|
|
1533
|
+
key = decodeURIComponent(pathname.slice(1));
|
|
1534
|
+
} catch {
|
|
1535
|
+
return plain(400, "Bad Request\n");
|
|
1536
|
+
}
|
|
1537
|
+
const asset = assetStorage.readForServing(key);
|
|
1538
|
+
if (asset === null) return plain(404, "Not Found\n");
|
|
1539
|
+
const headers = new Headers();
|
|
1540
|
+
headers.set("content-type", asset.contentType);
|
|
1541
|
+
headers.set("x-content-type-options", "nosniff");
|
|
1542
|
+
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
1543
|
+
return new Response(method === "HEAD" ? null : asset.bytes, { status: 200, headers });
|
|
1544
|
+
}
|
|
861
1545
|
function createAdminApp(opts) {
|
|
862
1546
|
const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
|
|
863
1547
|
const storage = new FileStorage(opts.path);
|
|
1548
|
+
const assetStorage = new FileTakuhonAssetStorage(opts.path, { publicBaseUrl: opts.assetBaseUrl });
|
|
864
1549
|
const app = new Hono();
|
|
865
1550
|
app.route(
|
|
866
1551
|
"/api/admin",
|
|
867
1552
|
createAdminApiApp({
|
|
868
1553
|
storage,
|
|
1554
|
+
assetStorage,
|
|
869
1555
|
getAdminToken: () => opts.token,
|
|
870
1556
|
// Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
|
|
871
1557
|
getAdminOrigins: () => [],
|
|
@@ -876,7 +1562,10 @@ function createAdminApp(opts) {
|
|
|
876
1562
|
app.all("*", (c) => {
|
|
877
1563
|
const { method, path } = c.req;
|
|
878
1564
|
if (path === "/admin" || path.startsWith("/admin/")) {
|
|
879
|
-
return serveAdminBundle(bundleDir, method, path);
|
|
1565
|
+
return serveAdminBundle(bundleDir, method, path, opts.token);
|
|
1566
|
+
}
|
|
1567
|
+
if (path.startsWith("/assets/")) {
|
|
1568
|
+
return serveLocalAsset(assetStorage, method, path);
|
|
880
1569
|
}
|
|
881
1570
|
const state = loadSiteState(opts.path, opts.baseUrl);
|
|
882
1571
|
const res = handleRequest(method, path, state);
|
|
@@ -935,7 +1624,7 @@ function parseArgs2(args) {
|
|
|
935
1624
|
if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
|
|
936
1625
|
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
937
1626
|
}
|
|
938
|
-
return { path: path ??
|
|
1627
|
+
return { path: path ?? DEFAULT_PATH3, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
|
|
939
1628
|
}
|
|
940
1629
|
function isHttpUrl2(value) {
|
|
941
1630
|
try {
|
|
@@ -959,8 +1648,13 @@ Run \`takuhon admin --help\` for usage.
|
|
|
959
1648
|
`);
|
|
960
1649
|
return 2;
|
|
961
1650
|
}
|
|
962
|
-
const token = deps.token ??
|
|
963
|
-
const app = createAdminApp({
|
|
1651
|
+
const token = deps.token ?? randomBytes2(32).toString("base64url");
|
|
1652
|
+
const app = createAdminApp({
|
|
1653
|
+
path: parsed.path,
|
|
1654
|
+
token,
|
|
1655
|
+
baseUrl: parsed.baseUrl,
|
|
1656
|
+
assetBaseUrl: `http://127.0.0.1:${String(parsed.port)}`
|
|
1657
|
+
});
|
|
964
1658
|
return await new Promise((resolvePromise) => {
|
|
965
1659
|
let closing = false;
|
|
966
1660
|
function shutdown() {
|
|
@@ -997,12 +1691,12 @@ Run \`takuhon admin --help\` for usage.
|
|
|
997
1691
|
}
|
|
998
1692
|
|
|
999
1693
|
// src/build-command.ts
|
|
1000
|
-
import { mkdirSync as
|
|
1001
|
-
import { dirname as
|
|
1694
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "fs";
|
|
1695
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1002
1696
|
import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
|
|
1003
|
-
var
|
|
1697
|
+
var DEFAULT_PATH4 = "takuhon.json";
|
|
1004
1698
|
var DEFAULT_OUTPUT = "dist";
|
|
1005
|
-
var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
|
|
1699
|
+
var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>] [--cv]
|
|
1006
1700
|
|
|
1007
1701
|
Render a takuhon.json into a static site (one HTML page per locale, with
|
|
1008
1702
|
build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
|
|
@@ -1013,6 +1707,9 @@ Options:
|
|
|
1013
1707
|
to <dir>/<locale>/index.html.
|
|
1014
1708
|
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
1015
1709
|
canonical and hreflang links; without it those are omitted.
|
|
1710
|
+
--cv Also emit a print-ready CV/r\xE9sum\xE9 page per locale
|
|
1711
|
+
(<dir>/cv.html and <dir>/<locale>/cv.html). Open it and use
|
|
1712
|
+
the browser's "Save as PDF" to produce a r\xE9sum\xE9 PDF.
|
|
1016
1713
|
|
|
1017
1714
|
The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
|
|
1018
1715
|
referenced as-is and are not copied. The output directory is written into, not
|
|
@@ -1041,8 +1738,13 @@ function parseArgs3(args) {
|
|
|
1041
1738
|
let path;
|
|
1042
1739
|
let output;
|
|
1043
1740
|
let baseUrl;
|
|
1741
|
+
let cv = false;
|
|
1044
1742
|
for (let i = 0; i < args.length; i++) {
|
|
1045
1743
|
const arg = args[i];
|
|
1744
|
+
if (arg === "--cv") {
|
|
1745
|
+
cv = true;
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1046
1748
|
if (arg === "--output" || arg === "--base-url") {
|
|
1047
1749
|
const value = args[i + 1];
|
|
1048
1750
|
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
@@ -1077,10 +1779,11 @@ function parseArgs3(args) {
|
|
|
1077
1779
|
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
1078
1780
|
}
|
|
1079
1781
|
return {
|
|
1080
|
-
path: path ??
|
|
1782
|
+
path: path ?? DEFAULT_PATH4,
|
|
1081
1783
|
output: output ?? DEFAULT_OUTPUT,
|
|
1082
1784
|
// Drop any trailing slash so URL joins are predictable.
|
|
1083
|
-
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
1785
|
+
baseUrl: baseUrl?.replace(/\/+$/, ""),
|
|
1786
|
+
cv
|
|
1084
1787
|
};
|
|
1085
1788
|
}
|
|
1086
1789
|
function isHttpUrl3(value) {
|
|
@@ -1092,10 +1795,10 @@ function isHttpUrl3(value) {
|
|
|
1092
1795
|
}
|
|
1093
1796
|
}
|
|
1094
1797
|
function buildSite(parsed) {
|
|
1095
|
-
const { path, output, baseUrl } = parsed;
|
|
1798
|
+
const { path, output, baseUrl, cv } = parsed;
|
|
1096
1799
|
let raw;
|
|
1097
1800
|
try {
|
|
1098
|
-
raw =
|
|
1801
|
+
raw = readFileSync7(path, "utf8");
|
|
1099
1802
|
} catch {
|
|
1100
1803
|
return {
|
|
1101
1804
|
code: 2,
|
|
@@ -1124,11 +1827,12 @@ ${lines.join("\n")}
|
|
|
1124
1827
|
};
|
|
1125
1828
|
}
|
|
1126
1829
|
const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
|
|
1830
|
+
const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
|
|
1127
1831
|
const written = [];
|
|
1128
1832
|
try {
|
|
1129
|
-
for (const page of generateSite(filtered, { baseUrl })) {
|
|
1130
|
-
const outFile =
|
|
1131
|
-
|
|
1833
|
+
for (const page of generateSite(filtered, { baseUrl, activitySnapshot, cv })) {
|
|
1834
|
+
const outFile = join6(output, page.file);
|
|
1835
|
+
mkdirSync3(dirname5(outFile), { recursive: true });
|
|
1132
1836
|
writeFileAtomic(outFile, page.html);
|
|
1133
1837
|
written.push(outFile);
|
|
1134
1838
|
}
|
|
@@ -1148,10 +1852,10 @@ ${summary}
|
|
|
1148
1852
|
}
|
|
1149
1853
|
|
|
1150
1854
|
// src/export-command.ts
|
|
1151
|
-
import { readFileSync as
|
|
1152
|
-
import { resolve as
|
|
1855
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1856
|
+
import { resolve as resolve4 } from "path";
|
|
1153
1857
|
import { exportTakuhon, validate as validate3 } from "@takuhon/core";
|
|
1154
|
-
var
|
|
1858
|
+
var DEFAULT_PATH5 = "takuhon.json";
|
|
1155
1859
|
var USAGE4 = `Usage: takuhon export [path] [--output <file>]
|
|
1156
1860
|
|
|
1157
1861
|
Serialise a takuhon.json into its transport form and print it to stdout, or
|
|
@@ -1215,11 +1919,11 @@ function parseArgs4(args) {
|
|
|
1215
1919
|
}
|
|
1216
1920
|
path = arg;
|
|
1217
1921
|
}
|
|
1218
|
-
return { path: path ??
|
|
1922
|
+
return { path: path ?? DEFAULT_PATH5, output };
|
|
1219
1923
|
}
|
|
1220
1924
|
function exportFile(parsed) {
|
|
1221
1925
|
const { path, output } = parsed;
|
|
1222
|
-
if (output !== void 0 &&
|
|
1926
|
+
if (output !== void 0 && resolve4(output) === resolve4(path)) {
|
|
1223
1927
|
return {
|
|
1224
1928
|
code: 2,
|
|
1225
1929
|
stdout: "",
|
|
@@ -1230,7 +1934,7 @@ Omit --output to print to stdout, or choose a different file.
|
|
|
1230
1934
|
}
|
|
1231
1935
|
let raw;
|
|
1232
1936
|
try {
|
|
1233
|
-
raw =
|
|
1937
|
+
raw = readFileSync8(path, "utf8");
|
|
1234
1938
|
} catch {
|
|
1235
1939
|
return {
|
|
1236
1940
|
code: 2,
|
|
@@ -1275,7 +1979,7 @@ ${lines.join("\n")}
|
|
|
1275
1979
|
}
|
|
1276
1980
|
|
|
1277
1981
|
// src/import-command.ts
|
|
1278
|
-
import { readFileSync as
|
|
1982
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1279
1983
|
import {
|
|
1280
1984
|
ImportError,
|
|
1281
1985
|
MigrationError,
|
|
@@ -1283,7 +1987,7 @@ import {
|
|
|
1283
1987
|
importTakuhon,
|
|
1284
1988
|
migrateTakuhon
|
|
1285
1989
|
} from "@takuhon/core";
|
|
1286
|
-
var
|
|
1990
|
+
var DEFAULT_PATH6 = "takuhon.json";
|
|
1287
1991
|
var USAGE5 = `Usage: takuhon import <file> [path]
|
|
1288
1992
|
|
|
1289
1993
|
Load a previously exported profile from <file> into a local takuhon.json,
|
|
@@ -1327,13 +2031,13 @@ function parseArgs5(args) {
|
|
|
1327
2031
|
if (positionals.length > 2) {
|
|
1328
2032
|
return { error: "takuhon: `import` takes at most an input <file> and a target path." };
|
|
1329
2033
|
}
|
|
1330
|
-
return { file: positionals[0], path: positionals[1] ??
|
|
2034
|
+
return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH6 };
|
|
1331
2035
|
}
|
|
1332
2036
|
function importFile(parsed, now) {
|
|
1333
2037
|
const { file, path } = parsed;
|
|
1334
2038
|
let raw;
|
|
1335
2039
|
try {
|
|
1336
|
-
raw =
|
|
2040
|
+
raw = readFileSync9(file, "utf8");
|
|
1337
2041
|
} catch {
|
|
1338
2042
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
|
|
1339
2043
|
` };
|
|
@@ -1398,9 +2102,9 @@ ${lines2.join("\n")}` : ".";
|
|
|
1398
2102
|
}
|
|
1399
2103
|
let currentRaw;
|
|
1400
2104
|
try {
|
|
1401
|
-
currentRaw =
|
|
2105
|
+
currentRaw = readFileSync9(path, "utf8");
|
|
1402
2106
|
} catch (error) {
|
|
1403
|
-
if (!
|
|
2107
|
+
if (!isNotFound5(error)) {
|
|
1404
2108
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1405
2109
|
` };
|
|
1406
2110
|
}
|
|
@@ -1439,13 +2143,13 @@ ${lines2.join("\n")}` : ".";
|
|
|
1439
2143
|
return { code: 0, stdout: `${lines.join("\n")}
|
|
1440
2144
|
`, stderr: "" };
|
|
1441
2145
|
}
|
|
1442
|
-
function
|
|
2146
|
+
function isNotFound5(error) {
|
|
1443
2147
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1444
2148
|
}
|
|
1445
2149
|
|
|
1446
2150
|
// src/migrate-command.ts
|
|
1447
|
-
import { readFileSync as
|
|
1448
|
-
import { resolve as
|
|
2151
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
2152
|
+
import { resolve as resolve5 } from "path";
|
|
1449
2153
|
import {
|
|
1450
2154
|
MigrationError as MigrationError2,
|
|
1451
2155
|
SCHEMA_VERSION as SCHEMA_VERSION2,
|
|
@@ -1453,7 +2157,7 @@ import {
|
|
|
1453
2157
|
migrateTakuhon as migrateTakuhon2,
|
|
1454
2158
|
validate as validate4
|
|
1455
2159
|
} from "@takuhon/core";
|
|
1456
|
-
var
|
|
2160
|
+
var DEFAULT_PATH7 = "takuhon.json";
|
|
1457
2161
|
var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
|
|
1458
2162
|
|
|
1459
2163
|
Forward-migrate a takuhon.json to a newer schema version. The source version
|
|
@@ -1534,13 +2238,13 @@ function parseArgs6(args) {
|
|
|
1534
2238
|
error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
|
|
1535
2239
|
};
|
|
1536
2240
|
}
|
|
1537
|
-
return { path: path ??
|
|
2241
|
+
return { path: path ?? DEFAULT_PATH7, to: target, out, dryRun };
|
|
1538
2242
|
}
|
|
1539
2243
|
function migrateFile(parsed, now) {
|
|
1540
2244
|
const { path, to: target, out, dryRun } = parsed;
|
|
1541
2245
|
let raw;
|
|
1542
2246
|
try {
|
|
1543
|
-
raw =
|
|
2247
|
+
raw = readFileSync10(path, "utf8");
|
|
1544
2248
|
} catch {
|
|
1545
2249
|
return {
|
|
1546
2250
|
code: 2,
|
|
@@ -1575,7 +2279,7 @@ function migrateFile(parsed, now) {
|
|
|
1575
2279
|
};
|
|
1576
2280
|
}
|
|
1577
2281
|
const writeTarget = out ?? path;
|
|
1578
|
-
const inPlace =
|
|
2282
|
+
const inPlace = resolve5(writeTarget) === resolve5(path);
|
|
1579
2283
|
let migrated;
|
|
1580
2284
|
try {
|
|
1581
2285
|
migrated = migrateTakuhon2(data, target);
|
|
@@ -1653,11 +2357,119 @@ ${lines2.join("\n")}
|
|
|
1653
2357
|
`, stderr: "" };
|
|
1654
2358
|
}
|
|
1655
2359
|
|
|
2360
|
+
// src/refresh-admin-command.ts
|
|
2361
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
2362
|
+
import { cp, mkdtemp, readdir, rename, rm, stat } from "fs/promises";
|
|
2363
|
+
import { join as join7, resolve as resolve6 } from "path";
|
|
2364
|
+
var PROFILE_FILENAME = "takuhon.json";
|
|
2365
|
+
var USAGE7 = `Usage: takuhon admin update [path]
|
|
2366
|
+
|
|
2367
|
+
Refresh a project's ${ADMIN_DIST_DIRNAME}/ with the admin form-UI bundle shipped in
|
|
2368
|
+
this @takuhon/cli. Use it after upgrading @takuhon/cli to pick up a newer admin
|
|
2369
|
+
UI without re-scaffolding. With no path, refreshes the project in the current
|
|
2370
|
+
directory. The project must already have an ${ADMIN_DIST_DIRNAME}/ (created by
|
|
2371
|
+
create-takuhon); this command updates it, it does not create one.
|
|
2372
|
+
|
|
2373
|
+
Exit codes: 0 = refreshed, 2 = bad arguments / not a takuhon project /
|
|
2374
|
+
no ${ADMIN_DIST_DIRNAME}/ to refresh / copy failed.
|
|
2375
|
+
`;
|
|
2376
|
+
async function runRefreshAdmin(args = [], opts = {}) {
|
|
2377
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
2378
|
+
return { code: 0, stdout: USAGE7, stderr: "" };
|
|
2379
|
+
}
|
|
2380
|
+
const unknownFlag = args.find((arg) => arg.startsWith("-"));
|
|
2381
|
+
if (unknownFlag !== void 0) {
|
|
2382
|
+
return usageError(`unknown option '${unknownFlag}'.`);
|
|
2383
|
+
}
|
|
2384
|
+
if (args.length > 1) {
|
|
2385
|
+
return usageError("`admin update` takes at most one path argument.");
|
|
2386
|
+
}
|
|
2387
|
+
const displayDir = args[0] ?? ".";
|
|
2388
|
+
const projectDir = resolve6(args[0] ?? ".");
|
|
2389
|
+
try {
|
|
2390
|
+
await stat(join7(projectDir, PROFILE_FILENAME));
|
|
2391
|
+
} catch {
|
|
2392
|
+
return {
|
|
2393
|
+
code: 2,
|
|
2394
|
+
stdout: "",
|
|
2395
|
+
stderr: `takuhon: '${displayDir}' is not a takuhon project (no ${PROFILE_FILENAME} found).
|
|
2396
|
+
Run \`takuhon admin update\` from a project created by create-takuhon.
|
|
2397
|
+
`
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
const adminDist = join7(projectDir, ADMIN_DIST_DIRNAME);
|
|
2401
|
+
const adminDistStat = await stat(adminDist).catch(() => void 0);
|
|
2402
|
+
if (adminDistStat === void 0) {
|
|
2403
|
+
return {
|
|
2404
|
+
code: 2,
|
|
2405
|
+
stdout: "",
|
|
2406
|
+
stderr: `takuhon: '${displayDir}' has no ${ADMIN_DIST_DIRNAME}/ directory to refresh.
|
|
2407
|
+
This project was not scaffolded with the admin form UI; create a new project with create-takuhon to add it.
|
|
2408
|
+
`
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
if (!adminDistStat.isDirectory()) {
|
|
2412
|
+
return {
|
|
2413
|
+
code: 2,
|
|
2414
|
+
stdout: "",
|
|
2415
|
+
stderr: `takuhon: '${join7(displayDir, ADMIN_DIST_DIRNAME)}' exists but is not a directory.
|
|
2416
|
+
`
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
|
|
2420
|
+
let fileCount;
|
|
2421
|
+
let staged;
|
|
2422
|
+
try {
|
|
2423
|
+
fileCount = (await readdir(bundleDir, { recursive: true, withFileTypes: true })).filter(
|
|
2424
|
+
(entry) => entry.isFile()
|
|
2425
|
+
).length;
|
|
2426
|
+
staged = await mkdtemp(join7(projectDir, `.${ADMIN_DIST_DIRNAME}-`));
|
|
2427
|
+
await cp(bundleDir, staged, { recursive: true });
|
|
2428
|
+
await rm(adminDist, { recursive: true, force: true });
|
|
2429
|
+
await rename(staged, adminDist);
|
|
2430
|
+
staged = void 0;
|
|
2431
|
+
} catch (err) {
|
|
2432
|
+
if (staged !== void 0) {
|
|
2433
|
+
await rm(staged, { recursive: true, force: true }).catch(() => void 0);
|
|
2434
|
+
}
|
|
2435
|
+
const code = isNodeErrnoException(err) ? ` (${err.code})` : "";
|
|
2436
|
+
return {
|
|
2437
|
+
code: 2,
|
|
2438
|
+
stdout: "",
|
|
2439
|
+
stderr: `takuhon: failed to refresh ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli${code}. Reinstall or upgrade @takuhon/cli and try again.
|
|
2440
|
+
`
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
const plural = fileCount === 1 ? "" : "s";
|
|
2444
|
+
return {
|
|
2445
|
+
code: 0,
|
|
2446
|
+
stdout: `Refreshed ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli@${readVersion()} (${fileCount} file${plural}).
|
|
2447
|
+
`,
|
|
2448
|
+
stderr: ""
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
function usageError(message) {
|
|
2452
|
+
return {
|
|
2453
|
+
code: 2,
|
|
2454
|
+
stdout: "",
|
|
2455
|
+
stderr: `takuhon: ${message}
|
|
2456
|
+
Run \`takuhon admin update --help\` for usage.
|
|
2457
|
+
`
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
function readVersion() {
|
|
2461
|
+
const pkg2 = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
|
|
2462
|
+
return pkg2.version;
|
|
2463
|
+
}
|
|
2464
|
+
function isNodeErrnoException(err) {
|
|
2465
|
+
return err instanceof Error && typeof err.code === "string";
|
|
2466
|
+
}
|
|
2467
|
+
|
|
1656
2468
|
// src/restore-command.ts
|
|
1657
|
-
import { readFileSync as
|
|
2469
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
1658
2470
|
import { validate as validate5 } from "@takuhon/core";
|
|
1659
|
-
var
|
|
1660
|
-
var
|
|
2471
|
+
var DEFAULT_PATH8 = "takuhon.json";
|
|
2472
|
+
var USAGE8 = `Usage: takuhon restore --from <backup> [path] [--yes]
|
|
1661
2473
|
|
|
1662
2474
|
Overwrite a profile with a previously saved backup. With no path, restores
|
|
1663
2475
|
./takuhon.json in the current working directory.
|
|
@@ -1674,7 +2486,7 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
|
|
|
1674
2486
|
`;
|
|
1675
2487
|
async function runRestore(args = [], deps = {}) {
|
|
1676
2488
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1677
|
-
return { code: 0, stdout:
|
|
2489
|
+
return { code: 0, stdout: USAGE8, stderr: "" };
|
|
1678
2490
|
}
|
|
1679
2491
|
const parsed = parseArgs7(args);
|
|
1680
2492
|
if ("error" in parsed) {
|
|
@@ -1723,13 +2535,13 @@ function parseArgs7(args) {
|
|
|
1723
2535
|
if (from === void 0 || from.length === 0) {
|
|
1724
2536
|
return { error: "takuhon: `restore` requires `--from <backup>`." };
|
|
1725
2537
|
}
|
|
1726
|
-
return { from, path: path ??
|
|
2538
|
+
return { from, path: path ?? DEFAULT_PATH8, yes };
|
|
1727
2539
|
}
|
|
1728
2540
|
async function restoreFile(parsed, now, confirm) {
|
|
1729
2541
|
const { from, path, yes } = parsed;
|
|
1730
2542
|
let backupRaw;
|
|
1731
2543
|
try {
|
|
1732
|
-
backupRaw =
|
|
2544
|
+
backupRaw = readFileSync12(from, "utf8");
|
|
1733
2545
|
} catch {
|
|
1734
2546
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
|
|
1735
2547
|
` };
|
|
@@ -1759,9 +2571,9 @@ ${lines2.join("\n")}
|
|
|
1759
2571
|
}
|
|
1760
2572
|
let currentRaw;
|
|
1761
2573
|
try {
|
|
1762
|
-
currentRaw =
|
|
2574
|
+
currentRaw = readFileSync12(path, "utf8");
|
|
1763
2575
|
} catch (error) {
|
|
1764
|
-
if (!
|
|
2576
|
+
if (!isNotFound6(error)) {
|
|
1765
2577
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1766
2578
|
` };
|
|
1767
2579
|
}
|
|
@@ -1824,17 +2636,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
|
|
|
1824
2636
|
${preNote}
|
|
1825
2637
|
Continue? [y/N]`;
|
|
1826
2638
|
}
|
|
1827
|
-
function
|
|
2639
|
+
function isNotFound6(error) {
|
|
1828
2640
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1829
2641
|
}
|
|
1830
2642
|
|
|
1831
2643
|
// src/sync-command.ts
|
|
1832
|
-
import { readFileSync as
|
|
2644
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
1833
2645
|
import { validate as validate6 } from "@takuhon/core";
|
|
1834
|
-
var
|
|
2646
|
+
var DEFAULT_PATH9 = "takuhon.json";
|
|
1835
2647
|
var ADMIN_PROFILE_PATH = "/api/admin/profile";
|
|
1836
2648
|
var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
|
|
1837
|
-
var
|
|
2649
|
+
var USAGE9 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
|
|
1838
2650
|
|
|
1839
2651
|
Push a local takuhon.json to a deployed takuhon instance by calling its admin
|
|
1840
2652
|
write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
|
|
@@ -1860,7 +2672,7 @@ unset / auth failure / network error / other non-success response.
|
|
|
1860
2672
|
`;
|
|
1861
2673
|
async function runSync(args = [], deps = {}) {
|
|
1862
2674
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1863
|
-
return { code: 0, stdout:
|
|
2675
|
+
return { code: 0, stdout: USAGE9, stderr: "" };
|
|
1864
2676
|
}
|
|
1865
2677
|
const parsed = parseArgs8(args);
|
|
1866
2678
|
if ("error" in parsed) {
|
|
@@ -1920,7 +2732,7 @@ function parseArgs8(args) {
|
|
|
1920
2732
|
}
|
|
1921
2733
|
const base = parseOrigin(url);
|
|
1922
2734
|
if ("error" in base) return base;
|
|
1923
|
-
return { path: path ??
|
|
2735
|
+
return { path: path ?? DEFAULT_PATH9, url: base.origin, ifMatch, dryRun };
|
|
1924
2736
|
}
|
|
1925
2737
|
function parseOrigin(value) {
|
|
1926
2738
|
let parsed;
|
|
@@ -1953,7 +2765,7 @@ async function syncProfile(parsed, deps) {
|
|
|
1953
2765
|
const { path, url, ifMatch, dryRun } = parsed;
|
|
1954
2766
|
let raw;
|
|
1955
2767
|
try {
|
|
1956
|
-
raw =
|
|
2768
|
+
raw = readFileSync13(path, "utf8");
|
|
1957
2769
|
} catch {
|
|
1958
2770
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
|
|
1959
2771
|
` };
|
|
@@ -2024,7 +2836,7 @@ Set it for this command only, e.g.:
|
|
|
2024
2836
|
async function interpretResponse(res, target) {
|
|
2025
2837
|
const { path, url, endpoint } = target;
|
|
2026
2838
|
if (res.ok) {
|
|
2027
|
-
const version = await
|
|
2839
|
+
const version = await readVersion2(res);
|
|
2028
2840
|
if (version === void 0) {
|
|
2029
2841
|
return {
|
|
2030
2842
|
code: 2,
|
|
@@ -2066,7 +2878,7 @@ ${lines.join("\n")}` : ".";
|
|
|
2066
2878
|
return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
|
|
2067
2879
|
` };
|
|
2068
2880
|
}
|
|
2069
|
-
async function
|
|
2881
|
+
async function readVersion2(res) {
|
|
2070
2882
|
try {
|
|
2071
2883
|
const parsed = await res.json();
|
|
2072
2884
|
const version = parsed.meta?.version;
|
|
@@ -2090,10 +2902,10 @@ async function readProblem(res) {
|
|
|
2090
2902
|
}
|
|
2091
2903
|
|
|
2092
2904
|
// src/validate-command.ts
|
|
2093
|
-
import { readFileSync as
|
|
2905
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
2094
2906
|
import { validate as validate7 } from "@takuhon/core";
|
|
2095
|
-
var
|
|
2096
|
-
var
|
|
2907
|
+
var DEFAULT_PATH10 = "takuhon.json";
|
|
2908
|
+
var USAGE10 = `Usage: takuhon validate [path]
|
|
2097
2909
|
|
|
2098
2910
|
Validate a takuhon.json against the takuhon schema. With no path, validates
|
|
2099
2911
|
./takuhon.json in the current working directory.
|
|
@@ -2102,7 +2914,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
|
|
|
2102
2914
|
`;
|
|
2103
2915
|
function runValidate(args = []) {
|
|
2104
2916
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
2105
|
-
return { code: 0, stdout:
|
|
2917
|
+
return { code: 0, stdout: USAGE10, stderr: "" };
|
|
2106
2918
|
}
|
|
2107
2919
|
if (args.length > 1) {
|
|
2108
2920
|
return {
|
|
@@ -2114,10 +2926,10 @@ function runValidate(args = []) {
|
|
|
2114
2926
|
return validateFile(args[0]);
|
|
2115
2927
|
}
|
|
2116
2928
|
function validateFile(pathArg) {
|
|
2117
|
-
const target = pathArg ??
|
|
2929
|
+
const target = pathArg ?? DEFAULT_PATH10;
|
|
2118
2930
|
let raw;
|
|
2119
2931
|
try {
|
|
2120
|
-
raw =
|
|
2932
|
+
raw = readFileSync14(target, "utf8");
|
|
2121
2933
|
} catch {
|
|
2122
2934
|
return {
|
|
2123
2935
|
code: 2,
|
|
@@ -2159,7 +2971,7 @@ ${lines.join("\n")}
|
|
|
2159
2971
|
}
|
|
2160
2972
|
|
|
2161
2973
|
// src/index.ts
|
|
2162
|
-
var pkg = JSON.parse(
|
|
2974
|
+
var pkg = JSON.parse(readFileSync15(new URL("../package.json", import.meta.url), "utf8"));
|
|
2163
2975
|
var VERSION = pkg.version;
|
|
2164
2976
|
var HELP = `takuhon ${VERSION}
|
|
2165
2977
|
|
|
@@ -2189,10 +3001,18 @@ Commands:
|
|
|
2189
3001
|
the form UI at /admin (writes takuhon.json, backs up
|
|
2190
3002
|
first) with a preview at / (default port: 4322). Binds
|
|
2191
3003
|
127.0.0.1; prints a per-run token to paste into the form.
|
|
3004
|
+
takuhon admin update [path] Refresh a project's admin-dist/ with the admin form UI
|
|
3005
|
+
bundled in this @takuhon/cli (use after upgrading the
|
|
3006
|
+
CLI). Updates an existing admin-dist/ only.
|
|
2192
3007
|
takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
|
|
2193
3008
|
(PUT <url>/api/admin/profile). Reads the admin token
|
|
2194
3009
|
from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
|
|
2195
3010
|
optimistic locking; --dry-run previews without sending.
|
|
3011
|
+
takuhon activity sync [path] Fetch the GitHub / WakaTime activity configured in
|
|
3012
|
+
settings.activity and store it as activity.json beside
|
|
3013
|
+
the profile. Secrets come from the environment only:
|
|
3014
|
+
TAKUHON_GITHUB_TOKEN (optional) / TAKUHON_WAKATIME_KEY.
|
|
3015
|
+
takuhon activity show [path] Print the stored activity snapshot (activity.json).
|
|
2196
3016
|
|
|
2197
3017
|
Scaffolding a new profile project:
|
|
2198
3018
|
npx create-takuhon my-profile
|
|
@@ -2225,6 +3045,9 @@ async function main(argv) {
|
|
|
2225
3045
|
return runDev(argv.slice(1));
|
|
2226
3046
|
}
|
|
2227
3047
|
if (first === "admin") {
|
|
3048
|
+
if (argv[1] === "update") {
|
|
3049
|
+
return emit(await runRefreshAdmin(argv.slice(2)));
|
|
3050
|
+
}
|
|
2228
3051
|
return runAdmin(argv.slice(1));
|
|
2229
3052
|
}
|
|
2230
3053
|
if (first === "import") {
|
|
@@ -2237,6 +3060,26 @@ async function main(argv) {
|
|
|
2237
3060
|
if (first === "sync") {
|
|
2238
3061
|
return emit(await runSync(argv.slice(1)));
|
|
2239
3062
|
}
|
|
3063
|
+
if (first === "activity") {
|
|
3064
|
+
const sub = argv[1];
|
|
3065
|
+
if (sub === "sync") {
|
|
3066
|
+
return emit(await runActivitySync(argv.slice(2)));
|
|
3067
|
+
}
|
|
3068
|
+
if (sub === "show") {
|
|
3069
|
+
return emit(await runActivityShow(argv.slice(2)));
|
|
3070
|
+
}
|
|
3071
|
+
const usage = "Subcommands:\n takuhon activity sync [path] Fetch and store the activity snapshot\n takuhon activity show [path] Print the stored activity snapshot\nRun `takuhon activity sync --help` for details.\n";
|
|
3072
|
+
if (sub === "--help" || sub === "-h") {
|
|
3073
|
+
process.stdout.write(usage);
|
|
3074
|
+
return 0;
|
|
3075
|
+
}
|
|
3076
|
+
process.stderr.write(
|
|
3077
|
+
sub === void 0 ? `takuhon: \`activity\` requires a subcommand.
|
|
3078
|
+
${usage}` : `takuhon: unknown subcommand \`${sub}\` for \`activity\`.
|
|
3079
|
+
${usage}`
|
|
3080
|
+
);
|
|
3081
|
+
return 2;
|
|
3082
|
+
}
|
|
2240
3083
|
process.stderr.write(
|
|
2241
3084
|
`takuhon: unknown command '${first}'
|
|
2242
3085
|
Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.
|