@takuhon/cli 0.10.0 → 0.12.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-XMSWT5NY.js +475 -0
- package/dist/chunk-XMSWT5NY.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1258 -325
- package/dist/index.js.map +1 -1
- package/dist/init.js +8 -398
- package/dist/init.js.map +1 -1
- package/package.json +6 -2
- package/admin-bundle/assets/index-C2-9nYVF.js +0 -9
package/dist/index.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_DIST_DIRNAME,
|
|
4
|
+
resolveAdminBundleDir
|
|
5
|
+
} from "./chunk-XMSWT5NY.js";
|
|
2
6
|
|
|
3
7
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
8
|
+
import { readFileSync as readFileSync15, realpathSync } from "fs";
|
|
5
9
|
import { stdin, stdout } from "process";
|
|
6
10
|
import { createInterface } from "readline/promises";
|
|
7
11
|
import { fileURLToPath } from "url";
|
|
8
12
|
|
|
9
|
-
// src/
|
|
10
|
-
import {
|
|
11
|
-
import { dirname as
|
|
12
|
-
import {
|
|
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";
|
|
13
22
|
|
|
14
23
|
// src/backup.ts
|
|
15
24
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
@@ -35,6 +44,9 @@ function preRestoreName(date, withMillis = false) {
|
|
|
35
44
|
function preImportName(date, withMillis = false) {
|
|
36
45
|
return `pre-import-${compactTimestamp(date, withMillis)}.json`;
|
|
37
46
|
}
|
|
47
|
+
function preAdminSaveName(date, withMillis = false) {
|
|
48
|
+
return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
|
|
49
|
+
}
|
|
38
50
|
function backupDirFor(targetPath) {
|
|
39
51
|
return join(dirname(targetPath), BACKUP_DIR_NAME);
|
|
40
52
|
}
|
|
@@ -76,11 +88,273 @@ function writeFileAtomic(target, content) {
|
|
|
76
88
|
}
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
// src/
|
|
80
|
-
|
|
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
|
+
|
|
338
|
+
// src/admin-command.ts
|
|
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";
|
|
342
|
+
import { serve } from "@hono/node-server";
|
|
343
|
+
import {
|
|
344
|
+
adminAssetSecurityHeaders,
|
|
345
|
+
createAdminApiApp,
|
|
346
|
+
noopAuditLogger,
|
|
347
|
+
noopCachePurger
|
|
348
|
+
} from "@takuhon/api";
|
|
349
|
+
import { Hono } from "hono";
|
|
350
|
+
|
|
351
|
+
// src/dev-command.ts
|
|
352
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
353
|
+
import { createServer } from "http";
|
|
354
|
+
import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
|
|
81
355
|
|
|
82
356
|
// src/build-html.ts
|
|
83
|
-
import { generateJsonLd } from "@takuhon/core";
|
|
357
|
+
import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
|
|
84
358
|
function escapeHtml(value) {
|
|
85
359
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
86
360
|
}
|
|
@@ -116,6 +390,7 @@ ul{padding:0;margin:0;list-style:none}
|
|
|
116
390
|
.rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
|
|
117
391
|
.rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
|
|
118
392
|
nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
|
|
393
|
+
.activity svg{max-width:100%;height:auto}
|
|
119
394
|
footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
|
|
120
395
|
function dateRange(start, end, isCurrent) {
|
|
121
396
|
const left = start ?? "";
|
|
@@ -203,6 +478,12 @@ function renderContact(contact) {
|
|
|
203
478
|
if (items.length === 0) return "";
|
|
204
479
|
return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
|
|
205
480
|
}
|
|
481
|
+
function renderActivity(snapshot) {
|
|
482
|
+
if (!snapshot) return "";
|
|
483
|
+
const svg = renderActivitySvg(snapshot);
|
|
484
|
+
if (svg === "") return "";
|
|
485
|
+
return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
|
|
486
|
+
}
|
|
206
487
|
function renderJsonLdScript(data) {
|
|
207
488
|
const payload = JSON.stringify(generateJsonLd(data));
|
|
208
489
|
return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
|
|
@@ -254,6 +535,7 @@ function renderProfileHtml(input) {
|
|
|
254
535
|
}))
|
|
255
536
|
),
|
|
256
537
|
renderSkills(d.skills),
|
|
538
|
+
renderActivity(input.activitySnapshot),
|
|
257
539
|
entryList(
|
|
258
540
|
"Education",
|
|
259
541
|
d.education.map((e) => {
|
|
@@ -366,11 +648,13 @@ ${footer ? `${footer}
|
|
|
366
648
|
}
|
|
367
649
|
|
|
368
650
|
// src/site.ts
|
|
651
|
+
import { resolveLocale } from "@takuhon/core";
|
|
369
652
|
function generateSite(profile, options = {}) {
|
|
370
653
|
const { baseUrl } = options;
|
|
371
654
|
const defaultLocale = profile.settings.defaultLocale;
|
|
372
655
|
const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
|
|
373
656
|
const jsonLd = profile.settings.enableJsonLd !== false;
|
|
657
|
+
const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
|
|
374
658
|
return locales.map((locale) => {
|
|
375
659
|
const localized = resolveLocale(profile, locale);
|
|
376
660
|
const isDefault = locale === defaultLocale;
|
|
@@ -381,7 +665,14 @@ function generateSite(profile, options = {}) {
|
|
|
381
665
|
}));
|
|
382
666
|
const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
|
|
383
667
|
const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
|
|
384
|
-
const html = renderProfileHtml({
|
|
668
|
+
const html = renderProfileHtml({
|
|
669
|
+
localized,
|
|
670
|
+
canonicalUrl,
|
|
671
|
+
alternates,
|
|
672
|
+
localeNav,
|
|
673
|
+
jsonLd,
|
|
674
|
+
activitySnapshot
|
|
675
|
+
});
|
|
385
676
|
return {
|
|
386
677
|
route: isDefault ? "/" : `/${locale}/`,
|
|
387
678
|
file: isDefault ? "index.html" : `${locale}/index.html`,
|
|
@@ -410,208 +701,60 @@ function localeHref(from, to, defaultLocale) {
|
|
|
410
701
|
return toRoot ? "../" : `../${to}/`;
|
|
411
702
|
}
|
|
412
703
|
|
|
413
|
-
// src/
|
|
414
|
-
var
|
|
415
|
-
var
|
|
416
|
-
var USAGE = `Usage: takuhon
|
|
704
|
+
// src/dev-command.ts
|
|
705
|
+
var DEFAULT_PATH2 = "takuhon.json";
|
|
706
|
+
var DEFAULT_PORT = 4321;
|
|
707
|
+
var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
|
|
417
708
|
|
|
418
|
-
|
|
419
|
-
build
|
|
709
|
+
Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
|
|
710
|
+
surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
|
|
711
|
+
is re-read and re-rendered on every request, so edit it and reload the browser
|
|
712
|
+
to see changes. Stop with Ctrl-C.
|
|
420
713
|
|
|
421
714
|
Options:
|
|
422
|
-
--
|
|
423
|
-
locale is written to <dir>/index.html and each other locale
|
|
424
|
-
to <dir>/<locale>/index.html.
|
|
715
|
+
--port <n> Port to listen on (default: ${DEFAULT_PORT}).
|
|
425
716
|
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
426
717
|
canonical and hreflang links; without it those are omitted.
|
|
427
718
|
|
|
428
|
-
The public privacy filter is applied (meta.privacy is honoured).
|
|
429
|
-
|
|
430
|
-
cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
|
|
719
|
+
The public privacy filter is applied (meta.privacy is honoured). An invalid
|
|
720
|
+
takuhon.json is served as an error page so you can fix it and reload.
|
|
431
721
|
|
|
432
|
-
Exit codes: 0 =
|
|
433
|
-
|
|
722
|
+
Exit codes: 0 = served then stopped, 2 = bad arguments / file missing /
|
|
723
|
+
unreadable / port in use.
|
|
434
724
|
`;
|
|
435
|
-
function
|
|
436
|
-
if (args[0] === "--help" || args[0] === "-h") {
|
|
437
|
-
return { code: 0, stdout: USAGE, stderr: "" };
|
|
438
|
-
}
|
|
439
|
-
const parsed = parseArgs(args);
|
|
440
|
-
if ("error" in parsed) {
|
|
441
|
-
return {
|
|
442
|
-
code: 2,
|
|
443
|
-
stdout: "",
|
|
444
|
-
stderr: `${parsed.error}
|
|
445
|
-
Run \`takuhon build --help\` for usage.
|
|
446
|
-
`
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
return buildSite(parsed);
|
|
450
|
-
}
|
|
451
|
-
function parseArgs(args) {
|
|
452
|
-
let path;
|
|
453
|
-
let output;
|
|
454
|
-
let baseUrl;
|
|
455
|
-
for (let i = 0; i < args.length; i++) {
|
|
456
|
-
const arg = args[i];
|
|
457
|
-
if (arg === "--output" || arg === "--base-url") {
|
|
458
|
-
const value = args[i + 1];
|
|
459
|
-
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
460
|
-
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
461
|
-
}
|
|
462
|
-
if (arg === "--output") output = value;
|
|
463
|
-
else baseUrl = value;
|
|
464
|
-
i++;
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
|
-
if (arg.startsWith("--output=")) {
|
|
468
|
-
const value = arg.slice("--output=".length);
|
|
469
|
-
if (value === "") return { error: "takuhon: `--output` requires a value." };
|
|
470
|
-
output = value;
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
if (arg.startsWith("--base-url=")) {
|
|
474
|
-
const value = arg.slice("--base-url=".length);
|
|
475
|
-
if (value === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
476
|
-
baseUrl = value;
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
if (arg.startsWith("-")) {
|
|
480
|
-
return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
|
|
481
|
-
}
|
|
482
|
-
if (path !== void 0) {
|
|
483
|
-
return { error: "takuhon: `build` takes at most one path argument." };
|
|
484
|
-
}
|
|
485
|
-
path = arg;
|
|
486
|
-
}
|
|
487
|
-
if (baseUrl !== void 0 && !isHttpUrl(baseUrl)) {
|
|
488
|
-
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
489
|
-
}
|
|
490
|
-
return {
|
|
491
|
-
path: path ?? DEFAULT_PATH,
|
|
492
|
-
output: output ?? DEFAULT_OUTPUT,
|
|
493
|
-
// Drop any trailing slash so URL joins are predictable.
|
|
494
|
-
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
function isHttpUrl(value) {
|
|
498
|
-
try {
|
|
499
|
-
const url = new URL(value);
|
|
500
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
501
|
-
} catch {
|
|
502
|
-
return false;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
function buildSite(parsed) {
|
|
506
|
-
const { path, output, baseUrl } = parsed;
|
|
725
|
+
function loadSiteState(path, baseUrl) {
|
|
507
726
|
let raw;
|
|
508
727
|
try {
|
|
509
|
-
raw =
|
|
728
|
+
raw = readFileSync3(path, "utf8");
|
|
510
729
|
} catch {
|
|
511
|
-
return {
|
|
512
|
-
code: 2,
|
|
513
|
-
stdout: "",
|
|
514
|
-
stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
515
|
-
`
|
|
516
|
-
};
|
|
730
|
+
return { ok: false, status: 500, message: `cannot read '${path}'.` };
|
|
517
731
|
}
|
|
518
732
|
let data;
|
|
519
733
|
try {
|
|
520
734
|
data = JSON.parse(raw);
|
|
521
735
|
} catch (error) {
|
|
522
736
|
const detail = error instanceof Error ? error.message : String(error);
|
|
523
|
-
return {
|
|
524
|
-
` };
|
|
737
|
+
return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
|
|
525
738
|
}
|
|
526
739
|
const result = validate(data);
|
|
527
740
|
if (!result.ok) {
|
|
528
741
|
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
529
742
|
return {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
${lines.join("\n")}
|
|
534
|
-
`
|
|
743
|
+
ok: false,
|
|
744
|
+
status: 500,
|
|
745
|
+
message: `'${path}' is not a valid takuhon profile:
|
|
746
|
+
${lines.join("\n")}`
|
|
535
747
|
};
|
|
536
748
|
}
|
|
537
749
|
const filtered = applyPublicPrivacyFilter(normalize(result.data));
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
} catch (error) {
|
|
547
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
548
|
-
return { code: 2, stdout: "", stderr: `takuhon: failed to write the site: ${detail}
|
|
549
|
-
` };
|
|
550
|
-
}
|
|
551
|
-
const summary = written.map((w) => ` ${w}`).join("\n");
|
|
552
|
-
return {
|
|
553
|
-
code: 0,
|
|
554
|
-
stdout: `built ${written.length} page${written.length === 1 ? "" : "s"} from ${path}:
|
|
555
|
-
${summary}
|
|
556
|
-
`,
|
|
557
|
-
stderr: ""
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// src/dev-command.ts
|
|
562
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
563
|
-
import { createServer } from "http";
|
|
564
|
-
import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
|
|
565
|
-
var DEFAULT_PATH2 = "takuhon.json";
|
|
566
|
-
var DEFAULT_PORT = 4321;
|
|
567
|
-
var USAGE2 = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
|
|
568
|
-
|
|
569
|
-
Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
|
|
570
|
-
surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
|
|
571
|
-
is re-read and re-rendered on every request, so edit it and reload the browser
|
|
572
|
-
to see changes. Stop with Ctrl-C.
|
|
573
|
-
|
|
574
|
-
Options:
|
|
575
|
-
--port <n> Port to listen on (default: ${DEFAULT_PORT}).
|
|
576
|
-
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
577
|
-
canonical and hreflang links; without it those are omitted.
|
|
578
|
-
|
|
579
|
-
The public privacy filter is applied (meta.privacy is honoured). An invalid
|
|
580
|
-
takuhon.json is served as an error page so you can fix it and reload.
|
|
581
|
-
|
|
582
|
-
Exit codes: 0 = served then stopped, 2 = bad arguments / file missing /
|
|
583
|
-
unreadable / port in use.
|
|
584
|
-
`;
|
|
585
|
-
function loadSiteState(path, baseUrl) {
|
|
586
|
-
let raw;
|
|
587
|
-
try {
|
|
588
|
-
raw = readFileSync2(path, "utf8");
|
|
589
|
-
} catch {
|
|
590
|
-
return { ok: false, status: 500, message: `cannot read '${path}'.` };
|
|
591
|
-
}
|
|
592
|
-
let data;
|
|
593
|
-
try {
|
|
594
|
-
data = JSON.parse(raw);
|
|
595
|
-
} catch (error) {
|
|
596
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
597
|
-
return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
|
|
598
|
-
}
|
|
599
|
-
const result = validate2(data);
|
|
600
|
-
if (!result.ok) {
|
|
601
|
-
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
602
|
-
return {
|
|
603
|
-
ok: false,
|
|
604
|
-
status: 500,
|
|
605
|
-
message: `'${path}' is not a valid takuhon profile:
|
|
606
|
-
${lines.join("\n")}`
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
|
|
610
|
-
const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
|
|
611
|
-
return { ok: true, pages };
|
|
612
|
-
}
|
|
613
|
-
function resolveRoute(urlPath) {
|
|
614
|
-
let p = urlPath;
|
|
750
|
+
const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
|
|
751
|
+
const pages = new Map(
|
|
752
|
+
generateSite(filtered, { baseUrl, activitySnapshot }).map((p) => [p.route, p.html])
|
|
753
|
+
);
|
|
754
|
+
return { ok: true, pages };
|
|
755
|
+
}
|
|
756
|
+
function resolveRoute(urlPath) {
|
|
757
|
+
let p = urlPath;
|
|
615
758
|
try {
|
|
616
759
|
p = decodeURIComponent(urlPath);
|
|
617
760
|
} catch {
|
|
@@ -661,10 +804,10 @@ async function runDev(args = [], deps = {}) {
|
|
|
661
804
|
const out = deps.stdout ?? ((text) => void process.stdout.write(text));
|
|
662
805
|
const err = deps.stderr ?? ((text) => void process.stderr.write(text));
|
|
663
806
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
664
|
-
out(
|
|
807
|
+
out(USAGE);
|
|
665
808
|
return 0;
|
|
666
809
|
}
|
|
667
|
-
const parsed =
|
|
810
|
+
const parsed = parseArgs(args);
|
|
668
811
|
if ("error" in parsed) {
|
|
669
812
|
err(`${parsed.error}
|
|
670
813
|
Run \`takuhon dev --help\` for usage.
|
|
@@ -672,7 +815,7 @@ Run \`takuhon dev --help\` for usage.
|
|
|
672
815
|
return 2;
|
|
673
816
|
}
|
|
674
817
|
try {
|
|
675
|
-
|
|
818
|
+
readFileSync3(parsed.path, "utf8");
|
|
676
819
|
} catch {
|
|
677
820
|
err(
|
|
678
821
|
`takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
@@ -681,14 +824,14 @@ Run \`takuhon dev --help\` for usage.
|
|
|
681
824
|
return 2;
|
|
682
825
|
}
|
|
683
826
|
const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
|
|
684
|
-
return await new Promise((
|
|
827
|
+
return await new Promise((resolve7) => {
|
|
685
828
|
let closing = false;
|
|
686
829
|
const shutdown = () => {
|
|
687
830
|
if (closing) return;
|
|
688
831
|
closing = true;
|
|
689
832
|
process.removeListener("SIGINT", shutdown);
|
|
690
833
|
process.removeListener("SIGTERM", shutdown);
|
|
691
|
-
server.close(() =>
|
|
834
|
+
server.close(() => resolve7(0));
|
|
692
835
|
server.closeAllConnections();
|
|
693
836
|
};
|
|
694
837
|
server.once("error", (error) => {
|
|
@@ -699,7 +842,7 @@ Run \`takuhon dev --help\` for usage.
|
|
|
699
842
|
err(`takuhon: ${error.message}
|
|
700
843
|
`);
|
|
701
844
|
}
|
|
702
|
-
|
|
845
|
+
resolve7(2);
|
|
703
846
|
});
|
|
704
847
|
server.listen(parsed.port, "127.0.0.1", () => {
|
|
705
848
|
out(
|
|
@@ -711,33 +854,679 @@ Run \`takuhon dev --help\` for usage.
|
|
|
711
854
|
err(
|
|
712
855
|
`takuhon dev: ${parsed.path} is not a valid profile yet; the preview will show the error until it is fixed.
|
|
713
856
|
`
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
process.once("SIGINT", shutdown);
|
|
717
|
-
process.once("SIGTERM", shutdown);
|
|
718
|
-
});
|
|
719
|
-
});
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
process.once("SIGINT", shutdown);
|
|
860
|
+
process.once("SIGTERM", shutdown);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
function parseArgs(args) {
|
|
865
|
+
let path;
|
|
866
|
+
let portRaw;
|
|
867
|
+
let baseUrl;
|
|
868
|
+
for (let i = 0; i < args.length; i++) {
|
|
869
|
+
const arg = args[i];
|
|
870
|
+
if (arg === "--port" || arg === "--base-url") {
|
|
871
|
+
const value = args[i + 1];
|
|
872
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
873
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
874
|
+
}
|
|
875
|
+
if (arg === "--port") portRaw = value;
|
|
876
|
+
else baseUrl = value;
|
|
877
|
+
i++;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (arg.startsWith("--port=")) {
|
|
881
|
+
const value = arg.slice("--port=".length);
|
|
882
|
+
if (value === "") return { error: "takuhon: `--port` requires a value." };
|
|
883
|
+
portRaw = value;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (arg.startsWith("--base-url=")) {
|
|
887
|
+
const value = arg.slice("--base-url=".length);
|
|
888
|
+
if (value === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
889
|
+
baseUrl = value;
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
if (arg.startsWith("-")) {
|
|
893
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`dev\`.` };
|
|
894
|
+
}
|
|
895
|
+
if (path !== void 0) {
|
|
896
|
+
return { error: "takuhon: `dev` takes at most one path argument." };
|
|
897
|
+
}
|
|
898
|
+
path = arg;
|
|
899
|
+
}
|
|
900
|
+
let port = DEFAULT_PORT;
|
|
901
|
+
if (portRaw !== void 0) {
|
|
902
|
+
const parsedPort = parsePort(portRaw);
|
|
903
|
+
if (parsedPort === void 0) {
|
|
904
|
+
return {
|
|
905
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
port = parsedPort;
|
|
909
|
+
}
|
|
910
|
+
if (baseUrl !== void 0 && !isHttpUrl(baseUrl)) {
|
|
911
|
+
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
path: path ?? DEFAULT_PATH2,
|
|
915
|
+
port,
|
|
916
|
+
// Drop any trailing slash so URL joins are predictable.
|
|
917
|
+
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function parsePort(value) {
|
|
921
|
+
if (!/^\d+$/.test(value)) return void 0;
|
|
922
|
+
const n = Number(value);
|
|
923
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : void 0;
|
|
924
|
+
}
|
|
925
|
+
function isHttpUrl(value) {
|
|
926
|
+
try {
|
|
927
|
+
const url = new URL(value);
|
|
928
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
929
|
+
} catch {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function pathnameOf(url) {
|
|
934
|
+
try {
|
|
935
|
+
return new URL(url, "http://localhost").pathname;
|
|
936
|
+
} catch {
|
|
937
|
+
return url;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function devPage(title, body) {
|
|
941
|
+
return `<!DOCTYPE html>
|
|
942
|
+
<html lang="en">
|
|
943
|
+
<head>
|
|
944
|
+
<meta charset="utf-8">
|
|
945
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
946
|
+
<title>${escapeHtml(title)}</title>
|
|
947
|
+
<style>body{margin:0;font:16px/1.6 system-ui,-apple-system,sans-serif;color:#1a1a1a}main{max-width:42rem;margin:2rem auto;padding:0 1.25rem}pre{background:#f6f6f6;padding:1rem;border-radius:.4rem;overflow:auto;white-space:pre-wrap}code{background:#f2f2f2;padding:.1rem .3rem;border-radius:.2rem}</style>
|
|
948
|
+
</head>
|
|
949
|
+
<body>
|
|
950
|
+
<main>
|
|
951
|
+
${body}
|
|
952
|
+
</main>
|
|
953
|
+
</body>
|
|
954
|
+
</html>
|
|
955
|
+
`;
|
|
956
|
+
}
|
|
957
|
+
function renderErrorPage(message) {
|
|
958
|
+
return devPage(
|
|
959
|
+
"takuhon dev \u2014 error",
|
|
960
|
+
`<h1>takuhon dev</h1>
|
|
961
|
+
<p>The profile could not be rendered:</p>
|
|
962
|
+
<pre>${escapeHtml(message)}</pre>
|
|
963
|
+
<p>Fix the file and reload.</p>`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
function renderNotFoundPage(route, routes) {
|
|
967
|
+
const links = routes.map((r) => `<li><a href="${escapeHtml(r)}">${escapeHtml(r)}</a></li>`).join("");
|
|
968
|
+
return devPage(
|
|
969
|
+
"takuhon dev \u2014 404",
|
|
970
|
+
`<h1>404</h1>
|
|
971
|
+
<p>No page for <code>${escapeHtml(route)}</code>.</p>
|
|
972
|
+
<p>Available pages:</p>
|
|
973
|
+
<ul>${links}</ul>`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// src/file-asset-storage.ts
|
|
978
|
+
import { randomBytes } from "crypto";
|
|
979
|
+
import {
|
|
980
|
+
existsSync,
|
|
981
|
+
mkdirSync as mkdirSync2,
|
|
982
|
+
readdirSync,
|
|
983
|
+
readFileSync as readFileSync4,
|
|
984
|
+
renameSync as renameSync2,
|
|
985
|
+
rmSync as rmSync2,
|
|
986
|
+
statSync,
|
|
987
|
+
writeFileSync as writeFileSync2
|
|
988
|
+
} from "fs";
|
|
989
|
+
import { basename as basename2, dirname as dirname4, extname, join as join4, resolve as resolve2, sep } from "path";
|
|
990
|
+
import {
|
|
991
|
+
ACCEPTED_IMAGE_MIME_TYPES,
|
|
992
|
+
detectImageMime,
|
|
993
|
+
IMAGE_EXTENSIONS,
|
|
994
|
+
NotFoundError,
|
|
995
|
+
readImageInfo
|
|
996
|
+
} from "@takuhon/core";
|
|
997
|
+
var ASSET_DIRNAME = "assets";
|
|
998
|
+
var ASSET_KEY_PREFIX = `${ASSET_DIRNAME}/`;
|
|
999
|
+
var DEFAULT_EXTENSION = "bin";
|
|
1000
|
+
var MIME_BY_EXTENSION = new Map(
|
|
1001
|
+
Object.entries(IMAGE_EXTENSIONS).map(([mime, ext]) => [ext, mime])
|
|
1002
|
+
);
|
|
1003
|
+
function asAcceptedMime(value) {
|
|
1004
|
+
if (value === void 0) return null;
|
|
1005
|
+
return ACCEPTED_IMAGE_MIME_TYPES.includes(value) ? value : null;
|
|
1006
|
+
}
|
|
1007
|
+
function contentTypeForFile(name) {
|
|
1008
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
1009
|
+
return MIME_BY_EXTENSION.get(ext) ?? "application/octet-stream";
|
|
1010
|
+
}
|
|
1011
|
+
function timestampSeconds() {
|
|
1012
|
+
return Math.floor(Date.now() / 1e3);
|
|
1013
|
+
}
|
|
1014
|
+
function shortHash() {
|
|
1015
|
+
return randomBytes(2).toString("hex");
|
|
1016
|
+
}
|
|
1017
|
+
function isNotFound2(err) {
|
|
1018
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
1019
|
+
}
|
|
1020
|
+
function writeFileAtomicBinary(target, bytes) {
|
|
1021
|
+
const tmp = join4(dirname4(target), `.${basename2(target)}.${String(process.pid)}.tmp`);
|
|
1022
|
+
try {
|
|
1023
|
+
writeFileSync2(tmp, bytes);
|
|
1024
|
+
renameSync2(tmp, target);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
rmSync2(tmp, { force: true });
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
var FileTakuhonAssetStorage = class {
|
|
1031
|
+
profilePath;
|
|
1032
|
+
publicBaseUrl;
|
|
1033
|
+
constructor(profilePath, options = {}) {
|
|
1034
|
+
this.profilePath = profilePath;
|
|
1035
|
+
this.publicBaseUrl = options.publicBaseUrl ?? "";
|
|
1036
|
+
}
|
|
1037
|
+
// Genuinely async (awaits the Blob), so no `Promise.resolve().then()` wrap is
|
|
1038
|
+
// needed here — unlike the synchronous methods below.
|
|
1039
|
+
async putAsset(file, options) {
|
|
1040
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1041
|
+
const mime = asAcceptedMime(options?.contentType) ?? detectImageMime(bytes);
|
|
1042
|
+
const ext = mime !== null ? IMAGE_EXTENSIONS[mime] : DEFAULT_EXTENSION;
|
|
1043
|
+
const contentType2 = mime ?? options?.contentType ?? file.type ?? "application/octet-stream";
|
|
1044
|
+
const key = `${ASSET_KEY_PREFIX}${String(timestampSeconds())}-${shortHash()}.${ext}`;
|
|
1045
|
+
const full = join4(this.projectDir, key);
|
|
1046
|
+
mkdirSync2(dirname4(full), { recursive: true });
|
|
1047
|
+
writeFileAtomicBinary(full, bytes);
|
|
1048
|
+
const info = mime !== null ? readImageInfo(bytes, mime) : null;
|
|
1049
|
+
const url = `/${key}`;
|
|
1050
|
+
return {
|
|
1051
|
+
id: key,
|
|
1052
|
+
url,
|
|
1053
|
+
publicUrl: this.absoluteUrl(url),
|
|
1054
|
+
mimeType: contentType2,
|
|
1055
|
+
size: bytes.length,
|
|
1056
|
+
width: info?.width,
|
|
1057
|
+
height: info?.height,
|
|
1058
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
getPublicUrl(assetId) {
|
|
1062
|
+
return Promise.resolve().then(() => {
|
|
1063
|
+
const full = this.resolveKey(assetId);
|
|
1064
|
+
if (full === null || !existsSync(full)) {
|
|
1065
|
+
throw new NotFoundError(`No asset is stored for "${assetId}".`);
|
|
1066
|
+
}
|
|
1067
|
+
return this.absoluteUrl(`/${assetId}`);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
deleteAsset(assetId) {
|
|
1071
|
+
return Promise.resolve().then(() => {
|
|
1072
|
+
const full = this.resolveKey(assetId);
|
|
1073
|
+
if (full !== null) rmSync2(full, { force: true });
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
listAssets() {
|
|
1077
|
+
return Promise.resolve().then(() => {
|
|
1078
|
+
const root = join4(this.projectDir, ASSET_DIRNAME);
|
|
1079
|
+
let names;
|
|
1080
|
+
try {
|
|
1081
|
+
names = readdirSync(root);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (isNotFound2(err)) return [];
|
|
1084
|
+
throw err;
|
|
1085
|
+
}
|
|
1086
|
+
return names.filter((name) => statSync(join4(root, name)).isFile()).sort().map((name) => {
|
|
1087
|
+
const key = `${ASSET_KEY_PREFIX}${name}`;
|
|
1088
|
+
const url = `/${key}`;
|
|
1089
|
+
return {
|
|
1090
|
+
id: key,
|
|
1091
|
+
url,
|
|
1092
|
+
publicUrl: this.absoluteUrl(url),
|
|
1093
|
+
mimeType: contentTypeForFile(name),
|
|
1094
|
+
size: statSync(join4(root, name)).size,
|
|
1095
|
+
createdAt: statSync(join4(root, name)).mtime.toISOString()
|
|
1096
|
+
};
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Local-server helper (not part of {@link TakuhonAssetStorage}): read an asset
|
|
1102
|
+
* for static delivery, or `null` when it is absent or the key escapes the
|
|
1103
|
+
* `assets/` directory. The traversal guard lives here so the delivery route
|
|
1104
|
+
* and the persistence methods share one notion of "where assets live".
|
|
1105
|
+
*/
|
|
1106
|
+
readForServing(key) {
|
|
1107
|
+
const full = this.resolveKey(key);
|
|
1108
|
+
if (full === null) return null;
|
|
1109
|
+
let bytes;
|
|
1110
|
+
try {
|
|
1111
|
+
bytes = readFileSync4(full);
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
if (isNotFound2(err)) return null;
|
|
1114
|
+
throw err;
|
|
1115
|
+
}
|
|
1116
|
+
return { bytes, contentType: contentTypeForFile(full) };
|
|
1117
|
+
}
|
|
1118
|
+
get projectDir() {
|
|
1119
|
+
return dirname4(resolve2(this.profilePath));
|
|
1120
|
+
}
|
|
1121
|
+
/** Resolve an `assets/...` key to an absolute path, or null if out of bounds. */
|
|
1122
|
+
resolveKey(key) {
|
|
1123
|
+
const root = resolve2(this.projectDir, ASSET_DIRNAME);
|
|
1124
|
+
const full = resolve2(this.projectDir, key);
|
|
1125
|
+
if (full !== root && !full.startsWith(root + sep)) return null;
|
|
1126
|
+
return full;
|
|
1127
|
+
}
|
|
1128
|
+
absoluteUrl(path) {
|
|
1129
|
+
return this.publicBaseUrl !== "" ? `${this.publicBaseUrl}${path}` : path;
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
// src/file-storage.ts
|
|
1134
|
+
import { createHash } from "crypto";
|
|
1135
|
+
import { readFileSync as readFileSync5, rmSync as rmSync3 } from "fs";
|
|
1136
|
+
import { ConflictError, NotFoundError as NotFoundError2 } from "@takuhon/core";
|
|
1137
|
+
function sha256Hex(content) {
|
|
1138
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
1139
|
+
}
|
|
1140
|
+
function isNotFound3(err) {
|
|
1141
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
1142
|
+
}
|
|
1143
|
+
function readMaybe(path) {
|
|
1144
|
+
try {
|
|
1145
|
+
return readFileSync5(path, "utf8");
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
if (isNotFound3(err)) return void 0;
|
|
1148
|
+
throw err;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
var FileStorage = class {
|
|
1152
|
+
constructor(path, deps = {}) {
|
|
1153
|
+
this.path = path;
|
|
1154
|
+
this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1155
|
+
}
|
|
1156
|
+
path;
|
|
1157
|
+
now;
|
|
1158
|
+
// The filesystem work is synchronous, but the TakuhonStorage contract is
|
|
1159
|
+
// async. Each method runs its body inside `Promise.resolve().then(...)` so a
|
|
1160
|
+
// synchronous throw becomes a rejected promise (e.g. NotFoundError →
|
|
1161
|
+
// ConflictError mapping in the admin API) rather than an exception the caller
|
|
1162
|
+
// must catch outside `await`.
|
|
1163
|
+
getProfile() {
|
|
1164
|
+
return Promise.resolve().then(() => {
|
|
1165
|
+
const raw = readMaybe(this.path);
|
|
1166
|
+
if (raw === void 0) {
|
|
1167
|
+
throw new NotFoundError2("No profile is stored yet.");
|
|
1168
|
+
}
|
|
1169
|
+
let data;
|
|
1170
|
+
try {
|
|
1171
|
+
data = JSON.parse(raw);
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
throw new Error("The profile file is not valid JSON.", { cause: err });
|
|
1174
|
+
}
|
|
1175
|
+
return { data, version: sha256Hex(raw) };
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
saveProfile(data, ifMatch) {
|
|
1179
|
+
return Promise.resolve().then(() => {
|
|
1180
|
+
const current = readMaybe(this.path);
|
|
1181
|
+
if (ifMatch !== void 0) {
|
|
1182
|
+
const currentVersion = current === void 0 ? void 0 : sha256Hex(current);
|
|
1183
|
+
if (currentVersion !== ifMatch) {
|
|
1184
|
+
throw new ConflictError(
|
|
1185
|
+
`If-Match preconditioned on version "${ifMatch}" but current is "${currentVersion ?? "absent"}".`,
|
|
1186
|
+
{ currentVersion }
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (current !== void 0) {
|
|
1191
|
+
const stamp = this.now();
|
|
1192
|
+
createBackup({
|
|
1193
|
+
targetPath: this.path,
|
|
1194
|
+
content: current,
|
|
1195
|
+
name: (withMillis) => preAdminSaveName(stamp, withMillis)
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const content = `${JSON.stringify(data, null, 2)}
|
|
1199
|
+
`;
|
|
1200
|
+
writeFileAtomic(this.path, content);
|
|
1201
|
+
return { version: sha256Hex(content) };
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
deleteProfile() {
|
|
1205
|
+
return Promise.resolve().then(() => {
|
|
1206
|
+
const current = readMaybe(this.path);
|
|
1207
|
+
if (current === void 0) return;
|
|
1208
|
+
const stamp = this.now();
|
|
1209
|
+
createBackup({
|
|
1210
|
+
targetPath: this.path,
|
|
1211
|
+
content: current,
|
|
1212
|
+
name: (withMillis) => preAdminSaveName(stamp, withMillis)
|
|
1213
|
+
});
|
|
1214
|
+
rmSync3(this.path, { force: true });
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// src/admin-command.ts
|
|
1220
|
+
var DEFAULT_PATH3 = "takuhon.json";
|
|
1221
|
+
var DEFAULT_PORT2 = 4322;
|
|
1222
|
+
var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
|
|
1223
|
+
|
|
1224
|
+
Run a local admin server: the form editor at /admin, the admin API at
|
|
1225
|
+
/api/admin/*, and a preview at /. With no path, edits ./takuhon.json. A fresh
|
|
1226
|
+
admin token is printed each run; paste it into the sign-in form. The server
|
|
1227
|
+
binds 127.0.0.1 only. Stop with Ctrl-C.
|
|
1228
|
+
|
|
1229
|
+
Options:
|
|
1230
|
+
--port <n> Port to listen on (default: ${DEFAULT_PORT2}).
|
|
1231
|
+
--base-url <url> Site origin for the preview's canonical / hreflang links.
|
|
1232
|
+
|
|
1233
|
+
Exit codes: 0 = served then stopped, 2 = bad arguments / port in use.
|
|
1234
|
+
`;
|
|
1235
|
+
var CONTENT_TYPES = {
|
|
1236
|
+
".html": "text/html; charset=utf-8",
|
|
1237
|
+
".js": "text/javascript; charset=utf-8",
|
|
1238
|
+
".css": "text/css; charset=utf-8",
|
|
1239
|
+
".json": "application/json; charset=utf-8",
|
|
1240
|
+
".svg": "image/svg+xml",
|
|
1241
|
+
".png": "image/png",
|
|
1242
|
+
".ico": "image/x-icon",
|
|
1243
|
+
".woff2": "font/woff2",
|
|
1244
|
+
".map": "application/json; charset=utf-8"
|
|
1245
|
+
};
|
|
1246
|
+
function contentTypeFor(file) {
|
|
1247
|
+
return CONTENT_TYPES[extname2(file).toLowerCase()] ?? "application/octet-stream";
|
|
1248
|
+
}
|
|
1249
|
+
function isNotFound4(err) {
|
|
1250
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
1251
|
+
}
|
|
1252
|
+
function localAdminHeaders() {
|
|
1253
|
+
const headers = { ...adminAssetSecurityHeaders() };
|
|
1254
|
+
delete headers["strict-transport-security"];
|
|
1255
|
+
const csp = headers["content-security-policy"];
|
|
1256
|
+
if (csp !== void 0) {
|
|
1257
|
+
headers["content-security-policy"] = csp.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "" && directive !== "upgrade-insecure-requests").join("; ");
|
|
1258
|
+
}
|
|
1259
|
+
return headers;
|
|
1260
|
+
}
|
|
1261
|
+
var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
1262
|
+
function escapeHtmlAttr(value) {
|
|
1263
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1264
|
+
}
|
|
1265
|
+
function injectLocalToken(html, token) {
|
|
1266
|
+
const meta = `<meta name="takuhon-local-token" content="${escapeHtmlAttr(token)}" />`;
|
|
1267
|
+
if (html.includes("</head>")) return html.replace("</head>", `${meta}</head>`);
|
|
1268
|
+
return meta + html;
|
|
1269
|
+
}
|
|
1270
|
+
function serveAdminBundle(bundleDir, method, pathname, token) {
|
|
1271
|
+
if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
|
|
1272
|
+
const rest = pathname.slice("/admin".length);
|
|
1273
|
+
let rel;
|
|
1274
|
+
try {
|
|
1275
|
+
rel = decodeURIComponent(rest).replace(/^\/+/, "");
|
|
1276
|
+
} catch {
|
|
1277
|
+
rel = "";
|
|
1278
|
+
}
|
|
1279
|
+
if (rel === "") rel = "index.html";
|
|
1280
|
+
const root = resolve3(bundleDir);
|
|
1281
|
+
let full = resolve3(root, rel);
|
|
1282
|
+
if (full !== root && !full.startsWith(root + sep2)) return plain(403, "Forbidden\n");
|
|
1283
|
+
let body;
|
|
1284
|
+
try {
|
|
1285
|
+
body = readFileSync6(full);
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
if (!isNotFound4(err)) throw err;
|
|
1288
|
+
if (extname2(rel) !== "") return plain(404, "Not Found\n");
|
|
1289
|
+
full = join5(root, "index.html");
|
|
1290
|
+
try {
|
|
1291
|
+
body = readFileSync6(full);
|
|
1292
|
+
} catch {
|
|
1293
|
+
return plain(404, "Not Found\n");
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
const contentType2 = contentTypeFor(full);
|
|
1297
|
+
const headers = new Headers(localAdminHeaders());
|
|
1298
|
+
headers.set("content-type", contentType2);
|
|
1299
|
+
if (contentType2.startsWith("text/html")) {
|
|
1300
|
+
const injected = injectLocalToken(body.toString("utf8"), token);
|
|
1301
|
+
return new Response(method === "HEAD" ? null : injected, { status: 200, headers });
|
|
1302
|
+
}
|
|
1303
|
+
return new Response(method === "HEAD" ? null : body, { status: 200, headers });
|
|
1304
|
+
}
|
|
1305
|
+
function serveLocalAsset(assetStorage, method, pathname) {
|
|
1306
|
+
if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
|
|
1307
|
+
let key;
|
|
1308
|
+
try {
|
|
1309
|
+
key = decodeURIComponent(pathname.slice(1));
|
|
1310
|
+
} catch {
|
|
1311
|
+
return plain(400, "Bad Request\n");
|
|
1312
|
+
}
|
|
1313
|
+
const asset = assetStorage.readForServing(key);
|
|
1314
|
+
if (asset === null) return plain(404, "Not Found\n");
|
|
1315
|
+
const headers = new Headers();
|
|
1316
|
+
headers.set("content-type", asset.contentType);
|
|
1317
|
+
headers.set("x-content-type-options", "nosniff");
|
|
1318
|
+
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
1319
|
+
return new Response(method === "HEAD" ? null : asset.bytes, { status: 200, headers });
|
|
1320
|
+
}
|
|
1321
|
+
function createAdminApp(opts) {
|
|
1322
|
+
const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
|
|
1323
|
+
const storage = new FileStorage(opts.path);
|
|
1324
|
+
const assetStorage = new FileTakuhonAssetStorage(opts.path, { publicBaseUrl: opts.assetBaseUrl });
|
|
1325
|
+
const app = new Hono();
|
|
1326
|
+
app.route(
|
|
1327
|
+
"/api/admin",
|
|
1328
|
+
createAdminApiApp({
|
|
1329
|
+
storage,
|
|
1330
|
+
assetStorage,
|
|
1331
|
+
getAdminToken: () => opts.token,
|
|
1332
|
+
// Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
|
|
1333
|
+
getAdminOrigins: () => [],
|
|
1334
|
+
cachePurger: noopCachePurger,
|
|
1335
|
+
auditLogger: noopAuditLogger
|
|
1336
|
+
})
|
|
1337
|
+
);
|
|
1338
|
+
app.all("*", (c) => {
|
|
1339
|
+
const { method, path } = c.req;
|
|
1340
|
+
if (path === "/admin" || path.startsWith("/admin/")) {
|
|
1341
|
+
return serveAdminBundle(bundleDir, method, path, opts.token);
|
|
1342
|
+
}
|
|
1343
|
+
if (path.startsWith("/assets/")) {
|
|
1344
|
+
return serveLocalAsset(assetStorage, method, path);
|
|
1345
|
+
}
|
|
1346
|
+
const state = loadSiteState(opts.path, opts.baseUrl);
|
|
1347
|
+
const res = handleRequest(method, path, state);
|
|
1348
|
+
return new Response(method === "HEAD" ? null : res.body, {
|
|
1349
|
+
status: res.status,
|
|
1350
|
+
headers: { "content-type": res.contentType }
|
|
1351
|
+
});
|
|
1352
|
+
});
|
|
1353
|
+
return app;
|
|
1354
|
+
}
|
|
1355
|
+
function parseArgs2(args) {
|
|
1356
|
+
let path;
|
|
1357
|
+
let portRaw;
|
|
1358
|
+
let baseUrl;
|
|
1359
|
+
for (let i = 0; i < args.length; i++) {
|
|
1360
|
+
const arg = args[i];
|
|
1361
|
+
if (arg === "--port" || arg === "--base-url") {
|
|
1362
|
+
const value = args[i + 1];
|
|
1363
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
1364
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
1365
|
+
}
|
|
1366
|
+
if (arg === "--port") portRaw = value;
|
|
1367
|
+
else baseUrl = value;
|
|
1368
|
+
i++;
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
if (arg.startsWith("--port=")) {
|
|
1372
|
+
portRaw = arg.slice("--port=".length);
|
|
1373
|
+
if (portRaw === "") return { error: "takuhon: `--port` requires a value." };
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (arg.startsWith("--base-url=")) {
|
|
1377
|
+
baseUrl = arg.slice("--base-url=".length);
|
|
1378
|
+
if (baseUrl === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (arg.startsWith("-")) return { error: `takuhon: unknown option \`${arg}\` for \`admin\`.` };
|
|
1382
|
+
if (path !== void 0) return { error: "takuhon: `admin` takes at most one path argument." };
|
|
1383
|
+
path = arg;
|
|
1384
|
+
}
|
|
1385
|
+
let port = DEFAULT_PORT2;
|
|
1386
|
+
if (portRaw !== void 0) {
|
|
1387
|
+
if (!/^\d+$/.test(portRaw)) {
|
|
1388
|
+
return {
|
|
1389
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const n = Number(portRaw);
|
|
1393
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
1394
|
+
return {
|
|
1395
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
port = n;
|
|
1399
|
+
}
|
|
1400
|
+
if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
|
|
1401
|
+
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
1402
|
+
}
|
|
1403
|
+
return { path: path ?? DEFAULT_PATH3, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
|
|
1404
|
+
}
|
|
1405
|
+
function isHttpUrl2(value) {
|
|
1406
|
+
try {
|
|
1407
|
+
const url = new URL(value);
|
|
1408
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1409
|
+
} catch {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
async function runAdmin(args = [], deps = {}) {
|
|
1414
|
+
const out = deps.stdout ?? ((text) => void process.stdout.write(text));
|
|
1415
|
+
const err = deps.stderr ?? ((text) => void process.stderr.write(text));
|
|
1416
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1417
|
+
out(USAGE2);
|
|
1418
|
+
return 0;
|
|
1419
|
+
}
|
|
1420
|
+
const parsed = parseArgs2(args);
|
|
1421
|
+
if ("error" in parsed) {
|
|
1422
|
+
err(`${parsed.error}
|
|
1423
|
+
Run \`takuhon admin --help\` for usage.
|
|
1424
|
+
`);
|
|
1425
|
+
return 2;
|
|
1426
|
+
}
|
|
1427
|
+
const token = deps.token ?? randomBytes2(32).toString("base64url");
|
|
1428
|
+
const app = createAdminApp({
|
|
1429
|
+
path: parsed.path,
|
|
1430
|
+
token,
|
|
1431
|
+
baseUrl: parsed.baseUrl,
|
|
1432
|
+
assetBaseUrl: `http://127.0.0.1:${String(parsed.port)}`
|
|
1433
|
+
});
|
|
1434
|
+
return await new Promise((resolvePromise) => {
|
|
1435
|
+
let closing = false;
|
|
1436
|
+
function shutdown() {
|
|
1437
|
+
if (closing) return;
|
|
1438
|
+
closing = true;
|
|
1439
|
+
process.removeListener("SIGINT", shutdown);
|
|
1440
|
+
process.removeListener("SIGTERM", shutdown);
|
|
1441
|
+
server.close(() => resolvePromise(0));
|
|
1442
|
+
}
|
|
1443
|
+
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: parsed.port }, () => {
|
|
1444
|
+
const origin = `http://127.0.0.1:${parsed.port}`;
|
|
1445
|
+
out(`takuhon admin: editing ${parsed.path} at ${origin}/ (Ctrl-C to stop)
|
|
1446
|
+
`);
|
|
1447
|
+
out(` Admin UI: ${origin}/admin
|
|
1448
|
+
`);
|
|
1449
|
+
out(` Admin token: ${token}
|
|
1450
|
+
`);
|
|
1451
|
+
out(` Paste the token into the sign-in form; it is valid for this run only.
|
|
1452
|
+
`);
|
|
1453
|
+
process.once("SIGINT", shutdown);
|
|
1454
|
+
process.once("SIGTERM", shutdown);
|
|
1455
|
+
});
|
|
1456
|
+
server.on("error", (error) => {
|
|
1457
|
+
if (error.code === "EADDRINUSE") {
|
|
1458
|
+
err(`takuhon: port ${parsed.port} is already in use; pass --port <n> to choose another.
|
|
1459
|
+
`);
|
|
1460
|
+
} else {
|
|
1461
|
+
err(`takuhon: ${error.message}
|
|
1462
|
+
`);
|
|
1463
|
+
}
|
|
1464
|
+
resolvePromise(2);
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/build-command.ts
|
|
1470
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "fs";
|
|
1471
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1472
|
+
import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
|
|
1473
|
+
var DEFAULT_PATH4 = "takuhon.json";
|
|
1474
|
+
var DEFAULT_OUTPUT = "dist";
|
|
1475
|
+
var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
|
|
1476
|
+
|
|
1477
|
+
Render a takuhon.json into a static site (one HTML page per locale, with
|
|
1478
|
+
build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
|
|
1479
|
+
|
|
1480
|
+
Options:
|
|
1481
|
+
--output <dir> Output directory (default: ${DEFAULT_OUTPUT}). The default
|
|
1482
|
+
locale is written to <dir>/index.html and each other locale
|
|
1483
|
+
to <dir>/<locale>/index.html.
|
|
1484
|
+
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
1485
|
+
canonical and hreflang links; without it those are omitted.
|
|
1486
|
+
|
|
1487
|
+
The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
|
|
1488
|
+
referenced as-is and are not copied. The output directory is written into, not
|
|
1489
|
+
cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
|
|
1490
|
+
|
|
1491
|
+
Exit codes: 0 = built, 1 = source is not a valid profile,
|
|
1492
|
+
2 = bad arguments / file missing / unreadable / not JSON / write failed.
|
|
1493
|
+
`;
|
|
1494
|
+
function runBuild(args = []) {
|
|
1495
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1496
|
+
return { code: 0, stdout: USAGE3, stderr: "" };
|
|
1497
|
+
}
|
|
1498
|
+
const parsed = parseArgs3(args);
|
|
1499
|
+
if ("error" in parsed) {
|
|
1500
|
+
return {
|
|
1501
|
+
code: 2,
|
|
1502
|
+
stdout: "",
|
|
1503
|
+
stderr: `${parsed.error}
|
|
1504
|
+
Run \`takuhon build --help\` for usage.
|
|
1505
|
+
`
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
return buildSite(parsed);
|
|
720
1509
|
}
|
|
721
|
-
function
|
|
1510
|
+
function parseArgs3(args) {
|
|
722
1511
|
let path;
|
|
723
|
-
let
|
|
1512
|
+
let output;
|
|
724
1513
|
let baseUrl;
|
|
725
1514
|
for (let i = 0; i < args.length; i++) {
|
|
726
1515
|
const arg = args[i];
|
|
727
|
-
if (arg === "--
|
|
1516
|
+
if (arg === "--output" || arg === "--base-url") {
|
|
728
1517
|
const value = args[i + 1];
|
|
729
1518
|
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
730
1519
|
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
731
1520
|
}
|
|
732
|
-
if (arg === "--
|
|
1521
|
+
if (arg === "--output") output = value;
|
|
733
1522
|
else baseUrl = value;
|
|
734
1523
|
i++;
|
|
735
1524
|
continue;
|
|
736
1525
|
}
|
|
737
|
-
if (arg.startsWith("--
|
|
738
|
-
const value = arg.slice("--
|
|
739
|
-
if (value === "") return { error: "takuhon: `--
|
|
740
|
-
|
|
1526
|
+
if (arg.startsWith("--output=")) {
|
|
1527
|
+
const value = arg.slice("--output=".length);
|
|
1528
|
+
if (value === "") return { error: "takuhon: `--output` requires a value." };
|
|
1529
|
+
output = value;
|
|
741
1530
|
continue;
|
|
742
1531
|
}
|
|
743
1532
|
if (arg.startsWith("--base-url=")) {
|
|
@@ -747,39 +1536,24 @@ function parseArgs2(args) {
|
|
|
747
1536
|
continue;
|
|
748
1537
|
}
|
|
749
1538
|
if (arg.startsWith("-")) {
|
|
750
|
-
return { error: `takuhon: unknown option \`${arg}\` for \`
|
|
1539
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
|
|
751
1540
|
}
|
|
752
1541
|
if (path !== void 0) {
|
|
753
|
-
return { error: "takuhon: `
|
|
1542
|
+
return { error: "takuhon: `build` takes at most one path argument." };
|
|
754
1543
|
}
|
|
755
1544
|
path = arg;
|
|
756
1545
|
}
|
|
757
|
-
|
|
758
|
-
if (portRaw !== void 0) {
|
|
759
|
-
const parsedPort = parsePort(portRaw);
|
|
760
|
-
if (parsedPort === void 0) {
|
|
761
|
-
return {
|
|
762
|
-
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
port = parsedPort;
|
|
766
|
-
}
|
|
767
|
-
if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
|
|
1546
|
+
if (baseUrl !== void 0 && !isHttpUrl3(baseUrl)) {
|
|
768
1547
|
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
769
1548
|
}
|
|
770
1549
|
return {
|
|
771
|
-
path: path ??
|
|
772
|
-
|
|
1550
|
+
path: path ?? DEFAULT_PATH4,
|
|
1551
|
+
output: output ?? DEFAULT_OUTPUT,
|
|
773
1552
|
// Drop any trailing slash so URL joins are predictable.
|
|
774
1553
|
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
775
1554
|
};
|
|
776
1555
|
}
|
|
777
|
-
function
|
|
778
|
-
if (!/^\d+$/.test(value)) return void 0;
|
|
779
|
-
const n = Number(value);
|
|
780
|
-
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : void 0;
|
|
781
|
-
}
|
|
782
|
-
function isHttpUrl2(value) {
|
|
1556
|
+
function isHttpUrl3(value) {
|
|
783
1557
|
try {
|
|
784
1558
|
const url = new URL(value);
|
|
785
1559
|
return url.protocol === "http:" || url.protocol === "https:";
|
|
@@ -787,56 +1561,69 @@ function isHttpUrl2(value) {
|
|
|
787
1561
|
return false;
|
|
788
1562
|
}
|
|
789
1563
|
}
|
|
790
|
-
function
|
|
1564
|
+
function buildSite(parsed) {
|
|
1565
|
+
const { path, output, baseUrl } = parsed;
|
|
1566
|
+
let raw;
|
|
791
1567
|
try {
|
|
792
|
-
|
|
1568
|
+
raw = readFileSync7(path, "utf8");
|
|
793
1569
|
} catch {
|
|
794
|
-
return
|
|
1570
|
+
return {
|
|
1571
|
+
code: 2,
|
|
1572
|
+
stdout: "",
|
|
1573
|
+
stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
1574
|
+
`
|
|
1575
|
+
};
|
|
795
1576
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1577
|
+
let data;
|
|
1578
|
+
try {
|
|
1579
|
+
data = JSON.parse(raw);
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1582
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
1583
|
+
` };
|
|
1584
|
+
}
|
|
1585
|
+
const result = validate2(data);
|
|
1586
|
+
if (!result.ok) {
|
|
1587
|
+
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1588
|
+
return {
|
|
1589
|
+
code: 1,
|
|
1590
|
+
stdout: "",
|
|
1591
|
+
stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to build:
|
|
1592
|
+
${lines.join("\n")}
|
|
1593
|
+
`
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
|
|
1597
|
+
const activitySnapshot = filtered.settings.activity?.enabled === true ? readActivitySnapshotSync(path) : null;
|
|
1598
|
+
const written = [];
|
|
1599
|
+
try {
|
|
1600
|
+
for (const page of generateSite(filtered, { baseUrl, activitySnapshot })) {
|
|
1601
|
+
const outFile = join6(output, page.file);
|
|
1602
|
+
mkdirSync3(dirname5(outFile), { recursive: true });
|
|
1603
|
+
writeFileAtomic(outFile, page.html);
|
|
1604
|
+
written.push(outFile);
|
|
1605
|
+
}
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1608
|
+
return { code: 2, stdout: "", stderr: `takuhon: failed to write the site: ${detail}
|
|
1609
|
+
` };
|
|
1610
|
+
}
|
|
1611
|
+
const summary = written.map((w) => ` ${w}`).join("\n");
|
|
1612
|
+
return {
|
|
1613
|
+
code: 0,
|
|
1614
|
+
stdout: `built ${written.length} page${written.length === 1 ? "" : "s"} from ${path}:
|
|
1615
|
+
${summary}
|
|
1616
|
+
`,
|
|
1617
|
+
stderr: ""
|
|
1618
|
+
};
|
|
832
1619
|
}
|
|
833
1620
|
|
|
834
1621
|
// src/export-command.ts
|
|
835
|
-
import { readFileSync as
|
|
836
|
-
import { resolve } from "path";
|
|
1622
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1623
|
+
import { resolve as resolve4 } from "path";
|
|
837
1624
|
import { exportTakuhon, validate as validate3 } from "@takuhon/core";
|
|
838
|
-
var
|
|
839
|
-
var
|
|
1625
|
+
var DEFAULT_PATH5 = "takuhon.json";
|
|
1626
|
+
var USAGE4 = `Usage: takuhon export [path] [--output <file>]
|
|
840
1627
|
|
|
841
1628
|
Serialise a takuhon.json into its transport form and print it to stdout, or
|
|
842
1629
|
write it to a file with --output. With no path, exports ./takuhon.json in the
|
|
@@ -850,9 +1637,9 @@ Exit codes: 0 = exported, 1 = source is not a valid profile,
|
|
|
850
1637
|
`;
|
|
851
1638
|
function runExport(args = []) {
|
|
852
1639
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
853
|
-
return { code: 0, stdout:
|
|
1640
|
+
return { code: 0, stdout: USAGE4, stderr: "" };
|
|
854
1641
|
}
|
|
855
|
-
const parsed =
|
|
1642
|
+
const parsed = parseArgs4(args);
|
|
856
1643
|
if ("error" in parsed) {
|
|
857
1644
|
return {
|
|
858
1645
|
code: 2,
|
|
@@ -864,7 +1651,7 @@ Run \`takuhon export --help\` for usage.
|
|
|
864
1651
|
}
|
|
865
1652
|
return exportFile(parsed);
|
|
866
1653
|
}
|
|
867
|
-
function
|
|
1654
|
+
function parseArgs4(args) {
|
|
868
1655
|
let path;
|
|
869
1656
|
let output;
|
|
870
1657
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -899,11 +1686,11 @@ function parseArgs3(args) {
|
|
|
899
1686
|
}
|
|
900
1687
|
path = arg;
|
|
901
1688
|
}
|
|
902
|
-
return { path: path ??
|
|
1689
|
+
return { path: path ?? DEFAULT_PATH5, output };
|
|
903
1690
|
}
|
|
904
1691
|
function exportFile(parsed) {
|
|
905
1692
|
const { path, output } = parsed;
|
|
906
|
-
if (output !== void 0 &&
|
|
1693
|
+
if (output !== void 0 && resolve4(output) === resolve4(path)) {
|
|
907
1694
|
return {
|
|
908
1695
|
code: 2,
|
|
909
1696
|
stdout: "",
|
|
@@ -914,7 +1701,7 @@ Omit --output to print to stdout, or choose a different file.
|
|
|
914
1701
|
}
|
|
915
1702
|
let raw;
|
|
916
1703
|
try {
|
|
917
|
-
raw =
|
|
1704
|
+
raw = readFileSync8(path, "utf8");
|
|
918
1705
|
} catch {
|
|
919
1706
|
return {
|
|
920
1707
|
code: 2,
|
|
@@ -959,7 +1746,7 @@ ${lines.join("\n")}
|
|
|
959
1746
|
}
|
|
960
1747
|
|
|
961
1748
|
// src/import-command.ts
|
|
962
|
-
import { readFileSync as
|
|
1749
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
963
1750
|
import {
|
|
964
1751
|
ImportError,
|
|
965
1752
|
MigrationError,
|
|
@@ -967,8 +1754,8 @@ import {
|
|
|
967
1754
|
importTakuhon,
|
|
968
1755
|
migrateTakuhon
|
|
969
1756
|
} from "@takuhon/core";
|
|
970
|
-
var
|
|
971
|
-
var
|
|
1757
|
+
var DEFAULT_PATH6 = "takuhon.json";
|
|
1758
|
+
var USAGE5 = `Usage: takuhon import <file> [path]
|
|
972
1759
|
|
|
973
1760
|
Load a previously exported profile from <file> into a local takuhon.json,
|
|
974
1761
|
migrating it to the current schema version if needed. With no path, writes
|
|
@@ -982,9 +1769,9 @@ version), 2 = bad arguments / file missing / unreadable / not JSON / write faile
|
|
|
982
1769
|
`;
|
|
983
1770
|
function runImport(args = [], deps = {}) {
|
|
984
1771
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
985
|
-
return { code: 0, stdout:
|
|
1772
|
+
return { code: 0, stdout: USAGE5, stderr: "" };
|
|
986
1773
|
}
|
|
987
|
-
const parsed =
|
|
1774
|
+
const parsed = parseArgs5(args);
|
|
988
1775
|
if ("error" in parsed) {
|
|
989
1776
|
return {
|
|
990
1777
|
code: 2,
|
|
@@ -997,7 +1784,7 @@ Run \`takuhon import --help\` for usage.
|
|
|
997
1784
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
998
1785
|
return importFile(parsed, now);
|
|
999
1786
|
}
|
|
1000
|
-
function
|
|
1787
|
+
function parseArgs5(args) {
|
|
1001
1788
|
const positionals = [];
|
|
1002
1789
|
for (const arg of args) {
|
|
1003
1790
|
if (arg.startsWith("-")) {
|
|
@@ -1011,13 +1798,13 @@ function parseArgs4(args) {
|
|
|
1011
1798
|
if (positionals.length > 2) {
|
|
1012
1799
|
return { error: "takuhon: `import` takes at most an input <file> and a target path." };
|
|
1013
1800
|
}
|
|
1014
|
-
return { file: positionals[0], path: positionals[1] ??
|
|
1801
|
+
return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH6 };
|
|
1015
1802
|
}
|
|
1016
1803
|
function importFile(parsed, now) {
|
|
1017
1804
|
const { file, path } = parsed;
|
|
1018
1805
|
let raw;
|
|
1019
1806
|
try {
|
|
1020
|
-
raw =
|
|
1807
|
+
raw = readFileSync9(file, "utf8");
|
|
1021
1808
|
} catch {
|
|
1022
1809
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
|
|
1023
1810
|
` };
|
|
@@ -1082,9 +1869,9 @@ ${lines2.join("\n")}` : ".";
|
|
|
1082
1869
|
}
|
|
1083
1870
|
let currentRaw;
|
|
1084
1871
|
try {
|
|
1085
|
-
currentRaw =
|
|
1872
|
+
currentRaw = readFileSync9(path, "utf8");
|
|
1086
1873
|
} catch (error) {
|
|
1087
|
-
if (!
|
|
1874
|
+
if (!isNotFound5(error)) {
|
|
1088
1875
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1089
1876
|
` };
|
|
1090
1877
|
}
|
|
@@ -1123,13 +1910,13 @@ ${lines2.join("\n")}` : ".";
|
|
|
1123
1910
|
return { code: 0, stdout: `${lines.join("\n")}
|
|
1124
1911
|
`, stderr: "" };
|
|
1125
1912
|
}
|
|
1126
|
-
function
|
|
1913
|
+
function isNotFound5(error) {
|
|
1127
1914
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1128
1915
|
}
|
|
1129
1916
|
|
|
1130
1917
|
// src/migrate-command.ts
|
|
1131
|
-
import { readFileSync as
|
|
1132
|
-
import { resolve as
|
|
1918
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1919
|
+
import { resolve as resolve5 } from "path";
|
|
1133
1920
|
import {
|
|
1134
1921
|
MigrationError as MigrationError2,
|
|
1135
1922
|
SCHEMA_VERSION as SCHEMA_VERSION2,
|
|
@@ -1137,8 +1924,8 @@ import {
|
|
|
1137
1924
|
migrateTakuhon as migrateTakuhon2,
|
|
1138
1925
|
validate as validate4
|
|
1139
1926
|
} from "@takuhon/core";
|
|
1140
|
-
var
|
|
1141
|
-
var
|
|
1927
|
+
var DEFAULT_PATH7 = "takuhon.json";
|
|
1928
|
+
var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
|
|
1142
1929
|
|
|
1143
1930
|
Forward-migrate a takuhon.json to a newer schema version. The source version
|
|
1144
1931
|
is read from the file's own schemaVersion. With no path, migrates
|
|
@@ -1160,9 +1947,9 @@ Exit codes: 0 = migrated / already current / dry-run, 1 = cannot migrate,
|
|
|
1160
1947
|
`;
|
|
1161
1948
|
function runMigrate(args = [], deps = {}) {
|
|
1162
1949
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1163
|
-
return { code: 0, stdout:
|
|
1950
|
+
return { code: 0, stdout: USAGE6, stderr: "" };
|
|
1164
1951
|
}
|
|
1165
|
-
const parsed =
|
|
1952
|
+
const parsed = parseArgs6(args);
|
|
1166
1953
|
if ("error" in parsed) {
|
|
1167
1954
|
return {
|
|
1168
1955
|
code: 2,
|
|
@@ -1175,7 +1962,7 @@ Run \`takuhon migrate --help\` for usage.
|
|
|
1175
1962
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1176
1963
|
return migrateFile(parsed, now);
|
|
1177
1964
|
}
|
|
1178
|
-
function
|
|
1965
|
+
function parseArgs6(args) {
|
|
1179
1966
|
let path;
|
|
1180
1967
|
let to;
|
|
1181
1968
|
let out;
|
|
@@ -1218,13 +2005,13 @@ function parseArgs5(args) {
|
|
|
1218
2005
|
error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
|
|
1219
2006
|
};
|
|
1220
2007
|
}
|
|
1221
|
-
return { path: path ??
|
|
2008
|
+
return { path: path ?? DEFAULT_PATH7, to: target, out, dryRun };
|
|
1222
2009
|
}
|
|
1223
2010
|
function migrateFile(parsed, now) {
|
|
1224
2011
|
const { path, to: target, out, dryRun } = parsed;
|
|
1225
2012
|
let raw;
|
|
1226
2013
|
try {
|
|
1227
|
-
raw =
|
|
2014
|
+
raw = readFileSync10(path, "utf8");
|
|
1228
2015
|
} catch {
|
|
1229
2016
|
return {
|
|
1230
2017
|
code: 2,
|
|
@@ -1259,7 +2046,7 @@ function migrateFile(parsed, now) {
|
|
|
1259
2046
|
};
|
|
1260
2047
|
}
|
|
1261
2048
|
const writeTarget = out ?? path;
|
|
1262
|
-
const inPlace =
|
|
2049
|
+
const inPlace = resolve5(writeTarget) === resolve5(path);
|
|
1263
2050
|
let migrated;
|
|
1264
2051
|
try {
|
|
1265
2052
|
migrated = migrateTakuhon2(data, target);
|
|
@@ -1337,11 +2124,119 @@ ${lines2.join("\n")}
|
|
|
1337
2124
|
`, stderr: "" };
|
|
1338
2125
|
}
|
|
1339
2126
|
|
|
2127
|
+
// src/refresh-admin-command.ts
|
|
2128
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
2129
|
+
import { cp, mkdtemp, readdir, rename, rm, stat } from "fs/promises";
|
|
2130
|
+
import { join as join7, resolve as resolve6 } from "path";
|
|
2131
|
+
var PROFILE_FILENAME = "takuhon.json";
|
|
2132
|
+
var USAGE7 = `Usage: takuhon admin update [path]
|
|
2133
|
+
|
|
2134
|
+
Refresh a project's ${ADMIN_DIST_DIRNAME}/ with the admin form-UI bundle shipped in
|
|
2135
|
+
this @takuhon/cli. Use it after upgrading @takuhon/cli to pick up a newer admin
|
|
2136
|
+
UI without re-scaffolding. With no path, refreshes the project in the current
|
|
2137
|
+
directory. The project must already have an ${ADMIN_DIST_DIRNAME}/ (created by
|
|
2138
|
+
create-takuhon); this command updates it, it does not create one.
|
|
2139
|
+
|
|
2140
|
+
Exit codes: 0 = refreshed, 2 = bad arguments / not a takuhon project /
|
|
2141
|
+
no ${ADMIN_DIST_DIRNAME}/ to refresh / copy failed.
|
|
2142
|
+
`;
|
|
2143
|
+
async function runRefreshAdmin(args = [], opts = {}) {
|
|
2144
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
2145
|
+
return { code: 0, stdout: USAGE7, stderr: "" };
|
|
2146
|
+
}
|
|
2147
|
+
const unknownFlag = args.find((arg) => arg.startsWith("-"));
|
|
2148
|
+
if (unknownFlag !== void 0) {
|
|
2149
|
+
return usageError(`unknown option '${unknownFlag}'.`);
|
|
2150
|
+
}
|
|
2151
|
+
if (args.length > 1) {
|
|
2152
|
+
return usageError("`admin update` takes at most one path argument.");
|
|
2153
|
+
}
|
|
2154
|
+
const displayDir = args[0] ?? ".";
|
|
2155
|
+
const projectDir = resolve6(args[0] ?? ".");
|
|
2156
|
+
try {
|
|
2157
|
+
await stat(join7(projectDir, PROFILE_FILENAME));
|
|
2158
|
+
} catch {
|
|
2159
|
+
return {
|
|
2160
|
+
code: 2,
|
|
2161
|
+
stdout: "",
|
|
2162
|
+
stderr: `takuhon: '${displayDir}' is not a takuhon project (no ${PROFILE_FILENAME} found).
|
|
2163
|
+
Run \`takuhon admin update\` from a project created by create-takuhon.
|
|
2164
|
+
`
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
const adminDist = join7(projectDir, ADMIN_DIST_DIRNAME);
|
|
2168
|
+
const adminDistStat = await stat(adminDist).catch(() => void 0);
|
|
2169
|
+
if (adminDistStat === void 0) {
|
|
2170
|
+
return {
|
|
2171
|
+
code: 2,
|
|
2172
|
+
stdout: "",
|
|
2173
|
+
stderr: `takuhon: '${displayDir}' has no ${ADMIN_DIST_DIRNAME}/ directory to refresh.
|
|
2174
|
+
This project was not scaffolded with the admin form UI; create a new project with create-takuhon to add it.
|
|
2175
|
+
`
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
if (!adminDistStat.isDirectory()) {
|
|
2179
|
+
return {
|
|
2180
|
+
code: 2,
|
|
2181
|
+
stdout: "",
|
|
2182
|
+
stderr: `takuhon: '${join7(displayDir, ADMIN_DIST_DIRNAME)}' exists but is not a directory.
|
|
2183
|
+
`
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
|
|
2187
|
+
let fileCount;
|
|
2188
|
+
let staged;
|
|
2189
|
+
try {
|
|
2190
|
+
fileCount = (await readdir(bundleDir, { recursive: true, withFileTypes: true })).filter(
|
|
2191
|
+
(entry) => entry.isFile()
|
|
2192
|
+
).length;
|
|
2193
|
+
staged = await mkdtemp(join7(projectDir, `.${ADMIN_DIST_DIRNAME}-`));
|
|
2194
|
+
await cp(bundleDir, staged, { recursive: true });
|
|
2195
|
+
await rm(adminDist, { recursive: true, force: true });
|
|
2196
|
+
await rename(staged, adminDist);
|
|
2197
|
+
staged = void 0;
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
if (staged !== void 0) {
|
|
2200
|
+
await rm(staged, { recursive: true, force: true }).catch(() => void 0);
|
|
2201
|
+
}
|
|
2202
|
+
const code = isNodeErrnoException(err) ? ` (${err.code})` : "";
|
|
2203
|
+
return {
|
|
2204
|
+
code: 2,
|
|
2205
|
+
stdout: "",
|
|
2206
|
+
stderr: `takuhon: failed to refresh ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli${code}. Reinstall or upgrade @takuhon/cli and try again.
|
|
2207
|
+
`
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
const plural = fileCount === 1 ? "" : "s";
|
|
2211
|
+
return {
|
|
2212
|
+
code: 0,
|
|
2213
|
+
stdout: `Refreshed ${ADMIN_DIST_DIRNAME}/ from @takuhon/cli@${readVersion()} (${fileCount} file${plural}).
|
|
2214
|
+
`,
|
|
2215
|
+
stderr: ""
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
function usageError(message) {
|
|
2219
|
+
return {
|
|
2220
|
+
code: 2,
|
|
2221
|
+
stdout: "",
|
|
2222
|
+
stderr: `takuhon: ${message}
|
|
2223
|
+
Run \`takuhon admin update --help\` for usage.
|
|
2224
|
+
`
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
function readVersion() {
|
|
2228
|
+
const pkg2 = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
|
|
2229
|
+
return pkg2.version;
|
|
2230
|
+
}
|
|
2231
|
+
function isNodeErrnoException(err) {
|
|
2232
|
+
return err instanceof Error && typeof err.code === "string";
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1340
2235
|
// src/restore-command.ts
|
|
1341
|
-
import { readFileSync as
|
|
2236
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
1342
2237
|
import { validate as validate5 } from "@takuhon/core";
|
|
1343
|
-
var
|
|
1344
|
-
var
|
|
2238
|
+
var DEFAULT_PATH8 = "takuhon.json";
|
|
2239
|
+
var USAGE8 = `Usage: takuhon restore --from <backup> [path] [--yes]
|
|
1345
2240
|
|
|
1346
2241
|
Overwrite a profile with a previously saved backup. With no path, restores
|
|
1347
2242
|
./takuhon.json in the current working directory.
|
|
@@ -1358,9 +2253,9 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
|
|
|
1358
2253
|
`;
|
|
1359
2254
|
async function runRestore(args = [], deps = {}) {
|
|
1360
2255
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1361
|
-
return { code: 0, stdout:
|
|
2256
|
+
return { code: 0, stdout: USAGE8, stderr: "" };
|
|
1362
2257
|
}
|
|
1363
|
-
const parsed =
|
|
2258
|
+
const parsed = parseArgs7(args);
|
|
1364
2259
|
if ("error" in parsed) {
|
|
1365
2260
|
return {
|
|
1366
2261
|
code: 2,
|
|
@@ -1373,7 +2268,7 @@ Run \`takuhon restore --help\` for usage.
|
|
|
1373
2268
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1374
2269
|
return restoreFile(parsed, now, deps.confirm);
|
|
1375
2270
|
}
|
|
1376
|
-
function
|
|
2271
|
+
function parseArgs7(args) {
|
|
1377
2272
|
let from;
|
|
1378
2273
|
let path;
|
|
1379
2274
|
let yes = false;
|
|
@@ -1407,13 +2302,13 @@ function parseArgs6(args) {
|
|
|
1407
2302
|
if (from === void 0 || from.length === 0) {
|
|
1408
2303
|
return { error: "takuhon: `restore` requires `--from <backup>`." };
|
|
1409
2304
|
}
|
|
1410
|
-
return { from, path: path ??
|
|
2305
|
+
return { from, path: path ?? DEFAULT_PATH8, yes };
|
|
1411
2306
|
}
|
|
1412
2307
|
async function restoreFile(parsed, now, confirm) {
|
|
1413
2308
|
const { from, path, yes } = parsed;
|
|
1414
2309
|
let backupRaw;
|
|
1415
2310
|
try {
|
|
1416
|
-
backupRaw =
|
|
2311
|
+
backupRaw = readFileSync12(from, "utf8");
|
|
1417
2312
|
} catch {
|
|
1418
2313
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
|
|
1419
2314
|
` };
|
|
@@ -1443,9 +2338,9 @@ ${lines2.join("\n")}
|
|
|
1443
2338
|
}
|
|
1444
2339
|
let currentRaw;
|
|
1445
2340
|
try {
|
|
1446
|
-
currentRaw =
|
|
2341
|
+
currentRaw = readFileSync12(path, "utf8");
|
|
1447
2342
|
} catch (error) {
|
|
1448
|
-
if (!
|
|
2343
|
+
if (!isNotFound6(error)) {
|
|
1449
2344
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1450
2345
|
` };
|
|
1451
2346
|
}
|
|
@@ -1508,17 +2403,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
|
|
|
1508
2403
|
${preNote}
|
|
1509
2404
|
Continue? [y/N]`;
|
|
1510
2405
|
}
|
|
1511
|
-
function
|
|
2406
|
+
function isNotFound6(error) {
|
|
1512
2407
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1513
2408
|
}
|
|
1514
2409
|
|
|
1515
2410
|
// src/sync-command.ts
|
|
1516
|
-
import { readFileSync as
|
|
2411
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
1517
2412
|
import { validate as validate6 } from "@takuhon/core";
|
|
1518
|
-
var
|
|
2413
|
+
var DEFAULT_PATH9 = "takuhon.json";
|
|
1519
2414
|
var ADMIN_PROFILE_PATH = "/api/admin/profile";
|
|
1520
2415
|
var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
|
|
1521
|
-
var
|
|
2416
|
+
var USAGE9 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
|
|
1522
2417
|
|
|
1523
2418
|
Push a local takuhon.json to a deployed takuhon instance by calling its admin
|
|
1524
2419
|
write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
|
|
@@ -1544,9 +2439,9 @@ unset / auth failure / network error / other non-success response.
|
|
|
1544
2439
|
`;
|
|
1545
2440
|
async function runSync(args = [], deps = {}) {
|
|
1546
2441
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1547
|
-
return { code: 0, stdout:
|
|
2442
|
+
return { code: 0, stdout: USAGE9, stderr: "" };
|
|
1548
2443
|
}
|
|
1549
|
-
const parsed =
|
|
2444
|
+
const parsed = parseArgs8(args);
|
|
1550
2445
|
if ("error" in parsed) {
|
|
1551
2446
|
return {
|
|
1552
2447
|
code: 2,
|
|
@@ -1558,7 +2453,7 @@ Run \`takuhon sync --help\` for usage.
|
|
|
1558
2453
|
}
|
|
1559
2454
|
return syncProfile(parsed, deps);
|
|
1560
2455
|
}
|
|
1561
|
-
function
|
|
2456
|
+
function parseArgs8(args) {
|
|
1562
2457
|
let path;
|
|
1563
2458
|
let url;
|
|
1564
2459
|
let ifMatch;
|
|
@@ -1604,7 +2499,7 @@ function parseArgs7(args) {
|
|
|
1604
2499
|
}
|
|
1605
2500
|
const base = parseOrigin(url);
|
|
1606
2501
|
if ("error" in base) return base;
|
|
1607
|
-
return { path: path ??
|
|
2502
|
+
return { path: path ?? DEFAULT_PATH9, url: base.origin, ifMatch, dryRun };
|
|
1608
2503
|
}
|
|
1609
2504
|
function parseOrigin(value) {
|
|
1610
2505
|
let parsed;
|
|
@@ -1637,7 +2532,7 @@ async function syncProfile(parsed, deps) {
|
|
|
1637
2532
|
const { path, url, ifMatch, dryRun } = parsed;
|
|
1638
2533
|
let raw;
|
|
1639
2534
|
try {
|
|
1640
|
-
raw =
|
|
2535
|
+
raw = readFileSync13(path, "utf8");
|
|
1641
2536
|
} catch {
|
|
1642
2537
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
|
|
1643
2538
|
` };
|
|
@@ -1708,7 +2603,7 @@ Set it for this command only, e.g.:
|
|
|
1708
2603
|
async function interpretResponse(res, target) {
|
|
1709
2604
|
const { path, url, endpoint } = target;
|
|
1710
2605
|
if (res.ok) {
|
|
1711
|
-
const version = await
|
|
2606
|
+
const version = await readVersion2(res);
|
|
1712
2607
|
if (version === void 0) {
|
|
1713
2608
|
return {
|
|
1714
2609
|
code: 2,
|
|
@@ -1750,7 +2645,7 @@ ${lines.join("\n")}` : ".";
|
|
|
1750
2645
|
return { code: 2, stdout: "", stderr: `takuhon: sync failed (${String(status)})${tail}
|
|
1751
2646
|
` };
|
|
1752
2647
|
}
|
|
1753
|
-
async function
|
|
2648
|
+
async function readVersion2(res) {
|
|
1754
2649
|
try {
|
|
1755
2650
|
const parsed = await res.json();
|
|
1756
2651
|
const version = parsed.meta?.version;
|
|
@@ -1774,10 +2669,10 @@ async function readProblem(res) {
|
|
|
1774
2669
|
}
|
|
1775
2670
|
|
|
1776
2671
|
// src/validate-command.ts
|
|
1777
|
-
import { readFileSync as
|
|
2672
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
1778
2673
|
import { validate as validate7 } from "@takuhon/core";
|
|
1779
|
-
var
|
|
1780
|
-
var
|
|
2674
|
+
var DEFAULT_PATH10 = "takuhon.json";
|
|
2675
|
+
var USAGE10 = `Usage: takuhon validate [path]
|
|
1781
2676
|
|
|
1782
2677
|
Validate a takuhon.json against the takuhon schema. With no path, validates
|
|
1783
2678
|
./takuhon.json in the current working directory.
|
|
@@ -1786,7 +2681,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
|
|
|
1786
2681
|
`;
|
|
1787
2682
|
function runValidate(args = []) {
|
|
1788
2683
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1789
|
-
return { code: 0, stdout:
|
|
2684
|
+
return { code: 0, stdout: USAGE10, stderr: "" };
|
|
1790
2685
|
}
|
|
1791
2686
|
if (args.length > 1) {
|
|
1792
2687
|
return {
|
|
@@ -1798,10 +2693,10 @@ function runValidate(args = []) {
|
|
|
1798
2693
|
return validateFile(args[0]);
|
|
1799
2694
|
}
|
|
1800
2695
|
function validateFile(pathArg) {
|
|
1801
|
-
const target = pathArg ??
|
|
2696
|
+
const target = pathArg ?? DEFAULT_PATH10;
|
|
1802
2697
|
let raw;
|
|
1803
2698
|
try {
|
|
1804
|
-
raw =
|
|
2699
|
+
raw = readFileSync14(target, "utf8");
|
|
1805
2700
|
} catch {
|
|
1806
2701
|
return {
|
|
1807
2702
|
code: 2,
|
|
@@ -1843,7 +2738,7 @@ ${lines.join("\n")}
|
|
|
1843
2738
|
}
|
|
1844
2739
|
|
|
1845
2740
|
// src/index.ts
|
|
1846
|
-
var pkg = JSON.parse(
|
|
2741
|
+
var pkg = JSON.parse(readFileSync15(new URL("../package.json", import.meta.url), "utf8"));
|
|
1847
2742
|
var VERSION = pkg.version;
|
|
1848
2743
|
var HELP = `takuhon ${VERSION}
|
|
1849
2744
|
|
|
@@ -1869,10 +2764,22 @@ Commands:
|
|
|
1869
2764
|
takuhon dev [path] [--port <n>] Serve a takuhon.json as a local static preview,
|
|
1870
2765
|
re-rendered on each request (default port: 4321).
|
|
1871
2766
|
--base-url <url> adds canonical/hreflang links.
|
|
2767
|
+
takuhon admin [path] [--port <n>] Run a local admin server: edit the profile through
|
|
2768
|
+
the form UI at /admin (writes takuhon.json, backs up
|
|
2769
|
+
first) with a preview at / (default port: 4322). Binds
|
|
2770
|
+
127.0.0.1; prints a per-run token to paste into the form.
|
|
2771
|
+
takuhon admin update [path] Refresh a project's admin-dist/ with the admin form UI
|
|
2772
|
+
bundled in this @takuhon/cli (use after upgrading the
|
|
2773
|
+
CLI). Updates an existing admin-dist/ only.
|
|
1872
2774
|
takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
|
|
1873
2775
|
(PUT <url>/api/admin/profile). Reads the admin token
|
|
1874
2776
|
from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
|
|
1875
2777
|
optimistic locking; --dry-run previews without sending.
|
|
2778
|
+
takuhon activity sync [path] Fetch the GitHub / WakaTime activity configured in
|
|
2779
|
+
settings.activity and store it as activity.json beside
|
|
2780
|
+
the profile. Secrets come from the environment only:
|
|
2781
|
+
TAKUHON_GITHUB_TOKEN (optional) / TAKUHON_WAKATIME_KEY.
|
|
2782
|
+
takuhon activity show [path] Print the stored activity snapshot (activity.json).
|
|
1876
2783
|
|
|
1877
2784
|
Scaffolding a new profile project:
|
|
1878
2785
|
npx create-takuhon my-profile
|
|
@@ -1904,6 +2811,12 @@ async function main(argv) {
|
|
|
1904
2811
|
if (first === "dev") {
|
|
1905
2812
|
return runDev(argv.slice(1));
|
|
1906
2813
|
}
|
|
2814
|
+
if (first === "admin") {
|
|
2815
|
+
if (argv[1] === "update") {
|
|
2816
|
+
return emit(await runRefreshAdmin(argv.slice(2)));
|
|
2817
|
+
}
|
|
2818
|
+
return runAdmin(argv.slice(1));
|
|
2819
|
+
}
|
|
1907
2820
|
if (first === "import") {
|
|
1908
2821
|
return emit(runImport(argv.slice(1)));
|
|
1909
2822
|
}
|
|
@@ -1914,6 +2827,26 @@ async function main(argv) {
|
|
|
1914
2827
|
if (first === "sync") {
|
|
1915
2828
|
return emit(await runSync(argv.slice(1)));
|
|
1916
2829
|
}
|
|
2830
|
+
if (first === "activity") {
|
|
2831
|
+
const sub = argv[1];
|
|
2832
|
+
if (sub === "sync") {
|
|
2833
|
+
return emit(await runActivitySync(argv.slice(2)));
|
|
2834
|
+
}
|
|
2835
|
+
if (sub === "show") {
|
|
2836
|
+
return emit(await runActivityShow(argv.slice(2)));
|
|
2837
|
+
}
|
|
2838
|
+
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";
|
|
2839
|
+
if (sub === "--help" || sub === "-h") {
|
|
2840
|
+
process.stdout.write(usage);
|
|
2841
|
+
return 0;
|
|
2842
|
+
}
|
|
2843
|
+
process.stderr.write(
|
|
2844
|
+
sub === void 0 ? `takuhon: \`activity\` requires a subcommand.
|
|
2845
|
+
${usage}` : `takuhon: unknown subcommand \`${sub}\` for \`activity\`.
|
|
2846
|
+
${usage}`
|
|
2847
|
+
);
|
|
2848
|
+
return 2;
|
|
2849
|
+
}
|
|
1917
2850
|
process.stderr.write(
|
|
1918
2851
|
`takuhon: unknown command '${first}'
|
|
1919
2852
|
Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-takuhon\`.
|