@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/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 readFileSync9, realpathSync } from "fs";
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/build-command.ts
10
- import { mkdirSync as mkdirSync2, readFileSync } from "fs";
11
- import { dirname as dirname2, join as join2 } from "path";
12
- import { applyPublicPrivacyFilter, normalize, validate } from "@takuhon/core";
13
-
14
- // src/backup.ts
15
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
16
- import { basename, dirname, join } from "path";
17
- var BACKUP_DIR_NAME = ".takuhon-backups";
18
- var BackupError = class extends Error {
19
- constructor(message, options) {
20
- super(message, options);
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/site.ts
80
- import { resolveLocale } from "@takuhon/core";
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
- 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";
363
+ var DEFAULT_PATH = "takuhon.json";
566
364
  var DEFAULT_PORT = 4321;
567
- var USAGE2 = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
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 = readFileSync2(path, "utf8");
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 = validate2(data);
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 = applyPublicPrivacyFilter2(normalize2(result.data));
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(USAGE2);
462
+ out(USAGE);
665
463
  return 0;
666
464
  }
667
- const parsed = parseArgs2(args);
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
- readFileSync2(parsed.path, "utf8");
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((resolve3) => {
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
- const shutdown = () => {
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(() => resolve3(0));
692
- server.closeAllConnections();
693
- };
694
- server.once("error", (error) => {
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
- resolve3(2);
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
- function parseArgs2(args) {
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 portRaw;
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 === "--port" || arg === "--base-url") {
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 === "--port") portRaw = value;
1051
+ if (arg === "--output") output = value;
733
1052
  else baseUrl = value;
734
1053
  i++;
735
1054
  continue;
736
1055
  }
737
- if (arg.startsWith("--port=")) {
738
- const value = arg.slice("--port=".length);
739
- if (value === "") return { error: "takuhon: `--port` requires a value." };
740
- portRaw = value;
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 \`dev\`.` };
1069
+ return { error: `takuhon: unknown option \`${arg}\` for \`build\`.` };
751
1070
  }
752
1071
  if (path !== void 0) {
753
- return { error: "takuhon: `dev` takes at most one path argument." };
1072
+ return { error: "takuhon: `build` takes at most one path argument." };
754
1073
  }
755
1074
  path = arg;
756
1075
  }
757
- let port = DEFAULT_PORT;
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 ?? DEFAULT_PATH2,
772
- port,
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 parsePort(value) {
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 pathnameOf(url) {
1094
+ function buildSite(parsed) {
1095
+ const { path, output, baseUrl } = parsed;
1096
+ let raw;
791
1097
  try {
792
- return new URL(url, "http://localhost").pathname;
1098
+ raw = readFileSync4(path, "utf8");
793
1099
  } catch {
794
- return url;
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
- function devPage(title, body) {
798
- return `<!DOCTYPE html>
799
- <html lang="en">
800
- <head>
801
- <meta charset="utf-8">
802
- <meta name="viewport" content="width=device-width, initial-scale=1">
803
- <title>${escapeHtml(title)}</title>
804
- <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>
805
- </head>
806
- <body>
807
- <main>
808
- ${body}
809
- </main>
810
- </body>
811
- </html>
812
- `;
813
- }
814
- function renderErrorPage(message) {
815
- return devPage(
816
- "takuhon dev \u2014 error",
817
- `<h1>takuhon dev</h1>
818
- <p>The profile could not be rendered:</p>
819
- <pre>${escapeHtml(message)}</pre>
820
- <p>Fix the file and reload.</p>`
821
- );
822
- }
823
- function renderNotFoundPage(route, routes) {
824
- const links = routes.map((r) => `<li><a href="${escapeHtml(r)}">${escapeHtml(r)}</a></li>`).join("");
825
- return devPage(
826
- "takuhon dev \u2014 404",
827
- `<h1>404</h1>
828
- <p>No page for <code>${escapeHtml(route)}</code>.</p>
829
- <p>Available pages:</p>
830
- <ul>${links}</ul>`
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 readFileSync3 } from "fs";
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 DEFAULT_PATH3 = "takuhon.json";
839
- var USAGE3 = `Usage: takuhon export [path] [--output <file>]
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: USAGE3, stderr: "" };
1169
+ return { code: 0, stdout: USAGE4, stderr: "" };
854
1170
  }
855
- const parsed = parseArgs3(args);
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 parseArgs3(args) {
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 ?? DEFAULT_PATH3, output };
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 && resolve(output) === resolve(path)) {
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 = readFileSync3(path, "utf8");
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 readFileSync4 } from "fs";
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 DEFAULT_PATH4 = "takuhon.json";
971
- var USAGE4 = `Usage: takuhon import <file> [path]
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: USAGE4, stderr: "" };
1301
+ return { code: 0, stdout: USAGE5, stderr: "" };
986
1302
  }
987
- const parsed = parseArgs4(args);
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 parseArgs4(args) {
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] ?? DEFAULT_PATH4 };
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 = readFileSync4(file, "utf8");
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 = readFileSync4(path, "utf8");
1401
+ currentRaw = readFileSync6(path, "utf8");
1086
1402
  } catch (error) {
1087
- if (!isNotFound(error)) {
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 isNotFound(error) {
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 readFileSync5 } from "fs";
1132
- import { resolve as resolve2 } from "path";
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 DEFAULT_PATH5 = "takuhon.json";
1141
- var USAGE5 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
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: USAGE5, stderr: "" };
1479
+ return { code: 0, stdout: USAGE6, stderr: "" };
1164
1480
  }
1165
- const parsed = parseArgs5(args);
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 parseArgs5(args) {
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 ?? DEFAULT_PATH5, to: target, out, dryRun };
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 = readFileSync5(path, "utf8");
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 = resolve2(writeTarget) === resolve2(path);
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 readFileSync6 } from "fs";
1657
+ import { readFileSync as readFileSync8 } from "fs";
1342
1658
  import { validate as validate5 } from "@takuhon/core";
1343
- var DEFAULT_PATH6 = "takuhon.json";
1344
- var USAGE6 = `Usage: takuhon restore --from <backup> [path] [--yes]
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: USAGE6, stderr: "" };
1677
+ return { code: 0, stdout: USAGE7, stderr: "" };
1362
1678
  }
1363
- const parsed = parseArgs6(args);
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 parseArgs6(args) {
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 ?? DEFAULT_PATH6, yes };
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 = readFileSync6(from, "utf8");
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 = readFileSync6(path, "utf8");
1762
+ currentRaw = readFileSync8(path, "utf8");
1447
1763
  } catch (error) {
1448
- if (!isNotFound2(error)) {
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 isNotFound2(error) {
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 readFileSync7 } from "fs";
1832
+ import { readFileSync as readFileSync9 } from "fs";
1517
1833
  import { validate as validate6 } from "@takuhon/core";
1518
- var DEFAULT_PATH7 = "takuhon.json";
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 USAGE7 = `Usage: takuhon sync [path] --url <base-url> [--if-match <etag>] [--dry-run]
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: USAGE7, stderr: "" };
1863
+ return { code: 0, stdout: USAGE8, stderr: "" };
1548
1864
  }
1549
- const parsed = parseArgs7(args);
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 parseArgs7(args) {
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 ?? DEFAULT_PATH7, url: base.origin, ifMatch, dryRun };
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 = readFileSync7(path, "utf8");
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 readFileSync8 } from "fs";
2093
+ import { readFileSync as readFileSync10 } from "fs";
1778
2094
  import { validate as validate7 } from "@takuhon/core";
1779
- var DEFAULT_PATH8 = "takuhon.json";
1780
- var USAGE8 = `Usage: takuhon validate [path]
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: USAGE8, stderr: "" };
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 ?? DEFAULT_PATH8;
2117
+ const target = pathArg ?? DEFAULT_PATH9;
1802
2118
  let raw;
1803
2119
  try {
1804
- raw = readFileSync8(target, "utf8");
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(readFileSync9(new URL("../package.json", import.meta.url), "utf8"));
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
  }