@takuhon/cli 0.10.0 → 0.11.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/dist/chunk-JI2AH5Y3.js +404 -0
- package/dist/chunk-JI2AH5Y3.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +702 -379
- package/dist/index.js.map +1 -1
- package/dist/init.js +8 -398
- package/dist/init.js.map +1 -1
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,83 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
resolveAdminBundleDir
|
|
4
|
+
} from "./chunk-JI2AH5Y3.js";
|
|
2
5
|
|
|
3
6
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
7
|
+
import { readFileSync as readFileSync11, realpathSync } from "fs";
|
|
5
8
|
import { stdin, stdout } from "process";
|
|
6
9
|
import { createInterface } from "readline/promises";
|
|
7
10
|
import { fileURLToPath } from "url";
|
|
8
11
|
|
|
9
|
-
// src/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
this.name = "BackupError";
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
function compactTimestamp(date, withMillis = false) {
|
|
25
|
-
const iso = date.toISOString();
|
|
26
|
-
const trimmed = withMillis ? iso : iso.replace(/\.\d{3}Z$/, "Z");
|
|
27
|
-
return trimmed.replace(/[-:]/g, "");
|
|
28
|
-
}
|
|
29
|
-
function migrateBackupName(version, date, withMillis = false) {
|
|
30
|
-
return `takuhon-backup-v${version}-${compactTimestamp(date, withMillis)}.json`;
|
|
31
|
-
}
|
|
32
|
-
function preRestoreName(date, withMillis = false) {
|
|
33
|
-
return `pre-restore-${compactTimestamp(date, withMillis)}.json`;
|
|
34
|
-
}
|
|
35
|
-
function preImportName(date, withMillis = false) {
|
|
36
|
-
return `pre-import-${compactTimestamp(date, withMillis)}.json`;
|
|
37
|
-
}
|
|
38
|
-
function backupDirFor(targetPath) {
|
|
39
|
-
return join(dirname(targetPath), BACKUP_DIR_NAME);
|
|
40
|
-
}
|
|
41
|
-
function createBackup(params) {
|
|
42
|
-
const dir = backupDirFor(params.targetPath);
|
|
43
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
-
const primary = join(dir, params.name(false));
|
|
45
|
-
try {
|
|
46
|
-
writeFileSync(primary, params.content, { flag: "wx" });
|
|
47
|
-
return primary;
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (!isAlreadyExists(error)) throw error;
|
|
50
|
-
}
|
|
51
|
-
const fallback = join(dir, params.name(true));
|
|
52
|
-
try {
|
|
53
|
-
writeFileSync(fallback, params.content, { flag: "wx" });
|
|
54
|
-
return fallback;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
if (isAlreadyExists(error)) {
|
|
57
|
-
throw new BackupError(
|
|
58
|
-
`backup target already exists and could not be disambiguated: ${fallback}`,
|
|
59
|
-
{ cause: error }
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
throw error;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function isAlreadyExists(error) {
|
|
66
|
-
return typeof error === "object" && error !== null && error.code === "EEXIST";
|
|
67
|
-
}
|
|
68
|
-
function writeFileAtomic(target, content) {
|
|
69
|
-
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
|
|
70
|
-
try {
|
|
71
|
-
writeFileSync(tmp, content, "utf8");
|
|
72
|
-
renameSync(tmp, target);
|
|
73
|
-
} catch (error) {
|
|
74
|
-
rmSync(tmp, { force: true });
|
|
75
|
-
throw error;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
12
|
+
// src/admin-command.ts
|
|
13
|
+
import { randomBytes } from "crypto";
|
|
14
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
15
|
+
import { extname, join as join2, resolve, sep } from "path";
|
|
16
|
+
import { serve } from "@hono/node-server";
|
|
17
|
+
import {
|
|
18
|
+
adminAssetSecurityHeaders,
|
|
19
|
+
createAdminApiApp,
|
|
20
|
+
noopAuditLogger,
|
|
21
|
+
noopCachePurger
|
|
22
|
+
} from "@takuhon/api";
|
|
23
|
+
import { Hono } from "hono";
|
|
78
24
|
|
|
79
|
-
// src/
|
|
80
|
-
import {
|
|
25
|
+
// src/dev-command.ts
|
|
26
|
+
import { readFileSync } from "fs";
|
|
27
|
+
import { createServer } from "http";
|
|
28
|
+
import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
|
|
81
29
|
|
|
82
30
|
// src/build-html.ts
|
|
83
31
|
import { generateJsonLd } from "@takuhon/core";
|
|
@@ -366,6 +314,7 @@ ${footer ? `${footer}
|
|
|
366
314
|
}
|
|
367
315
|
|
|
368
316
|
// src/site.ts
|
|
317
|
+
import { resolveLocale } from "@takuhon/core";
|
|
369
318
|
function generateSite(profile, options = {}) {
|
|
370
319
|
const { baseUrl } = options;
|
|
371
320
|
const defaultLocale = profile.settings.defaultLocale;
|
|
@@ -410,161 +359,10 @@ function localeHref(from, to, defaultLocale) {
|
|
|
410
359
|
return toRoot ? "../" : `../${to}/`;
|
|
411
360
|
}
|
|
412
361
|
|
|
413
|
-
// src/build-command.ts
|
|
414
|
-
var DEFAULT_PATH = "takuhon.json";
|
|
415
|
-
var DEFAULT_OUTPUT = "dist";
|
|
416
|
-
var USAGE = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
|
|
417
|
-
|
|
418
|
-
Render a takuhon.json into a static site (one HTML page per locale, with
|
|
419
|
-
build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
|
|
420
|
-
|
|
421
|
-
Options:
|
|
422
|
-
--output <dir> Output directory (default: ${DEFAULT_OUTPUT}). The default
|
|
423
|
-
locale is written to <dir>/index.html and each other locale
|
|
424
|
-
to <dir>/<locale>/index.html.
|
|
425
|
-
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
426
|
-
canonical and hreflang links; without it those are omitted.
|
|
427
|
-
|
|
428
|
-
The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
|
|
429
|
-
referenced as-is and are not copied. The output directory is written into, not
|
|
430
|
-
cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
|
|
431
|
-
|
|
432
|
-
Exit codes: 0 = built, 1 = source is not a valid profile,
|
|
433
|
-
2 = bad arguments / file missing / unreadable / not JSON / write failed.
|
|
434
|
-
`;
|
|
435
|
-
function runBuild(args = []) {
|
|
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;
|
|
507
|
-
let raw;
|
|
508
|
-
try {
|
|
509
|
-
raw = readFileSync(path, "utf8");
|
|
510
|
-
} 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
|
-
};
|
|
517
|
-
}
|
|
518
|
-
let data;
|
|
519
|
-
try {
|
|
520
|
-
data = JSON.parse(raw);
|
|
521
|
-
} catch (error) {
|
|
522
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
523
|
-
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
524
|
-
` };
|
|
525
|
-
}
|
|
526
|
-
const result = validate(data);
|
|
527
|
-
if (!result.ok) {
|
|
528
|
-
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
529
|
-
return {
|
|
530
|
-
code: 1,
|
|
531
|
-
stdout: "",
|
|
532
|
-
stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to build:
|
|
533
|
-
${lines.join("\n")}
|
|
534
|
-
`
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
const filtered = applyPublicPrivacyFilter(normalize(result.data));
|
|
538
|
-
const written = [];
|
|
539
|
-
try {
|
|
540
|
-
for (const page of generateSite(filtered, { baseUrl })) {
|
|
541
|
-
const outFile = join2(output, page.file);
|
|
542
|
-
mkdirSync2(dirname2(outFile), { recursive: true });
|
|
543
|
-
writeFileAtomic(outFile, page.html);
|
|
544
|
-
written.push(outFile);
|
|
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
362
|
// src/dev-command.ts
|
|
562
|
-
|
|
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";
|
|
363
|
+
var DEFAULT_PATH = "takuhon.json";
|
|
566
364
|
var DEFAULT_PORT = 4321;
|
|
567
|
-
var
|
|
365
|
+
var USAGE = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
|
|
568
366
|
|
|
569
367
|
Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
|
|
570
368
|
surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
|
|
@@ -585,7 +383,7 @@ unreadable / port in use.
|
|
|
585
383
|
function loadSiteState(path, baseUrl) {
|
|
586
384
|
let raw;
|
|
587
385
|
try {
|
|
588
|
-
raw =
|
|
386
|
+
raw = readFileSync(path, "utf8");
|
|
589
387
|
} catch {
|
|
590
388
|
return { ok: false, status: 500, message: `cannot read '${path}'.` };
|
|
591
389
|
}
|
|
@@ -596,7 +394,7 @@ function loadSiteState(path, baseUrl) {
|
|
|
596
394
|
const detail = error instanceof Error ? error.message : String(error);
|
|
597
395
|
return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
|
|
598
396
|
}
|
|
599
|
-
const result =
|
|
397
|
+
const result = validate(data);
|
|
600
398
|
if (!result.ok) {
|
|
601
399
|
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
602
400
|
return {
|
|
@@ -606,7 +404,7 @@ function loadSiteState(path, baseUrl) {
|
|
|
606
404
|
${lines.join("\n")}`
|
|
607
405
|
};
|
|
608
406
|
}
|
|
609
|
-
const filtered =
|
|
407
|
+
const filtered = applyPublicPrivacyFilter(normalize(result.data));
|
|
610
408
|
const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
|
|
611
409
|
return { ok: true, pages };
|
|
612
410
|
}
|
|
@@ -661,10 +459,10 @@ async function runDev(args = [], deps = {}) {
|
|
|
661
459
|
const out = deps.stdout ?? ((text) => void process.stdout.write(text));
|
|
662
460
|
const err = deps.stderr ?? ((text) => void process.stderr.write(text));
|
|
663
461
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
664
|
-
out(
|
|
462
|
+
out(USAGE);
|
|
665
463
|
return 0;
|
|
666
464
|
}
|
|
667
|
-
const parsed =
|
|
465
|
+
const parsed = parseArgs(args);
|
|
668
466
|
if ("error" in parsed) {
|
|
669
467
|
err(`${parsed.error}
|
|
670
468
|
Run \`takuhon dev --help\` for usage.
|
|
@@ -672,7 +470,7 @@ Run \`takuhon dev --help\` for usage.
|
|
|
672
470
|
return 2;
|
|
673
471
|
}
|
|
674
472
|
try {
|
|
675
|
-
|
|
473
|
+
readFileSync(parsed.path, "utf8");
|
|
676
474
|
} catch {
|
|
677
475
|
err(
|
|
678
476
|
`takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
@@ -680,18 +478,512 @@ Run \`takuhon dev --help\` for usage.
|
|
|
680
478
|
);
|
|
681
479
|
return 2;
|
|
682
480
|
}
|
|
683
|
-
const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
|
|
684
|
-
return await new Promise((
|
|
481
|
+
const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
|
|
482
|
+
return await new Promise((resolve4) => {
|
|
483
|
+
let closing = false;
|
|
484
|
+
const shutdown = () => {
|
|
485
|
+
if (closing) return;
|
|
486
|
+
closing = true;
|
|
487
|
+
process.removeListener("SIGINT", shutdown);
|
|
488
|
+
process.removeListener("SIGTERM", shutdown);
|
|
489
|
+
server.close(() => resolve4(0));
|
|
490
|
+
server.closeAllConnections();
|
|
491
|
+
};
|
|
492
|
+
server.once("error", (error) => {
|
|
493
|
+
if (error.code === "EADDRINUSE") {
|
|
494
|
+
err(`takuhon: port ${parsed.port} is already in use; pass --port <n> to choose another.
|
|
495
|
+
`);
|
|
496
|
+
} else {
|
|
497
|
+
err(`takuhon: ${error.message}
|
|
498
|
+
`);
|
|
499
|
+
}
|
|
500
|
+
resolve4(2);
|
|
501
|
+
});
|
|
502
|
+
server.listen(parsed.port, "127.0.0.1", () => {
|
|
503
|
+
out(
|
|
504
|
+
`takuhon dev: serving ${parsed.path} at http://localhost:${parsed.port}/ (Ctrl-C to stop)
|
|
505
|
+
`
|
|
506
|
+
);
|
|
507
|
+
const state = loadSiteState(parsed.path, parsed.baseUrl);
|
|
508
|
+
if (!state.ok) {
|
|
509
|
+
err(
|
|
510
|
+
`takuhon dev: ${parsed.path} is not a valid profile yet; the preview will show the error until it is fixed.
|
|
511
|
+
`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
process.once("SIGINT", shutdown);
|
|
515
|
+
process.once("SIGTERM", shutdown);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
function parseArgs(args) {
|
|
520
|
+
let path;
|
|
521
|
+
let portRaw;
|
|
522
|
+
let baseUrl;
|
|
523
|
+
for (let i = 0; i < args.length; i++) {
|
|
524
|
+
const arg = args[i];
|
|
525
|
+
if (arg === "--port" || arg === "--base-url") {
|
|
526
|
+
const value = args[i + 1];
|
|
527
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
528
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
529
|
+
}
|
|
530
|
+
if (arg === "--port") portRaw = value;
|
|
531
|
+
else baseUrl = value;
|
|
532
|
+
i++;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (arg.startsWith("--port=")) {
|
|
536
|
+
const value = arg.slice("--port=".length);
|
|
537
|
+
if (value === "") return { error: "takuhon: `--port` requires a value." };
|
|
538
|
+
portRaw = value;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (arg.startsWith("--base-url=")) {
|
|
542
|
+
const value = arg.slice("--base-url=".length);
|
|
543
|
+
if (value === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
544
|
+
baseUrl = value;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (arg.startsWith("-")) {
|
|
548
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`dev\`.` };
|
|
549
|
+
}
|
|
550
|
+
if (path !== void 0) {
|
|
551
|
+
return { error: "takuhon: `dev` takes at most one path argument." };
|
|
552
|
+
}
|
|
553
|
+
path = arg;
|
|
554
|
+
}
|
|
555
|
+
let port = DEFAULT_PORT;
|
|
556
|
+
if (portRaw !== void 0) {
|
|
557
|
+
const parsedPort = parsePort(portRaw);
|
|
558
|
+
if (parsedPort === void 0) {
|
|
559
|
+
return {
|
|
560
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
port = parsedPort;
|
|
564
|
+
}
|
|
565
|
+
if (baseUrl !== void 0 && !isHttpUrl(baseUrl)) {
|
|
566
|
+
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
path: path ?? DEFAULT_PATH,
|
|
570
|
+
port,
|
|
571
|
+
// Drop any trailing slash so URL joins are predictable.
|
|
572
|
+
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function parsePort(value) {
|
|
576
|
+
if (!/^\d+$/.test(value)) return void 0;
|
|
577
|
+
const n = Number(value);
|
|
578
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : void 0;
|
|
579
|
+
}
|
|
580
|
+
function isHttpUrl(value) {
|
|
581
|
+
try {
|
|
582
|
+
const url = new URL(value);
|
|
583
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function pathnameOf(url) {
|
|
589
|
+
try {
|
|
590
|
+
return new URL(url, "http://localhost").pathname;
|
|
591
|
+
} catch {
|
|
592
|
+
return url;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function devPage(title, body) {
|
|
596
|
+
return `<!DOCTYPE html>
|
|
597
|
+
<html lang="en">
|
|
598
|
+
<head>
|
|
599
|
+
<meta charset="utf-8">
|
|
600
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
601
|
+
<title>${escapeHtml(title)}</title>
|
|
602
|
+
<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>
|
|
603
|
+
</head>
|
|
604
|
+
<body>
|
|
605
|
+
<main>
|
|
606
|
+
${body}
|
|
607
|
+
</main>
|
|
608
|
+
</body>
|
|
609
|
+
</html>
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
612
|
+
function renderErrorPage(message) {
|
|
613
|
+
return devPage(
|
|
614
|
+
"takuhon dev \u2014 error",
|
|
615
|
+
`<h1>takuhon dev</h1>
|
|
616
|
+
<p>The profile could not be rendered:</p>
|
|
617
|
+
<pre>${escapeHtml(message)}</pre>
|
|
618
|
+
<p>Fix the file and reload.</p>`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
function renderNotFoundPage(route, routes) {
|
|
622
|
+
const links = routes.map((r) => `<li><a href="${escapeHtml(r)}">${escapeHtml(r)}</a></li>`).join("");
|
|
623
|
+
return devPage(
|
|
624
|
+
"takuhon dev \u2014 404",
|
|
625
|
+
`<h1>404</h1>
|
|
626
|
+
<p>No page for <code>${escapeHtml(route)}</code>.</p>
|
|
627
|
+
<p>Available pages:</p>
|
|
628
|
+
<ul>${links}</ul>`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/file-storage.ts
|
|
633
|
+
import { createHash } from "crypto";
|
|
634
|
+
import { readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
|
|
635
|
+
import { ConflictError, NotFoundError } from "@takuhon/core";
|
|
636
|
+
|
|
637
|
+
// src/backup.ts
|
|
638
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
639
|
+
import { basename, dirname, join } from "path";
|
|
640
|
+
var BACKUP_DIR_NAME = ".takuhon-backups";
|
|
641
|
+
var BackupError = class extends Error {
|
|
642
|
+
constructor(message, options) {
|
|
643
|
+
super(message, options);
|
|
644
|
+
this.name = "BackupError";
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
function compactTimestamp(date, withMillis = false) {
|
|
648
|
+
const iso = date.toISOString();
|
|
649
|
+
const trimmed = withMillis ? iso : iso.replace(/\.\d{3}Z$/, "Z");
|
|
650
|
+
return trimmed.replace(/[-:]/g, "");
|
|
651
|
+
}
|
|
652
|
+
function migrateBackupName(version, date, withMillis = false) {
|
|
653
|
+
return `takuhon-backup-v${version}-${compactTimestamp(date, withMillis)}.json`;
|
|
654
|
+
}
|
|
655
|
+
function preRestoreName(date, withMillis = false) {
|
|
656
|
+
return `pre-restore-${compactTimestamp(date, withMillis)}.json`;
|
|
657
|
+
}
|
|
658
|
+
function preImportName(date, withMillis = false) {
|
|
659
|
+
return `pre-import-${compactTimestamp(date, withMillis)}.json`;
|
|
660
|
+
}
|
|
661
|
+
function preAdminSaveName(date, withMillis = false) {
|
|
662
|
+
return `pre-admin-${compactTimestamp(date, withMillis)}.json`;
|
|
663
|
+
}
|
|
664
|
+
function backupDirFor(targetPath) {
|
|
665
|
+
return join(dirname(targetPath), BACKUP_DIR_NAME);
|
|
666
|
+
}
|
|
667
|
+
function createBackup(params) {
|
|
668
|
+
const dir = backupDirFor(params.targetPath);
|
|
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));
|
|
678
|
+
try {
|
|
679
|
+
writeFileSync(fallback, params.content, { flag: "wx" });
|
|
680
|
+
return fallback;
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (isAlreadyExists(error)) {
|
|
683
|
+
throw new BackupError(
|
|
684
|
+
`backup target already exists and could not be disambiguated: ${fallback}`,
|
|
685
|
+
{ cause: error }
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function isAlreadyExists(error) {
|
|
692
|
+
return typeof error === "object" && error !== null && error.code === "EEXIST";
|
|
693
|
+
}
|
|
694
|
+
function writeFileAtomic(target, content) {
|
|
695
|
+
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
|
|
696
|
+
try {
|
|
697
|
+
writeFileSync(tmp, content, "utf8");
|
|
698
|
+
renameSync(tmp, target);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
rmSync(tmp, { force: true });
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/file-storage.ts
|
|
706
|
+
function sha256Hex(content) {
|
|
707
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
708
|
+
}
|
|
709
|
+
function isNotFound(err) {
|
|
710
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
711
|
+
}
|
|
712
|
+
function readMaybe(path) {
|
|
713
|
+
try {
|
|
714
|
+
return readFileSync2(path, "utf8");
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (isNotFound(err)) return void 0;
|
|
717
|
+
throw err;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
var FileStorage = class {
|
|
721
|
+
constructor(path, deps = {}) {
|
|
722
|
+
this.path = path;
|
|
723
|
+
this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
724
|
+
}
|
|
725
|
+
path;
|
|
726
|
+
now;
|
|
727
|
+
// The filesystem work is synchronous, but the TakuhonStorage contract is
|
|
728
|
+
// async. Each method runs its body inside `Promise.resolve().then(...)` so a
|
|
729
|
+
// synchronous throw becomes a rejected promise (e.g. NotFoundError →
|
|
730
|
+
// ConflictError mapping in the admin API) rather than an exception the caller
|
|
731
|
+
// must catch outside `await`.
|
|
732
|
+
getProfile() {
|
|
733
|
+
return Promise.resolve().then(() => {
|
|
734
|
+
const raw = readMaybe(this.path);
|
|
735
|
+
if (raw === void 0) {
|
|
736
|
+
throw new NotFoundError("No profile is stored yet.");
|
|
737
|
+
}
|
|
738
|
+
let data;
|
|
739
|
+
try {
|
|
740
|
+
data = JSON.parse(raw);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
throw new Error("The profile file is not valid JSON.", { cause: err });
|
|
743
|
+
}
|
|
744
|
+
return { data, version: sha256Hex(raw) };
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
saveProfile(data, ifMatch) {
|
|
748
|
+
return Promise.resolve().then(() => {
|
|
749
|
+
const current = readMaybe(this.path);
|
|
750
|
+
if (ifMatch !== void 0) {
|
|
751
|
+
const currentVersion = current === void 0 ? void 0 : sha256Hex(current);
|
|
752
|
+
if (currentVersion !== ifMatch) {
|
|
753
|
+
throw new ConflictError(
|
|
754
|
+
`If-Match preconditioned on version "${ifMatch}" but current is "${currentVersion ?? "absent"}".`,
|
|
755
|
+
{ currentVersion }
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (current !== void 0) {
|
|
760
|
+
const stamp = this.now();
|
|
761
|
+
createBackup({
|
|
762
|
+
targetPath: this.path,
|
|
763
|
+
content: current,
|
|
764
|
+
name: (withMillis) => preAdminSaveName(stamp, withMillis)
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
const content = `${JSON.stringify(data, null, 2)}
|
|
768
|
+
`;
|
|
769
|
+
writeFileAtomic(this.path, content);
|
|
770
|
+
return { version: sha256Hex(content) };
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
deleteProfile() {
|
|
774
|
+
return Promise.resolve().then(() => {
|
|
775
|
+
const current = readMaybe(this.path);
|
|
776
|
+
if (current === void 0) return;
|
|
777
|
+
const stamp = this.now();
|
|
778
|
+
createBackup({
|
|
779
|
+
targetPath: this.path,
|
|
780
|
+
content: current,
|
|
781
|
+
name: (withMillis) => preAdminSaveName(stamp, withMillis)
|
|
782
|
+
});
|
|
783
|
+
rmSync2(this.path, { force: true });
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
// src/admin-command.ts
|
|
789
|
+
var DEFAULT_PATH2 = "takuhon.json";
|
|
790
|
+
var DEFAULT_PORT2 = 4322;
|
|
791
|
+
var USAGE2 = `Usage: takuhon admin [path] [--port <n>] [--base-url <url>]
|
|
792
|
+
|
|
793
|
+
Run a local admin server: the form editor at /admin, the admin API at
|
|
794
|
+
/api/admin/*, and a preview at /. With no path, edits ./takuhon.json. A fresh
|
|
795
|
+
admin token is printed each run; paste it into the sign-in form. The server
|
|
796
|
+
binds 127.0.0.1 only. Stop with Ctrl-C.
|
|
797
|
+
|
|
798
|
+
Options:
|
|
799
|
+
--port <n> Port to listen on (default: ${DEFAULT_PORT2}).
|
|
800
|
+
--base-url <url> Site origin for the preview's canonical / hreflang links.
|
|
801
|
+
|
|
802
|
+
Exit codes: 0 = served then stopped, 2 = bad arguments / port in use.
|
|
803
|
+
`;
|
|
804
|
+
var CONTENT_TYPES = {
|
|
805
|
+
".html": "text/html; charset=utf-8",
|
|
806
|
+
".js": "text/javascript; charset=utf-8",
|
|
807
|
+
".css": "text/css; charset=utf-8",
|
|
808
|
+
".json": "application/json; charset=utf-8",
|
|
809
|
+
".svg": "image/svg+xml",
|
|
810
|
+
".png": "image/png",
|
|
811
|
+
".ico": "image/x-icon",
|
|
812
|
+
".woff2": "font/woff2",
|
|
813
|
+
".map": "application/json; charset=utf-8"
|
|
814
|
+
};
|
|
815
|
+
function contentTypeFor(file) {
|
|
816
|
+
return CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
817
|
+
}
|
|
818
|
+
function isNotFound2(err) {
|
|
819
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
820
|
+
}
|
|
821
|
+
function localAdminHeaders() {
|
|
822
|
+
const headers = { ...adminAssetSecurityHeaders() };
|
|
823
|
+
delete headers["strict-transport-security"];
|
|
824
|
+
const csp = headers["content-security-policy"];
|
|
825
|
+
if (csp !== void 0) {
|
|
826
|
+
headers["content-security-policy"] = csp.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "" && directive !== "upgrade-insecure-requests").join("; ");
|
|
827
|
+
}
|
|
828
|
+
return headers;
|
|
829
|
+
}
|
|
830
|
+
var plain = (status, body) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
831
|
+
function serveAdminBundle(bundleDir, method, pathname) {
|
|
832
|
+
if (method !== "GET" && method !== "HEAD") return plain(405, "Method Not Allowed\n");
|
|
833
|
+
const rest = pathname.slice("/admin".length);
|
|
834
|
+
let rel;
|
|
835
|
+
try {
|
|
836
|
+
rel = decodeURIComponent(rest).replace(/^\/+/, "");
|
|
837
|
+
} catch {
|
|
838
|
+
rel = "";
|
|
839
|
+
}
|
|
840
|
+
if (rel === "") rel = "index.html";
|
|
841
|
+
const root = resolve(bundleDir);
|
|
842
|
+
let full = resolve(root, rel);
|
|
843
|
+
if (full !== root && !full.startsWith(root + sep)) return plain(403, "Forbidden\n");
|
|
844
|
+
let body;
|
|
845
|
+
try {
|
|
846
|
+
body = readFileSync3(full);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
if (!isNotFound2(err)) throw err;
|
|
849
|
+
if (extname(rel) !== "") return plain(404, "Not Found\n");
|
|
850
|
+
full = join2(root, "index.html");
|
|
851
|
+
try {
|
|
852
|
+
body = readFileSync3(full);
|
|
853
|
+
} catch {
|
|
854
|
+
return plain(404, "Not Found\n");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const headers = new Headers(localAdminHeaders());
|
|
858
|
+
headers.set("content-type", contentTypeFor(full));
|
|
859
|
+
return new Response(method === "HEAD" ? null : body, { status: 200, headers });
|
|
860
|
+
}
|
|
861
|
+
function createAdminApp(opts) {
|
|
862
|
+
const bundleDir = opts.bundleDir ?? resolveAdminBundleDir();
|
|
863
|
+
const storage = new FileStorage(opts.path);
|
|
864
|
+
const app = new Hono();
|
|
865
|
+
app.route(
|
|
866
|
+
"/api/admin",
|
|
867
|
+
createAdminApiApp({
|
|
868
|
+
storage,
|
|
869
|
+
getAdminToken: () => opts.token,
|
|
870
|
+
// Loopback, same-origin SPA: no Origin allowlist needed (empty = skip).
|
|
871
|
+
getAdminOrigins: () => [],
|
|
872
|
+
cachePurger: noopCachePurger,
|
|
873
|
+
auditLogger: noopAuditLogger
|
|
874
|
+
})
|
|
875
|
+
);
|
|
876
|
+
app.all("*", (c) => {
|
|
877
|
+
const { method, path } = c.req;
|
|
878
|
+
if (path === "/admin" || path.startsWith("/admin/")) {
|
|
879
|
+
return serveAdminBundle(bundleDir, method, path);
|
|
880
|
+
}
|
|
881
|
+
const state = loadSiteState(opts.path, opts.baseUrl);
|
|
882
|
+
const res = handleRequest(method, path, state);
|
|
883
|
+
return new Response(method === "HEAD" ? null : res.body, {
|
|
884
|
+
status: res.status,
|
|
885
|
+
headers: { "content-type": res.contentType }
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
return app;
|
|
889
|
+
}
|
|
890
|
+
function parseArgs2(args) {
|
|
891
|
+
let path;
|
|
892
|
+
let portRaw;
|
|
893
|
+
let baseUrl;
|
|
894
|
+
for (let i = 0; i < args.length; i++) {
|
|
895
|
+
const arg = args[i];
|
|
896
|
+
if (arg === "--port" || arg === "--base-url") {
|
|
897
|
+
const value = args[i + 1];
|
|
898
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
899
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
900
|
+
}
|
|
901
|
+
if (arg === "--port") portRaw = value;
|
|
902
|
+
else baseUrl = value;
|
|
903
|
+
i++;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (arg.startsWith("--port=")) {
|
|
907
|
+
portRaw = arg.slice("--port=".length);
|
|
908
|
+
if (portRaw === "") return { error: "takuhon: `--port` requires a value." };
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (arg.startsWith("--base-url=")) {
|
|
912
|
+
baseUrl = arg.slice("--base-url=".length);
|
|
913
|
+
if (baseUrl === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (arg.startsWith("-")) return { error: `takuhon: unknown option \`${arg}\` for \`admin\`.` };
|
|
917
|
+
if (path !== void 0) return { error: "takuhon: `admin` takes at most one path argument." };
|
|
918
|
+
path = arg;
|
|
919
|
+
}
|
|
920
|
+
let port = DEFAULT_PORT2;
|
|
921
|
+
if (portRaw !== void 0) {
|
|
922
|
+
if (!/^\d+$/.test(portRaw)) {
|
|
923
|
+
return {
|
|
924
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const n = Number(portRaw);
|
|
928
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
929
|
+
return {
|
|
930
|
+
error: `takuhon: \`--port\` must be an integer between 1 and 65535 (got \`${portRaw}\`).`
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
port = n;
|
|
934
|
+
}
|
|
935
|
+
if (baseUrl !== void 0 && !isHttpUrl2(baseUrl)) {
|
|
936
|
+
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
937
|
+
}
|
|
938
|
+
return { path: path ?? DEFAULT_PATH2, port, baseUrl: baseUrl?.replace(/\/+$/, "") };
|
|
939
|
+
}
|
|
940
|
+
function isHttpUrl2(value) {
|
|
941
|
+
try {
|
|
942
|
+
const url = new URL(value);
|
|
943
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async function runAdmin(args = [], deps = {}) {
|
|
949
|
+
const out = deps.stdout ?? ((text) => void process.stdout.write(text));
|
|
950
|
+
const err = deps.stderr ?? ((text) => void process.stderr.write(text));
|
|
951
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
952
|
+
out(USAGE2);
|
|
953
|
+
return 0;
|
|
954
|
+
}
|
|
955
|
+
const parsed = parseArgs2(args);
|
|
956
|
+
if ("error" in parsed) {
|
|
957
|
+
err(`${parsed.error}
|
|
958
|
+
Run \`takuhon admin --help\` for usage.
|
|
959
|
+
`);
|
|
960
|
+
return 2;
|
|
961
|
+
}
|
|
962
|
+
const token = deps.token ?? randomBytes(32).toString("base64url");
|
|
963
|
+
const app = createAdminApp({ path: parsed.path, token, baseUrl: parsed.baseUrl });
|
|
964
|
+
return await new Promise((resolvePromise) => {
|
|
685
965
|
let closing = false;
|
|
686
|
-
|
|
966
|
+
function shutdown() {
|
|
687
967
|
if (closing) return;
|
|
688
968
|
closing = true;
|
|
689
969
|
process.removeListener("SIGINT", shutdown);
|
|
690
970
|
process.removeListener("SIGTERM", shutdown);
|
|
691
|
-
server.close(() =>
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
971
|
+
server.close(() => resolvePromise(0));
|
|
972
|
+
}
|
|
973
|
+
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: parsed.port }, () => {
|
|
974
|
+
const origin = `http://127.0.0.1:${parsed.port}`;
|
|
975
|
+
out(`takuhon admin: editing ${parsed.path} at ${origin}/ (Ctrl-C to stop)
|
|
976
|
+
`);
|
|
977
|
+
out(` Admin UI: ${origin}/admin
|
|
978
|
+
`);
|
|
979
|
+
out(` Admin token: ${token}
|
|
980
|
+
`);
|
|
981
|
+
out(` Paste the token into the sign-in form; it is valid for this run only.
|
|
982
|
+
`);
|
|
983
|
+
process.once("SIGINT", shutdown);
|
|
984
|
+
process.once("SIGTERM", shutdown);
|
|
985
|
+
});
|
|
986
|
+
server.on("error", (error) => {
|
|
695
987
|
if (error.code === "EADDRINUSE") {
|
|
696
988
|
err(`takuhon: port ${parsed.port} is already in use; pass --port <n> to choose another.
|
|
697
989
|
`);
|
|
@@ -699,45 +991,72 @@ Run \`takuhon dev --help\` for usage.
|
|
|
699
991
|
err(`takuhon: ${error.message}
|
|
700
992
|
`);
|
|
701
993
|
}
|
|
702
|
-
|
|
703
|
-
});
|
|
704
|
-
server.listen(parsed.port, "127.0.0.1", () => {
|
|
705
|
-
out(
|
|
706
|
-
`takuhon dev: serving ${parsed.path} at http://localhost:${parsed.port}/ (Ctrl-C to stop)
|
|
707
|
-
`
|
|
708
|
-
);
|
|
709
|
-
const state = loadSiteState(parsed.path, parsed.baseUrl);
|
|
710
|
-
if (!state.ok) {
|
|
711
|
-
err(
|
|
712
|
-
`takuhon dev: ${parsed.path} is not a valid profile yet; the preview will show the error until it is fixed.
|
|
713
|
-
`
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
process.once("SIGINT", shutdown);
|
|
717
|
-
process.once("SIGTERM", shutdown);
|
|
994
|
+
resolvePromise(2);
|
|
718
995
|
});
|
|
719
996
|
});
|
|
720
997
|
}
|
|
721
|
-
|
|
998
|
+
|
|
999
|
+
// src/build-command.ts
|
|
1000
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
1001
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1002
|
+
import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
|
|
1003
|
+
var DEFAULT_PATH3 = "takuhon.json";
|
|
1004
|
+
var DEFAULT_OUTPUT = "dist";
|
|
1005
|
+
var USAGE3 = `Usage: takuhon build [path] [--output <dir>] [--base-url <url>]
|
|
1006
|
+
|
|
1007
|
+
Render a takuhon.json into a static site (one HTML page per locale, with
|
|
1008
|
+
build-time Schema.org JSON-LD). With no path, builds ./takuhon.json.
|
|
1009
|
+
|
|
1010
|
+
Options:
|
|
1011
|
+
--output <dir> Output directory (default: ${DEFAULT_OUTPUT}). The default
|
|
1012
|
+
locale is written to <dir>/index.html and each other locale
|
|
1013
|
+
to <dir>/<locale>/index.html.
|
|
1014
|
+
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
1015
|
+
canonical and hreflang links; without it those are omitted.
|
|
1016
|
+
|
|
1017
|
+
The public privacy filter is applied (meta.privacy is honoured). Asset URLs are
|
|
1018
|
+
referenced as-is and are not copied. The output directory is written into, not
|
|
1019
|
+
cleaned \u2014 use a dedicated/empty directory so stale pages do not linger.
|
|
1020
|
+
|
|
1021
|
+
Exit codes: 0 = built, 1 = source is not a valid profile,
|
|
1022
|
+
2 = bad arguments / file missing / unreadable / not JSON / write failed.
|
|
1023
|
+
`;
|
|
1024
|
+
function runBuild(args = []) {
|
|
1025
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1026
|
+
return { code: 0, stdout: USAGE3, stderr: "" };
|
|
1027
|
+
}
|
|
1028
|
+
const parsed = parseArgs3(args);
|
|
1029
|
+
if ("error" in parsed) {
|
|
1030
|
+
return {
|
|
1031
|
+
code: 2,
|
|
1032
|
+
stdout: "",
|
|
1033
|
+
stderr: `${parsed.error}
|
|
1034
|
+
Run \`takuhon build --help\` for usage.
|
|
1035
|
+
`
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
return buildSite(parsed);
|
|
1039
|
+
}
|
|
1040
|
+
function parseArgs3(args) {
|
|
722
1041
|
let path;
|
|
723
|
-
let
|
|
1042
|
+
let output;
|
|
724
1043
|
let baseUrl;
|
|
725
1044
|
for (let i = 0; i < args.length; i++) {
|
|
726
1045
|
const arg = args[i];
|
|
727
|
-
if (arg === "--
|
|
1046
|
+
if (arg === "--output" || arg === "--base-url") {
|
|
728
1047
|
const value = args[i + 1];
|
|
729
1048
|
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
730
1049
|
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
731
1050
|
}
|
|
732
|
-
if (arg === "--
|
|
1051
|
+
if (arg === "--output") output = value;
|
|
733
1052
|
else baseUrl = value;
|
|
734
1053
|
i++;
|
|
735
1054
|
continue;
|
|
736
1055
|
}
|
|
737
|
-
if (arg.startsWith("--
|
|
738
|
-
const value = arg.slice("--
|
|
739
|
-
if (value === "") return { error: "takuhon: `--
|
|
740
|
-
|
|
1056
|
+
if (arg.startsWith("--output=")) {
|
|
1057
|
+
const value = arg.slice("--output=".length);
|
|
1058
|
+
if (value === "") return { error: "takuhon: `--output` requires a value." };
|
|
1059
|
+
output = value;
|
|
741
1060
|
continue;
|
|
742
1061
|
}
|
|
743
1062
|
if (arg.startsWith("--base-url=")) {
|
|
@@ -747,39 +1066,24 @@ function parseArgs2(args) {
|
|
|
747
1066
|
continue;
|
|
748
1067
|
}
|
|
749
1068
|
if (arg.startsWith("-")) {
|
|
750
|
-
return { error: `takuhon: unknown option \`${arg}\` for \`
|
|
1069
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
|
|
751
1070
|
}
|
|
752
1071
|
if (path !== void 0) {
|
|
753
|
-
return { error: "takuhon: `
|
|
1072
|
+
return { error: "takuhon: `build` takes at most one path argument." };
|
|
754
1073
|
}
|
|
755
1074
|
path = arg;
|
|
756
1075
|
}
|
|
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)) {
|
|
1076
|
+
if (baseUrl !== void 0 && !isHttpUrl3(baseUrl)) {
|
|
768
1077
|
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
769
1078
|
}
|
|
770
1079
|
return {
|
|
771
|
-
path: path ??
|
|
772
|
-
|
|
1080
|
+
path: path ?? DEFAULT_PATH3,
|
|
1081
|
+
output: output ?? DEFAULT_OUTPUT,
|
|
773
1082
|
// Drop any trailing slash so URL joins are predictable.
|
|
774
1083
|
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
775
1084
|
};
|
|
776
1085
|
}
|
|
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) {
|
|
1086
|
+
function isHttpUrl3(value) {
|
|
783
1087
|
try {
|
|
784
1088
|
const url = new URL(value);
|
|
785
1089
|
return url.protocol === "http:" || url.protocol === "https:";
|
|
@@ -787,56 +1091,68 @@ function isHttpUrl2(value) {
|
|
|
787
1091
|
return false;
|
|
788
1092
|
}
|
|
789
1093
|
}
|
|
790
|
-
function
|
|
1094
|
+
function buildSite(parsed) {
|
|
1095
|
+
const { path, output, baseUrl } = parsed;
|
|
1096
|
+
let raw;
|
|
791
1097
|
try {
|
|
792
|
-
|
|
1098
|
+
raw = readFileSync4(path, "utf8");
|
|
793
1099
|
} catch {
|
|
794
|
-
return
|
|
1100
|
+
return {
|
|
1101
|
+
code: 2,
|
|
1102
|
+
stdout: "",
|
|
1103
|
+
stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
1104
|
+
`
|
|
1105
|
+
};
|
|
795
1106
|
}
|
|
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
|
-
"takuhon
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1107
|
+
let data;
|
|
1108
|
+
try {
|
|
1109
|
+
data = JSON.parse(raw);
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1112
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
1113
|
+
` };
|
|
1114
|
+
}
|
|
1115
|
+
const result = validate2(data);
|
|
1116
|
+
if (!result.ok) {
|
|
1117
|
+
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1118
|
+
return {
|
|
1119
|
+
code: 1,
|
|
1120
|
+
stdout: "",
|
|
1121
|
+
stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to build:
|
|
1122
|
+
${lines.join("\n")}
|
|
1123
|
+
`
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
|
|
1127
|
+
const written = [];
|
|
1128
|
+
try {
|
|
1129
|
+
for (const page of generateSite(filtered, { baseUrl })) {
|
|
1130
|
+
const outFile = join3(output, page.file);
|
|
1131
|
+
mkdirSync2(dirname2(outFile), { recursive: true });
|
|
1132
|
+
writeFileAtomic(outFile, page.html);
|
|
1133
|
+
written.push(outFile);
|
|
1134
|
+
}
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1137
|
+
return { code: 2, stdout: "", stderr: `takuhon: failed to write the site: ${detail}
|
|
1138
|
+
` };
|
|
1139
|
+
}
|
|
1140
|
+
const summary = written.map((w) => ` ${w}`).join("\n");
|
|
1141
|
+
return {
|
|
1142
|
+
code: 0,
|
|
1143
|
+
stdout: `built ${written.length} page${written.length === 1 ? "" : "s"} from ${path}:
|
|
1144
|
+
${summary}
|
|
1145
|
+
`,
|
|
1146
|
+
stderr: ""
|
|
1147
|
+
};
|
|
832
1148
|
}
|
|
833
1149
|
|
|
834
1150
|
// src/export-command.ts
|
|
835
|
-
import { readFileSync as
|
|
836
|
-
import { resolve } from "path";
|
|
1151
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1152
|
+
import { resolve as resolve2 } from "path";
|
|
837
1153
|
import { exportTakuhon, validate as validate3 } from "@takuhon/core";
|
|
838
|
-
var
|
|
839
|
-
var
|
|
1154
|
+
var DEFAULT_PATH4 = "takuhon.json";
|
|
1155
|
+
var USAGE4 = `Usage: takuhon export [path] [--output <file>]
|
|
840
1156
|
|
|
841
1157
|
Serialise a takuhon.json into its transport form and print it to stdout, or
|
|
842
1158
|
write it to a file with --output. With no path, exports ./takuhon.json in the
|
|
@@ -850,9 +1166,9 @@ Exit codes: 0 = exported, 1 = source is not a valid profile,
|
|
|
850
1166
|
`;
|
|
851
1167
|
function runExport(args = []) {
|
|
852
1168
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
853
|
-
return { code: 0, stdout:
|
|
1169
|
+
return { code: 0, stdout: USAGE4, stderr: "" };
|
|
854
1170
|
}
|
|
855
|
-
const parsed =
|
|
1171
|
+
const parsed = parseArgs4(args);
|
|
856
1172
|
if ("error" in parsed) {
|
|
857
1173
|
return {
|
|
858
1174
|
code: 2,
|
|
@@ -864,7 +1180,7 @@ Run \`takuhon export --help\` for usage.
|
|
|
864
1180
|
}
|
|
865
1181
|
return exportFile(parsed);
|
|
866
1182
|
}
|
|
867
|
-
function
|
|
1183
|
+
function parseArgs4(args) {
|
|
868
1184
|
let path;
|
|
869
1185
|
let output;
|
|
870
1186
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -899,11 +1215,11 @@ function parseArgs3(args) {
|
|
|
899
1215
|
}
|
|
900
1216
|
path = arg;
|
|
901
1217
|
}
|
|
902
|
-
return { path: path ??
|
|
1218
|
+
return { path: path ?? DEFAULT_PATH4, output };
|
|
903
1219
|
}
|
|
904
1220
|
function exportFile(parsed) {
|
|
905
1221
|
const { path, output } = parsed;
|
|
906
|
-
if (output !== void 0 &&
|
|
1222
|
+
if (output !== void 0 && resolve2(output) === resolve2(path)) {
|
|
907
1223
|
return {
|
|
908
1224
|
code: 2,
|
|
909
1225
|
stdout: "",
|
|
@@ -914,7 +1230,7 @@ Omit --output to print to stdout, or choose a different file.
|
|
|
914
1230
|
}
|
|
915
1231
|
let raw;
|
|
916
1232
|
try {
|
|
917
|
-
raw =
|
|
1233
|
+
raw = readFileSync5(path, "utf8");
|
|
918
1234
|
} catch {
|
|
919
1235
|
return {
|
|
920
1236
|
code: 2,
|
|
@@ -959,7 +1275,7 @@ ${lines.join("\n")}
|
|
|
959
1275
|
}
|
|
960
1276
|
|
|
961
1277
|
// src/import-command.ts
|
|
962
|
-
import { readFileSync as
|
|
1278
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
963
1279
|
import {
|
|
964
1280
|
ImportError,
|
|
965
1281
|
MigrationError,
|
|
@@ -967,8 +1283,8 @@ import {
|
|
|
967
1283
|
importTakuhon,
|
|
968
1284
|
migrateTakuhon
|
|
969
1285
|
} from "@takuhon/core";
|
|
970
|
-
var
|
|
971
|
-
var
|
|
1286
|
+
var DEFAULT_PATH5 = "takuhon.json";
|
|
1287
|
+
var USAGE5 = `Usage: takuhon import <file> [path]
|
|
972
1288
|
|
|
973
1289
|
Load a previously exported profile from <file> into a local takuhon.json,
|
|
974
1290
|
migrating it to the current schema version if needed. With no path, writes
|
|
@@ -982,9 +1298,9 @@ version), 2 = bad arguments / file missing / unreadable / not JSON / write faile
|
|
|
982
1298
|
`;
|
|
983
1299
|
function runImport(args = [], deps = {}) {
|
|
984
1300
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
985
|
-
return { code: 0, stdout:
|
|
1301
|
+
return { code: 0, stdout: USAGE5, stderr: "" };
|
|
986
1302
|
}
|
|
987
|
-
const parsed =
|
|
1303
|
+
const parsed = parseArgs5(args);
|
|
988
1304
|
if ("error" in parsed) {
|
|
989
1305
|
return {
|
|
990
1306
|
code: 2,
|
|
@@ -997,7 +1313,7 @@ Run \`takuhon import --help\` for usage.
|
|
|
997
1313
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
998
1314
|
return importFile(parsed, now);
|
|
999
1315
|
}
|
|
1000
|
-
function
|
|
1316
|
+
function parseArgs5(args) {
|
|
1001
1317
|
const positionals = [];
|
|
1002
1318
|
for (const arg of args) {
|
|
1003
1319
|
if (arg.startsWith("-")) {
|
|
@@ -1011,13 +1327,13 @@ function parseArgs4(args) {
|
|
|
1011
1327
|
if (positionals.length > 2) {
|
|
1012
1328
|
return { error: "takuhon: `import` takes at most an input <file> and a target path." };
|
|
1013
1329
|
}
|
|
1014
|
-
return { file: positionals[0], path: positionals[1] ??
|
|
1330
|
+
return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH5 };
|
|
1015
1331
|
}
|
|
1016
1332
|
function importFile(parsed, now) {
|
|
1017
1333
|
const { file, path } = parsed;
|
|
1018
1334
|
let raw;
|
|
1019
1335
|
try {
|
|
1020
|
-
raw =
|
|
1336
|
+
raw = readFileSync6(file, "utf8");
|
|
1021
1337
|
} catch {
|
|
1022
1338
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
|
|
1023
1339
|
` };
|
|
@@ -1082,9 +1398,9 @@ ${lines2.join("\n")}` : ".";
|
|
|
1082
1398
|
}
|
|
1083
1399
|
let currentRaw;
|
|
1084
1400
|
try {
|
|
1085
|
-
currentRaw =
|
|
1401
|
+
currentRaw = readFileSync6(path, "utf8");
|
|
1086
1402
|
} catch (error) {
|
|
1087
|
-
if (!
|
|
1403
|
+
if (!isNotFound3(error)) {
|
|
1088
1404
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1089
1405
|
` };
|
|
1090
1406
|
}
|
|
@@ -1123,13 +1439,13 @@ ${lines2.join("\n")}` : ".";
|
|
|
1123
1439
|
return { code: 0, stdout: `${lines.join("\n")}
|
|
1124
1440
|
`, stderr: "" };
|
|
1125
1441
|
}
|
|
1126
|
-
function
|
|
1442
|
+
function isNotFound3(error) {
|
|
1127
1443
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1128
1444
|
}
|
|
1129
1445
|
|
|
1130
1446
|
// src/migrate-command.ts
|
|
1131
|
-
import { readFileSync as
|
|
1132
|
-
import { resolve as
|
|
1447
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
1448
|
+
import { resolve as resolve3 } from "path";
|
|
1133
1449
|
import {
|
|
1134
1450
|
MigrationError as MigrationError2,
|
|
1135
1451
|
SCHEMA_VERSION as SCHEMA_VERSION2,
|
|
@@ -1137,8 +1453,8 @@ import {
|
|
|
1137
1453
|
migrateTakuhon as migrateTakuhon2,
|
|
1138
1454
|
validate as validate4
|
|
1139
1455
|
} from "@takuhon/core";
|
|
1140
|
-
var
|
|
1141
|
-
var
|
|
1456
|
+
var DEFAULT_PATH6 = "takuhon.json";
|
|
1457
|
+
var USAGE6 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
|
|
1142
1458
|
|
|
1143
1459
|
Forward-migrate a takuhon.json to a newer schema version. The source version
|
|
1144
1460
|
is read from the file's own schemaVersion. With no path, migrates
|
|
@@ -1160,9 +1476,9 @@ Exit codes: 0 = migrated / already current / dry-run, 1 = cannot migrate,
|
|
|
1160
1476
|
`;
|
|
1161
1477
|
function runMigrate(args = [], deps = {}) {
|
|
1162
1478
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1163
|
-
return { code: 0, stdout:
|
|
1479
|
+
return { code: 0, stdout: USAGE6, stderr: "" };
|
|
1164
1480
|
}
|
|
1165
|
-
const parsed =
|
|
1481
|
+
const parsed = parseArgs6(args);
|
|
1166
1482
|
if ("error" in parsed) {
|
|
1167
1483
|
return {
|
|
1168
1484
|
code: 2,
|
|
@@ -1175,7 +1491,7 @@ Run \`takuhon migrate --help\` for usage.
|
|
|
1175
1491
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1176
1492
|
return migrateFile(parsed, now);
|
|
1177
1493
|
}
|
|
1178
|
-
function
|
|
1494
|
+
function parseArgs6(args) {
|
|
1179
1495
|
let path;
|
|
1180
1496
|
let to;
|
|
1181
1497
|
let out;
|
|
@@ -1218,13 +1534,13 @@ function parseArgs5(args) {
|
|
|
1218
1534
|
error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
|
|
1219
1535
|
};
|
|
1220
1536
|
}
|
|
1221
|
-
return { path: path ??
|
|
1537
|
+
return { path: path ?? DEFAULT_PATH6, to: target, out, dryRun };
|
|
1222
1538
|
}
|
|
1223
1539
|
function migrateFile(parsed, now) {
|
|
1224
1540
|
const { path, to: target, out, dryRun } = parsed;
|
|
1225
1541
|
let raw;
|
|
1226
1542
|
try {
|
|
1227
|
-
raw =
|
|
1543
|
+
raw = readFileSync7(path, "utf8");
|
|
1228
1544
|
} catch {
|
|
1229
1545
|
return {
|
|
1230
1546
|
code: 2,
|
|
@@ -1259,7 +1575,7 @@ function migrateFile(parsed, now) {
|
|
|
1259
1575
|
};
|
|
1260
1576
|
}
|
|
1261
1577
|
const writeTarget = out ?? path;
|
|
1262
|
-
const inPlace =
|
|
1578
|
+
const inPlace = resolve3(writeTarget) === resolve3(path);
|
|
1263
1579
|
let migrated;
|
|
1264
1580
|
try {
|
|
1265
1581
|
migrated = migrateTakuhon2(data, target);
|
|
@@ -1338,10 +1654,10 @@ ${lines2.join("\n")}
|
|
|
1338
1654
|
}
|
|
1339
1655
|
|
|
1340
1656
|
// src/restore-command.ts
|
|
1341
|
-
import { readFileSync as
|
|
1657
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1342
1658
|
import { validate as validate5 } from "@takuhon/core";
|
|
1343
|
-
var
|
|
1344
|
-
var
|
|
1659
|
+
var DEFAULT_PATH7 = "takuhon.json";
|
|
1660
|
+
var USAGE7 = `Usage: takuhon restore --from <backup> [path] [--yes]
|
|
1345
1661
|
|
|
1346
1662
|
Overwrite a profile with a previously saved backup. With no path, restores
|
|
1347
1663
|
./takuhon.json in the current working directory.
|
|
@@ -1358,9 +1674,9 @@ Exit codes: 0 = restored / aborted, 1 = backup failed validation,
|
|
|
1358
1674
|
`;
|
|
1359
1675
|
async function runRestore(args = [], deps = {}) {
|
|
1360
1676
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1361
|
-
return { code: 0, stdout:
|
|
1677
|
+
return { code: 0, stdout: USAGE7, stderr: "" };
|
|
1362
1678
|
}
|
|
1363
|
-
const parsed =
|
|
1679
|
+
const parsed = parseArgs7(args);
|
|
1364
1680
|
if ("error" in parsed) {
|
|
1365
1681
|
return {
|
|
1366
1682
|
code: 2,
|
|
@@ -1373,7 +1689,7 @@ Run \`takuhon restore --help\` for usage.
|
|
|
1373
1689
|
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1374
1690
|
return restoreFile(parsed, now, deps.confirm);
|
|
1375
1691
|
}
|
|
1376
|
-
function
|
|
1692
|
+
function parseArgs7(args) {
|
|
1377
1693
|
let from;
|
|
1378
1694
|
let path;
|
|
1379
1695
|
let yes = false;
|
|
@@ -1407,13 +1723,13 @@ function parseArgs6(args) {
|
|
|
1407
1723
|
if (from === void 0 || from.length === 0) {
|
|
1408
1724
|
return { error: "takuhon: `restore` requires `--from <backup>`." };
|
|
1409
1725
|
}
|
|
1410
|
-
return { from, path: path ??
|
|
1726
|
+
return { from, path: path ?? DEFAULT_PATH7, yes };
|
|
1411
1727
|
}
|
|
1412
1728
|
async function restoreFile(parsed, now, confirm) {
|
|
1413
1729
|
const { from, path, yes } = parsed;
|
|
1414
1730
|
let backupRaw;
|
|
1415
1731
|
try {
|
|
1416
|
-
backupRaw =
|
|
1732
|
+
backupRaw = readFileSync8(from, "utf8");
|
|
1417
1733
|
} catch {
|
|
1418
1734
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
|
|
1419
1735
|
` };
|
|
@@ -1443,9 +1759,9 @@ ${lines2.join("\n")}
|
|
|
1443
1759
|
}
|
|
1444
1760
|
let currentRaw;
|
|
1445
1761
|
try {
|
|
1446
|
-
currentRaw =
|
|
1762
|
+
currentRaw = readFileSync8(path, "utf8");
|
|
1447
1763
|
} catch (error) {
|
|
1448
|
-
if (!
|
|
1764
|
+
if (!isNotFound4(error)) {
|
|
1449
1765
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1450
1766
|
` };
|
|
1451
1767
|
}
|
|
@@ -1508,17 +1824,17 @@ function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
|
|
|
1508
1824
|
${preNote}
|
|
1509
1825
|
Continue? [y/N]`;
|
|
1510
1826
|
}
|
|
1511
|
-
function
|
|
1827
|
+
function isNotFound4(error) {
|
|
1512
1828
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1513
1829
|
}
|
|
1514
1830
|
|
|
1515
1831
|
// src/sync-command.ts
|
|
1516
|
-
import { readFileSync as
|
|
1832
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1517
1833
|
import { validate as validate6 } from "@takuhon/core";
|
|
1518
|
-
var
|
|
1834
|
+
var DEFAULT_PATH8 = "takuhon.json";
|
|
1519
1835
|
var ADMIN_PROFILE_PATH = "/api/admin/profile";
|
|
1520
1836
|
var TOKEN_ENV = "TAKUHON_ADMIN_TOKEN";
|
|
1521
|
-
var
|
|
1837
|
+
var USAGE8 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
|
|
1522
1838
|
|
|
1523
1839
|
Push a local takuhon.json to a deployed takuhon instance by calling its admin
|
|
1524
1840
|
write endpoint (PUT <base-url>/api/admin/profile). With no path, syncs
|
|
@@ -1544,9 +1860,9 @@ unset / auth failure / network error / other non-success response.
|
|
|
1544
1860
|
`;
|
|
1545
1861
|
async function runSync(args = [], deps = {}) {
|
|
1546
1862
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1547
|
-
return { code: 0, stdout:
|
|
1863
|
+
return { code: 0, stdout: USAGE8, stderr: "" };
|
|
1548
1864
|
}
|
|
1549
|
-
const parsed =
|
|
1865
|
+
const parsed = parseArgs8(args);
|
|
1550
1866
|
if ("error" in parsed) {
|
|
1551
1867
|
return {
|
|
1552
1868
|
code: 2,
|
|
@@ -1558,7 +1874,7 @@ Run \`takuhon sync --help\` for usage.
|
|
|
1558
1874
|
}
|
|
1559
1875
|
return syncProfile(parsed, deps);
|
|
1560
1876
|
}
|
|
1561
|
-
function
|
|
1877
|
+
function parseArgs8(args) {
|
|
1562
1878
|
let path;
|
|
1563
1879
|
let url;
|
|
1564
1880
|
let ifMatch;
|
|
@@ -1604,7 +1920,7 @@ function parseArgs7(args) {
|
|
|
1604
1920
|
}
|
|
1605
1921
|
const base = parseOrigin(url);
|
|
1606
1922
|
if ("error" in base) return base;
|
|
1607
|
-
return { path: path ??
|
|
1923
|
+
return { path: path ?? DEFAULT_PATH8, url: base.origin, ifMatch, dryRun };
|
|
1608
1924
|
}
|
|
1609
1925
|
function parseOrigin(value) {
|
|
1610
1926
|
let parsed;
|
|
@@ -1637,7 +1953,7 @@ async function syncProfile(parsed, deps) {
|
|
|
1637
1953
|
const { path, url, ifMatch, dryRun } = parsed;
|
|
1638
1954
|
let raw;
|
|
1639
1955
|
try {
|
|
1640
|
-
raw =
|
|
1956
|
+
raw = readFileSync9(path, "utf8");
|
|
1641
1957
|
} catch {
|
|
1642
1958
|
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${path}'.
|
|
1643
1959
|
` };
|
|
@@ -1774,10 +2090,10 @@ async function readProblem(res) {
|
|
|
1774
2090
|
}
|
|
1775
2091
|
|
|
1776
2092
|
// src/validate-command.ts
|
|
1777
|
-
import { readFileSync as
|
|
2093
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1778
2094
|
import { validate as validate7 } from "@takuhon/core";
|
|
1779
|
-
var
|
|
1780
|
-
var
|
|
2095
|
+
var DEFAULT_PATH9 = "takuhon.json";
|
|
2096
|
+
var USAGE9 = `Usage: takuhon validate [path]
|
|
1781
2097
|
|
|
1782
2098
|
Validate a takuhon.json against the takuhon schema. With no path, validates
|
|
1783
2099
|
./takuhon.json in the current working directory.
|
|
@@ -1786,7 +2102,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
|
|
|
1786
2102
|
`;
|
|
1787
2103
|
function runValidate(args = []) {
|
|
1788
2104
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
1789
|
-
return { code: 0, stdout:
|
|
2105
|
+
return { code: 0, stdout: USAGE9, stderr: "" };
|
|
1790
2106
|
}
|
|
1791
2107
|
if (args.length > 1) {
|
|
1792
2108
|
return {
|
|
@@ -1798,10 +2114,10 @@ function runValidate(args = []) {
|
|
|
1798
2114
|
return validateFile(args[0]);
|
|
1799
2115
|
}
|
|
1800
2116
|
function validateFile(pathArg) {
|
|
1801
|
-
const target = pathArg ??
|
|
2117
|
+
const target = pathArg ?? DEFAULT_PATH9;
|
|
1802
2118
|
let raw;
|
|
1803
2119
|
try {
|
|
1804
|
-
raw =
|
|
2120
|
+
raw = readFileSync10(target, "utf8");
|
|
1805
2121
|
} catch {
|
|
1806
2122
|
return {
|
|
1807
2123
|
code: 2,
|
|
@@ -1843,7 +2159,7 @@ ${lines.join("\n")}
|
|
|
1843
2159
|
}
|
|
1844
2160
|
|
|
1845
2161
|
// src/index.ts
|
|
1846
|
-
var pkg = JSON.parse(
|
|
2162
|
+
var pkg = JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
|
|
1847
2163
|
var VERSION = pkg.version;
|
|
1848
2164
|
var HELP = `takuhon ${VERSION}
|
|
1849
2165
|
|
|
@@ -1869,6 +2185,10 @@ Commands:
|
|
|
1869
2185
|
takuhon dev [path] [--port <n>] Serve a takuhon.json as a local static preview,
|
|
1870
2186
|
re-rendered on each request (default port: 4321).
|
|
1871
2187
|
--base-url <url> adds canonical/hreflang links.
|
|
2188
|
+
takuhon admin [path] [--port <n>] Run a local admin server: edit the profile through
|
|
2189
|
+
the form UI at /admin (writes takuhon.json, backs up
|
|
2190
|
+
first) with a preview at / (default port: 4322). Binds
|
|
2191
|
+
127.0.0.1; prints a per-run token to paste into the form.
|
|
1872
2192
|
takuhon sync [path] --url <url> Push a takuhon.json to a deployment's admin API
|
|
1873
2193
|
(PUT <url>/api/admin/profile). Reads the admin token
|
|
1874
2194
|
from TAKUHON_ADMIN_TOKEN. --if-match <etag> opts into
|
|
@@ -1904,6 +2224,9 @@ async function main(argv) {
|
|
|
1904
2224
|
if (first === "dev") {
|
|
1905
2225
|
return runDev(argv.slice(1));
|
|
1906
2226
|
}
|
|
2227
|
+
if (first === "admin") {
|
|
2228
|
+
return runAdmin(argv.slice(1));
|
|
2229
|
+
}
|
|
1907
2230
|
if (first === "import") {
|
|
1908
2231
|
return emit(runImport(argv.slice(1)));
|
|
1909
2232
|
}
|