@takuhon/cli 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/index.d.ts +22 -0
- package/dist/index.js +1594 -18
- package/dist/index.js.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,1522 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync8, realpathSync } from "fs";
|
|
5
|
+
import { stdin, stdout } from "process";
|
|
6
|
+
import { createInterface } from "readline/promises";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/site.ts
|
|
80
|
+
import { resolveLocale } from "@takuhon/core";
|
|
81
|
+
|
|
82
|
+
// src/build-html.ts
|
|
83
|
+
import { generateJsonLd } from "@takuhon/core";
|
|
84
|
+
function escapeHtml(value) {
|
|
85
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
86
|
+
}
|
|
87
|
+
function escapeJsonLd(json) {
|
|
88
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
89
|
+
}
|
|
90
|
+
function safeUrl(url) {
|
|
91
|
+
const trimmed = url.trim();
|
|
92
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)?.[1]?.toLowerCase();
|
|
93
|
+
if (scheme === void 0) return trimmed;
|
|
94
|
+
return scheme === "http" || scheme === "https" || scheme === "mailto" ? trimmed : void 0;
|
|
95
|
+
}
|
|
96
|
+
var CSS = `:root{--fg:#1a1a1a;--muted:#666;--accent:#0b5fff;--line:#e5e5e5}
|
|
97
|
+
*{box-sizing:border-box}
|
|
98
|
+
body{margin:0;color:var(--fg);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#fff}
|
|
99
|
+
main{max-width:42rem;margin:0 auto;padding:2rem 1.25rem}
|
|
100
|
+
a{color:var(--accent)}
|
|
101
|
+
h1{font-size:1.9rem;margin:.2rem 0}
|
|
102
|
+
h2{font-size:1.15rem;margin:2rem 0 .75rem;padding-bottom:.3rem;border-bottom:1px solid var(--line)}
|
|
103
|
+
h3{font-size:1rem;margin:0}
|
|
104
|
+
header .avatar{width:96px;height:96px;border-radius:50%;object-fit:cover}
|
|
105
|
+
.tagline{font-size:1.1rem;color:var(--muted);margin:.2rem 0}
|
|
106
|
+
.location{color:var(--muted);margin:.2rem 0}
|
|
107
|
+
.bio{margin:.75rem 0}
|
|
108
|
+
ul{padding:0;margin:0;list-style:none}
|
|
109
|
+
.entries>li{margin:0 0 1.1rem}
|
|
110
|
+
.sub{margin:.1rem 0;font-weight:600}
|
|
111
|
+
.meta{margin:.1rem 0;color:var(--muted);font-size:.9rem}
|
|
112
|
+
.links{display:flex;flex-wrap:wrap;gap:.5rem 1rem;margin:.75rem 0}
|
|
113
|
+
.skills,.tags{display:flex;flex-wrap:wrap;gap:.4rem}
|
|
114
|
+
.skills>li,.tags>li{background:#f2f2f2;border-radius:1rem;padding:.15rem .6rem;font-size:.85rem}
|
|
115
|
+
.rec{margin:0 0 1.1rem}
|
|
116
|
+
.rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
|
|
117
|
+
.rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
|
|
118
|
+
nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
|
|
119
|
+
footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
|
|
120
|
+
function dateRange(start, end, isCurrent) {
|
|
121
|
+
const left = start ?? "";
|
|
122
|
+
const right = isCurrent === true || end === null ? "Present" : end ?? "";
|
|
123
|
+
if (left && right) return `${left} \u2013 ${right}`;
|
|
124
|
+
return left || right;
|
|
125
|
+
}
|
|
126
|
+
function nonEmpty(values, separator) {
|
|
127
|
+
const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
|
|
128
|
+
return joined.length > 0 ? joined : void 0;
|
|
129
|
+
}
|
|
130
|
+
function renderEntry(entry) {
|
|
131
|
+
const href = entry.url ? safeUrl(entry.url) : void 0;
|
|
132
|
+
const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
|
|
133
|
+
const parts = [`<h3>${heading}</h3>`];
|
|
134
|
+
if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
|
|
135
|
+
if (entry.dates) parts.push(`<p class="meta">${escapeHtml(entry.dates)}</p>`);
|
|
136
|
+
if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
|
|
137
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
138
|
+
parts.push(
|
|
139
|
+
`<ul class="tags">${entry.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return `<li>${parts.join("")}</li>`;
|
|
143
|
+
}
|
|
144
|
+
function entryList(title, entries) {
|
|
145
|
+
if (entries.length === 0) return "";
|
|
146
|
+
return `<section><h2>${escapeHtml(title)}</h2><ul class="entries">${entries.map(renderEntry).join("")}</ul></section>`;
|
|
147
|
+
}
|
|
148
|
+
function renderHeader(p) {
|
|
149
|
+
const parts = [];
|
|
150
|
+
const avatarSrc = p.avatar?.url ? safeUrl(p.avatar.url) : void 0;
|
|
151
|
+
if (avatarSrc) {
|
|
152
|
+
parts.push(
|
|
153
|
+
`<img class="avatar" src="${escapeHtml(avatarSrc)}" alt="${escapeHtml(p.avatar?.alt ?? "")}">`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
parts.push(`<h1>${escapeHtml(p.displayName)}</h1>`);
|
|
157
|
+
if (p.tagline) parts.push(`<p class="tagline">${escapeHtml(p.tagline)}</p>`);
|
|
158
|
+
if (p.location?.display) parts.push(`<p class="location">${escapeHtml(p.location.display)}</p>`);
|
|
159
|
+
if (p.bio) parts.push(`<p class="bio">${escapeHtml(p.bio)}</p>`);
|
|
160
|
+
return `<header>${parts.join("")}</header>`;
|
|
161
|
+
}
|
|
162
|
+
function renderLinks(links) {
|
|
163
|
+
if (links.length === 0) return "";
|
|
164
|
+
const items = links.map((l) => {
|
|
165
|
+
const text = escapeHtml(l.label ?? l.url);
|
|
166
|
+
const href = safeUrl(l.url);
|
|
167
|
+
return href ? `<li><a href="${escapeHtml(href)}">${text}</a></li>` : `<li>${text}</li>`;
|
|
168
|
+
}).join("");
|
|
169
|
+
return `<nav aria-label="Links"><ul class="links">${items}</ul></nav>`;
|
|
170
|
+
}
|
|
171
|
+
function renderSkills(skills) {
|
|
172
|
+
if (skills.length === 0) return "";
|
|
173
|
+
const items = skills.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("");
|
|
174
|
+
return `<section><h2>Skills</h2><ul class="skills">${items}</ul></section>`;
|
|
175
|
+
}
|
|
176
|
+
function renderLanguages(languages) {
|
|
177
|
+
if (languages.length === 0) return "";
|
|
178
|
+
const items = languages.map((l) => `<li>${escapeHtml(`${l.displayName ?? l.language} \u2014 ${l.proficiency}`)}</li>`).join("");
|
|
179
|
+
return `<section><h2>Languages</h2><ul class="entries">${items}</ul></section>`;
|
|
180
|
+
}
|
|
181
|
+
function renderRecommendations(recs) {
|
|
182
|
+
if (recs.length === 0) return "";
|
|
183
|
+
const items = recs.map((r) => {
|
|
184
|
+
const authorHref = r.author.url ? safeUrl(r.author.url) : void 0;
|
|
185
|
+
const name = authorHref ? `<a href="${escapeHtml(authorHref)}">${escapeHtml(r.author.name)}</a>` : escapeHtml(r.author.name);
|
|
186
|
+
const caption = [name, r.author.headline ? escapeHtml(r.author.headline) : ""].filter(Boolean).join(", ");
|
|
187
|
+
const rel = r.relationship ? ` (${escapeHtml(r.relationship)})` : "";
|
|
188
|
+
return `<figure class="rec"><blockquote>${escapeHtml(r.body)}</blockquote><figcaption>\u2014 ${caption}${rel}</figcaption></figure>`;
|
|
189
|
+
}).join("");
|
|
190
|
+
return `<section><h2>Recommendations</h2>${items}</section>`;
|
|
191
|
+
}
|
|
192
|
+
function renderContact(contact) {
|
|
193
|
+
const items = [];
|
|
194
|
+
if (contact.email) {
|
|
195
|
+
items.push(
|
|
196
|
+
`<li><a href="mailto:${escapeHtml(contact.email)}">${escapeHtml(contact.email)}</a></li>`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const formHref = contact.formUrl ? safeUrl(contact.formUrl) : void 0;
|
|
200
|
+
if (formHref) {
|
|
201
|
+
items.push(`<li><a href="${escapeHtml(formHref)}">Contact form</a></li>`);
|
|
202
|
+
}
|
|
203
|
+
if (items.length === 0) return "";
|
|
204
|
+
return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
|
|
205
|
+
}
|
|
206
|
+
function renderJsonLdScript(data) {
|
|
207
|
+
const payload = JSON.stringify(generateJsonLd(data));
|
|
208
|
+
return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
|
|
209
|
+
}
|
|
210
|
+
function renderLocaleNav(localeNav) {
|
|
211
|
+
const items = localeNav.map(
|
|
212
|
+
(l) => l.current ? `<span aria-current="true">${escapeHtml(l.locale)}</span>` : `<a href="${escapeHtml(l.href)}">${escapeHtml(l.locale)}</a>`
|
|
213
|
+
).join("");
|
|
214
|
+
return `<nav class="locales" aria-label="Language">${items}</nav>`;
|
|
215
|
+
}
|
|
216
|
+
function renderProfileHtml(input) {
|
|
217
|
+
const d = input.localized;
|
|
218
|
+
const p = d.profile;
|
|
219
|
+
const description = p.tagline ?? p.bio ?? "";
|
|
220
|
+
const head = [
|
|
221
|
+
'<meta charset="utf-8">',
|
|
222
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
223
|
+
`<title>${escapeHtml(p.displayName)}</title>`,
|
|
224
|
+
description ? `<meta name="description" content="${escapeHtml(description.slice(0, 300))}">` : "",
|
|
225
|
+
input.canonicalUrl ? `<link rel="canonical" href="${escapeHtml(input.canonicalUrl)}">` : "",
|
|
226
|
+
...input.alternates.map(
|
|
227
|
+
(a) => `<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}">`
|
|
228
|
+
),
|
|
229
|
+
input.jsonLd ? renderJsonLdScript(d) : "",
|
|
230
|
+
`<style>${CSS}</style>`
|
|
231
|
+
].filter(Boolean).join("\n ");
|
|
232
|
+
const body = [
|
|
233
|
+
input.localeNav.length > 1 ? renderLocaleNav(input.localeNav) : "",
|
|
234
|
+
renderHeader(p),
|
|
235
|
+
renderLinks(d.links),
|
|
236
|
+
entryList(
|
|
237
|
+
"Experience",
|
|
238
|
+
d.careers.map((c) => ({
|
|
239
|
+
heading: c.role,
|
|
240
|
+
sub: c.organization,
|
|
241
|
+
dates: dateRange(c.startDate, c.endDate, c.isCurrent),
|
|
242
|
+
body: c.description,
|
|
243
|
+
url: c.url
|
|
244
|
+
}))
|
|
245
|
+
),
|
|
246
|
+
entryList(
|
|
247
|
+
"Projects",
|
|
248
|
+
d.projects.map((x) => ({
|
|
249
|
+
heading: x.title,
|
|
250
|
+
dates: dateRange(x.startDate, x.endDate),
|
|
251
|
+
body: x.description,
|
|
252
|
+
url: x.url,
|
|
253
|
+
tags: x.tags
|
|
254
|
+
}))
|
|
255
|
+
),
|
|
256
|
+
renderSkills(d.skills),
|
|
257
|
+
entryList(
|
|
258
|
+
"Education",
|
|
259
|
+
d.education.map((e) => {
|
|
260
|
+
const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
|
|
261
|
+
return {
|
|
262
|
+
heading: degree ?? e.institution,
|
|
263
|
+
sub: degree ? e.institution : void 0,
|
|
264
|
+
dates: dateRange(e.startDate, e.endDate, e.isCurrent),
|
|
265
|
+
body: e.description,
|
|
266
|
+
url: e.url
|
|
267
|
+
};
|
|
268
|
+
})
|
|
269
|
+
),
|
|
270
|
+
entryList(
|
|
271
|
+
"Certifications",
|
|
272
|
+
d.certifications.map((c) => ({
|
|
273
|
+
heading: c.title,
|
|
274
|
+
sub: c.issuingOrganization,
|
|
275
|
+
dates: dateRange(c.issueDate, c.expirationDate),
|
|
276
|
+
url: c.url
|
|
277
|
+
}))
|
|
278
|
+
),
|
|
279
|
+
entryList(
|
|
280
|
+
"Publications",
|
|
281
|
+
d.publications.map((x) => ({
|
|
282
|
+
heading: x.title,
|
|
283
|
+
sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
|
|
284
|
+
dates: dateRange(x.date),
|
|
285
|
+
body: x.description,
|
|
286
|
+
url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
|
|
287
|
+
}))
|
|
288
|
+
),
|
|
289
|
+
entryList(
|
|
290
|
+
"Honors & awards",
|
|
291
|
+
d.honors.map((x) => ({
|
|
292
|
+
heading: x.title,
|
|
293
|
+
sub: x.issuer,
|
|
294
|
+
dates: dateRange(x.date),
|
|
295
|
+
body: x.description,
|
|
296
|
+
url: x.url
|
|
297
|
+
}))
|
|
298
|
+
),
|
|
299
|
+
entryList(
|
|
300
|
+
"Memberships",
|
|
301
|
+
d.memberships.map((x) => ({
|
|
302
|
+
heading: x.role ?? x.organization,
|
|
303
|
+
sub: x.role ? x.organization : void 0,
|
|
304
|
+
dates: dateRange(x.startDate, x.endDate, x.isCurrent),
|
|
305
|
+
body: x.description,
|
|
306
|
+
url: x.url
|
|
307
|
+
}))
|
|
308
|
+
),
|
|
309
|
+
entryList(
|
|
310
|
+
"Volunteering",
|
|
311
|
+
d.volunteering.map((x) => ({
|
|
312
|
+
heading: x.role,
|
|
313
|
+
sub: nonEmpty([x.organization, x.cause], " \xB7 "),
|
|
314
|
+
dates: dateRange(x.startDate, x.endDate, x.isCurrent),
|
|
315
|
+
body: x.description,
|
|
316
|
+
url: x.url
|
|
317
|
+
}))
|
|
318
|
+
),
|
|
319
|
+
entryList(
|
|
320
|
+
"Courses",
|
|
321
|
+
d.courses.map((x) => ({
|
|
322
|
+
heading: x.title,
|
|
323
|
+
sub: x.provider,
|
|
324
|
+
dates: dateRange(x.completionDate),
|
|
325
|
+
body: x.description,
|
|
326
|
+
url: x.certificateUrl
|
|
327
|
+
}))
|
|
328
|
+
),
|
|
329
|
+
entryList(
|
|
330
|
+
"Patents",
|
|
331
|
+
d.patents.map((x) => ({
|
|
332
|
+
heading: x.title,
|
|
333
|
+
sub: nonEmpty([x.patentNumber, x.office, x.status, x.coInventors?.join(", ")], " \xB7 "),
|
|
334
|
+
dates: dateRange(x.filingDate ?? x.grantDate),
|
|
335
|
+
body: x.description,
|
|
336
|
+
url: x.url
|
|
337
|
+
}))
|
|
338
|
+
),
|
|
339
|
+
entryList(
|
|
340
|
+
"Test scores",
|
|
341
|
+
d.testScores.map((x) => ({
|
|
342
|
+
heading: `${x.title}: ${x.score}`,
|
|
343
|
+
dates: dateRange(x.date),
|
|
344
|
+
body: x.description,
|
|
345
|
+
url: x.url
|
|
346
|
+
}))
|
|
347
|
+
),
|
|
348
|
+
renderLanguages(d.languages),
|
|
349
|
+
renderRecommendations(d.recommendations),
|
|
350
|
+
renderContact(d.contact)
|
|
351
|
+
].filter(Boolean).join("\n");
|
|
352
|
+
const footer = d.settings.showPoweredBy === true ? '<footer class="powered">Powered by takuhon</footer>' : "";
|
|
353
|
+
return `<!DOCTYPE html>
|
|
354
|
+
<html lang="${escapeHtml(d.resolvedLocale)}">
|
|
355
|
+
<head>
|
|
356
|
+
${head}
|
|
357
|
+
</head>
|
|
358
|
+
<body>
|
|
359
|
+
<main>
|
|
360
|
+
${body}
|
|
361
|
+
</main>
|
|
362
|
+
${footer ? `${footer}
|
|
363
|
+
` : ""}</body>
|
|
364
|
+
</html>
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/site.ts
|
|
369
|
+
function generateSite(profile, options = {}) {
|
|
370
|
+
const { baseUrl } = options;
|
|
371
|
+
const defaultLocale = profile.settings.defaultLocale;
|
|
372
|
+
const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
|
|
373
|
+
const jsonLd = profile.settings.enableJsonLd !== false;
|
|
374
|
+
return locales.map((locale) => {
|
|
375
|
+
const localized = resolveLocale(profile, locale);
|
|
376
|
+
const isDefault = locale === defaultLocale;
|
|
377
|
+
const localeNav = locales.map((to) => ({
|
|
378
|
+
locale: to,
|
|
379
|
+
href: localeHref(locale, to, defaultLocale),
|
|
380
|
+
current: to === locale
|
|
381
|
+
}));
|
|
382
|
+
const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
|
|
383
|
+
const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
|
|
384
|
+
const html = renderProfileHtml({ localized, canonicalUrl, alternates, localeNav, jsonLd });
|
|
385
|
+
return {
|
|
386
|
+
route: isDefault ? "/" : `/${locale}/`,
|
|
387
|
+
file: isDefault ? "index.html" : `${locale}/index.html`,
|
|
388
|
+
html
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function absoluteUrl(baseUrl, locale, defaultLocale) {
|
|
393
|
+
return locale === defaultLocale ? `${baseUrl}/` : `${baseUrl}/${locale}/`;
|
|
394
|
+
}
|
|
395
|
+
function buildAlternates(baseUrl, locales, defaultLocale) {
|
|
396
|
+
const alternates = locales.map((locale) => ({
|
|
397
|
+
hreflang: locale,
|
|
398
|
+
href: absoluteUrl(baseUrl, locale, defaultLocale)
|
|
399
|
+
}));
|
|
400
|
+
alternates.push({
|
|
401
|
+
hreflang: "x-default",
|
|
402
|
+
href: absoluteUrl(baseUrl, defaultLocale, defaultLocale)
|
|
403
|
+
});
|
|
404
|
+
return alternates;
|
|
405
|
+
}
|
|
406
|
+
function localeHref(from, to, defaultLocale) {
|
|
407
|
+
const fromRoot = from === defaultLocale;
|
|
408
|
+
const toRoot = to === defaultLocale;
|
|
409
|
+
if (fromRoot) return toRoot ? "./" : `${to}/`;
|
|
410
|
+
return toRoot ? "../" : `../${to}/`;
|
|
411
|
+
}
|
|
412
|
+
|
|
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
|
+
// src/dev-command.ts
|
|
4
562
|
import { readFileSync as readFileSync2 } from "fs";
|
|
563
|
+
import { createServer } from "http";
|
|
564
|
+
import { applyPublicPrivacyFilter as applyPublicPrivacyFilter2, normalize as normalize2, validate as validate2 } from "@takuhon/core";
|
|
565
|
+
var DEFAULT_PATH2 = "takuhon.json";
|
|
566
|
+
var DEFAULT_PORT = 4321;
|
|
567
|
+
var USAGE2 = `Usage: takuhon dev [path] [--port <n>] [--base-url <url>]
|
|
568
|
+
|
|
569
|
+
Serve a takuhon.json as a local static preview (one page per locale) \u2014 the same
|
|
570
|
+
surface \`takuhon build\` produces. With no path, serves ./takuhon.json. The file
|
|
571
|
+
is re-read and re-rendered on every request, so edit it and reload the browser
|
|
572
|
+
to see changes. Stop with Ctrl-C.
|
|
573
|
+
|
|
574
|
+
Options:
|
|
575
|
+
--port <n> Port to listen on (default: ${DEFAULT_PORT}).
|
|
576
|
+
--base-url <url> Site origin (e.g. https://me.example). Enables absolute
|
|
577
|
+
canonical and hreflang links; without it those are omitted.
|
|
578
|
+
|
|
579
|
+
The public privacy filter is applied (meta.privacy is honoured). An invalid
|
|
580
|
+
takuhon.json is served as an error page so you can fix it and reload.
|
|
581
|
+
|
|
582
|
+
Exit codes: 0 = served then stopped, 2 = bad arguments / file missing /
|
|
583
|
+
unreadable / port in use.
|
|
584
|
+
`;
|
|
585
|
+
function loadSiteState(path, baseUrl) {
|
|
586
|
+
let raw;
|
|
587
|
+
try {
|
|
588
|
+
raw = readFileSync2(path, "utf8");
|
|
589
|
+
} catch {
|
|
590
|
+
return { ok: false, status: 500, message: `cannot read '${path}'.` };
|
|
591
|
+
}
|
|
592
|
+
let data;
|
|
593
|
+
try {
|
|
594
|
+
data = JSON.parse(raw);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
597
|
+
return { ok: false, status: 500, message: `'${path}' is not valid JSON: ${detail}` };
|
|
598
|
+
}
|
|
599
|
+
const result = validate2(data);
|
|
600
|
+
if (!result.ok) {
|
|
601
|
+
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
status: 500,
|
|
605
|
+
message: `'${path}' is not a valid takuhon profile:
|
|
606
|
+
${lines.join("\n")}`
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const filtered = applyPublicPrivacyFilter2(normalize2(result.data));
|
|
610
|
+
const pages = new Map(generateSite(filtered, { baseUrl }).map((p) => [p.route, p.html]));
|
|
611
|
+
return { ok: true, pages };
|
|
612
|
+
}
|
|
613
|
+
function resolveRoute(urlPath) {
|
|
614
|
+
let p = urlPath;
|
|
615
|
+
try {
|
|
616
|
+
p = decodeURIComponent(urlPath);
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
p = p.replace(/\/index\.html$/, "/");
|
|
620
|
+
if (p === "" || p === "/") return "/";
|
|
621
|
+
if (!p.startsWith("/")) p = `/${p}`;
|
|
622
|
+
if (!p.endsWith("/")) p = `${p}/`;
|
|
623
|
+
return p;
|
|
624
|
+
}
|
|
625
|
+
function contentType(_route) {
|
|
626
|
+
return "text/html; charset=utf-8";
|
|
627
|
+
}
|
|
628
|
+
function handleRequest(method, urlPath, state) {
|
|
629
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
630
|
+
return { status: 405, contentType: "text/plain; charset=utf-8", body: "Method Not Allowed\n" };
|
|
631
|
+
}
|
|
632
|
+
if (!state.ok) {
|
|
633
|
+
return {
|
|
634
|
+
status: state.status,
|
|
635
|
+
contentType: contentType("/"),
|
|
636
|
+
body: renderErrorPage(state.message)
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const route = resolveRoute(urlPath);
|
|
640
|
+
const html = state.pages.get(route);
|
|
641
|
+
if (html === void 0) {
|
|
642
|
+
return {
|
|
643
|
+
status: 404,
|
|
644
|
+
contentType: contentType(route),
|
|
645
|
+
body: renderNotFoundPage(route, [...state.pages.keys()])
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return { status: 200, contentType: contentType(route), body: html };
|
|
649
|
+
}
|
|
650
|
+
function createDevServer(opts) {
|
|
651
|
+
return createServer((req, res) => {
|
|
652
|
+
const method = req.method ?? "GET";
|
|
653
|
+
const state = loadSiteState(opts.path, opts.baseUrl);
|
|
654
|
+
const response = handleRequest(method, pathnameOf(req.url ?? "/"), state);
|
|
655
|
+
res.writeHead(response.status, { "Content-Type": response.contentType });
|
|
656
|
+
if (method === "HEAD") res.end();
|
|
657
|
+
else res.end(response.body);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
async function runDev(args = [], deps = {}) {
|
|
661
|
+
const out = deps.stdout ?? ((text) => void process.stdout.write(text));
|
|
662
|
+
const err = deps.stderr ?? ((text) => void process.stderr.write(text));
|
|
663
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
664
|
+
out(USAGE2);
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
const parsed = parseArgs2(args);
|
|
668
|
+
if ("error" in parsed) {
|
|
669
|
+
err(`${parsed.error}
|
|
670
|
+
Run \`takuhon dev --help\` for usage.
|
|
671
|
+
`);
|
|
672
|
+
return 2;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
readFileSync2(parsed.path, "utf8");
|
|
676
|
+
} catch {
|
|
677
|
+
err(
|
|
678
|
+
`takuhon: cannot read '${parsed.path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
679
|
+
`
|
|
680
|
+
);
|
|
681
|
+
return 2;
|
|
682
|
+
}
|
|
683
|
+
const server = createDevServer({ path: parsed.path, baseUrl: parsed.baseUrl });
|
|
684
|
+
return await new Promise((resolve3) => {
|
|
685
|
+
let closing = false;
|
|
686
|
+
const shutdown = () => {
|
|
687
|
+
if (closing) return;
|
|
688
|
+
closing = true;
|
|
689
|
+
process.removeListener("SIGINT", shutdown);
|
|
690
|
+
process.removeListener("SIGTERM", shutdown);
|
|
691
|
+
server.close(() => resolve3(0));
|
|
692
|
+
server.closeAllConnections();
|
|
693
|
+
};
|
|
694
|
+
server.once("error", (error) => {
|
|
695
|
+
if (error.code === "EADDRINUSE") {
|
|
696
|
+
err(`takuhon: port ${parsed.port} is already in use; pass --port <n> to choose another.
|
|
697
|
+
`);
|
|
698
|
+
} else {
|
|
699
|
+
err(`takuhon: ${error.message}
|
|
700
|
+
`);
|
|
701
|
+
}
|
|
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);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function parseArgs2(args) {
|
|
722
|
+
let path;
|
|
723
|
+
let portRaw;
|
|
724
|
+
let baseUrl;
|
|
725
|
+
for (let i = 0; i < args.length; i++) {
|
|
726
|
+
const arg = args[i];
|
|
727
|
+
if (arg === "--port" || arg === "--base-url") {
|
|
728
|
+
const value = args[i + 1];
|
|
729
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
730
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
731
|
+
}
|
|
732
|
+
if (arg === "--port") portRaw = value;
|
|
733
|
+
else baseUrl = value;
|
|
734
|
+
i++;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
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;
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (arg.startsWith("--base-url=")) {
|
|
744
|
+
const value = arg.slice("--base-url=".length);
|
|
745
|
+
if (value === "") return { error: "takuhon: `--base-url` requires a value." };
|
|
746
|
+
baseUrl = value;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (arg.startsWith("-")) {
|
|
750
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`dev\`.` };
|
|
751
|
+
}
|
|
752
|
+
if (path !== void 0) {
|
|
753
|
+
return { error: "takuhon: `dev` takes at most one path argument." };
|
|
754
|
+
}
|
|
755
|
+
path = arg;
|
|
756
|
+
}
|
|
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)) {
|
|
768
|
+
return { error: "takuhon: `--base-url` must be an absolute http(s) URL." };
|
|
769
|
+
}
|
|
770
|
+
return {
|
|
771
|
+
path: path ?? DEFAULT_PATH2,
|
|
772
|
+
port,
|
|
773
|
+
// Drop any trailing slash so URL joins are predictable.
|
|
774
|
+
baseUrl: baseUrl?.replace(/\/+$/, "")
|
|
775
|
+
};
|
|
776
|
+
}
|
|
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) {
|
|
783
|
+
try {
|
|
784
|
+
const url = new URL(value);
|
|
785
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
786
|
+
} catch {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function pathnameOf(url) {
|
|
791
|
+
try {
|
|
792
|
+
return new URL(url, "http://localhost").pathname;
|
|
793
|
+
} catch {
|
|
794
|
+
return url;
|
|
795
|
+
}
|
|
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
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/export-command.ts
|
|
835
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
836
|
+
import { resolve } from "path";
|
|
837
|
+
import { exportTakuhon, validate as validate3 } from "@takuhon/core";
|
|
838
|
+
var DEFAULT_PATH3 = "takuhon.json";
|
|
839
|
+
var USAGE3 = `Usage: takuhon export [path] [--output <file>]
|
|
840
|
+
|
|
841
|
+
Serialise a takuhon.json into its transport form and print it to stdout, or
|
|
842
|
+
write it to a file with --output. With no path, exports ./takuhon.json in the
|
|
843
|
+
current working directory.
|
|
844
|
+
|
|
845
|
+
Options:
|
|
846
|
+
--output <file> Write the export to <file> instead of stdout (atomic).
|
|
847
|
+
|
|
848
|
+
Exit codes: 0 = exported, 1 = source is not a valid profile,
|
|
849
|
+
2 = bad arguments / file missing / unreadable / not JSON / write failed.
|
|
850
|
+
`;
|
|
851
|
+
function runExport(args = []) {
|
|
852
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
853
|
+
return { code: 0, stdout: USAGE3, stderr: "" };
|
|
854
|
+
}
|
|
855
|
+
const parsed = parseArgs3(args);
|
|
856
|
+
if ("error" in parsed) {
|
|
857
|
+
return {
|
|
858
|
+
code: 2,
|
|
859
|
+
stdout: "",
|
|
860
|
+
stderr: `${parsed.error}
|
|
861
|
+
Run \`takuhon export --help\` for usage.
|
|
862
|
+
`
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
return exportFile(parsed);
|
|
866
|
+
}
|
|
867
|
+
function parseArgs3(args) {
|
|
868
|
+
let path;
|
|
869
|
+
let output;
|
|
870
|
+
for (let i = 0; i < args.length; i++) {
|
|
871
|
+
const arg = args[i];
|
|
872
|
+
if (arg === "--embed-assets") {
|
|
873
|
+
return {
|
|
874
|
+
error: "takuhon: --embed-assets is not supported yet; assets are remote and asset embedding is deferred."
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
if (arg === "--output") {
|
|
878
|
+
const value = args[i + 1];
|
|
879
|
+
if (value === void 0 || value === "" || value.startsWith("-")) {
|
|
880
|
+
return { error: "takuhon: `--output` requires a value." };
|
|
881
|
+
}
|
|
882
|
+
output = value;
|
|
883
|
+
i++;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (arg.startsWith("--output=")) {
|
|
887
|
+
const value = arg.slice("--output=".length);
|
|
888
|
+
if (value === "") {
|
|
889
|
+
return { error: "takuhon: `--output` requires a value." };
|
|
890
|
+
}
|
|
891
|
+
output = value;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (arg.startsWith("-")) {
|
|
895
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`export\`.` };
|
|
896
|
+
}
|
|
897
|
+
if (path !== void 0) {
|
|
898
|
+
return { error: "takuhon: `export` takes at most one path argument." };
|
|
899
|
+
}
|
|
900
|
+
path = arg;
|
|
901
|
+
}
|
|
902
|
+
return { path: path ?? DEFAULT_PATH3, output };
|
|
903
|
+
}
|
|
904
|
+
function exportFile(parsed) {
|
|
905
|
+
const { path, output } = parsed;
|
|
906
|
+
if (output !== void 0 && resolve(output) === resolve(path)) {
|
|
907
|
+
return {
|
|
908
|
+
code: 2,
|
|
909
|
+
stdout: "",
|
|
910
|
+
stderr: `takuhon: --output '${output}' is the source file; export writes a separate artifact.
|
|
911
|
+
Omit --output to print to stdout, or choose a different file.
|
|
912
|
+
`
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
let raw;
|
|
916
|
+
try {
|
|
917
|
+
raw = readFileSync3(path, "utf8");
|
|
918
|
+
} catch {
|
|
919
|
+
return {
|
|
920
|
+
code: 2,
|
|
921
|
+
stdout: "",
|
|
922
|
+
stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
923
|
+
`
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
let data;
|
|
927
|
+
try {
|
|
928
|
+
data = JSON.parse(raw);
|
|
929
|
+
} catch (error) {
|
|
930
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
931
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
932
|
+
` };
|
|
933
|
+
}
|
|
934
|
+
const result = validate3(data);
|
|
935
|
+
if (!result.ok) {
|
|
936
|
+
const lines = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
937
|
+
return {
|
|
938
|
+
code: 1,
|
|
939
|
+
stdout: "",
|
|
940
|
+
stderr: `takuhon: '${path}' is not a valid takuhon profile; refusing to export:
|
|
941
|
+
${lines.join("\n")}
|
|
942
|
+
`
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const content = `${JSON.stringify(exportTakuhon(data), null, 2)}
|
|
946
|
+
`;
|
|
947
|
+
if (output === void 0) {
|
|
948
|
+
return { code: 0, stdout: content, stderr: "" };
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
writeFileAtomic(output, content);
|
|
952
|
+
} catch (error) {
|
|
953
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
954
|
+
return { code: 2, stdout: "", stderr: `takuhon: failed to write '${output}': ${detail}
|
|
955
|
+
` };
|
|
956
|
+
}
|
|
957
|
+
return { code: 0, stdout: `exported ${path} -> ${output}
|
|
958
|
+
`, stderr: "" };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/import-command.ts
|
|
962
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
963
|
+
import {
|
|
964
|
+
ImportError,
|
|
965
|
+
MigrationError,
|
|
966
|
+
SCHEMA_VERSION,
|
|
967
|
+
importTakuhon,
|
|
968
|
+
migrateTakuhon
|
|
969
|
+
} from "@takuhon/core";
|
|
970
|
+
var DEFAULT_PATH4 = "takuhon.json";
|
|
971
|
+
var USAGE4 = `Usage: takuhon import <file> [path]
|
|
972
|
+
|
|
973
|
+
Load a previously exported profile from <file> into a local takuhon.json,
|
|
974
|
+
migrating it to the current schema version if needed. With no path, writes
|
|
975
|
+
./takuhon.json in the current working directory.
|
|
976
|
+
|
|
977
|
+
The current profile (if any) is backed up to
|
|
978
|
+
.takuhon-backups/pre-import-<timestamp>.json before being overwritten.
|
|
979
|
+
|
|
980
|
+
Exit codes: 0 = imported, 1 = input cannot be imported (invalid / unsupported
|
|
981
|
+
version), 2 = bad arguments / file missing / unreadable / not JSON / write failed.
|
|
982
|
+
`;
|
|
983
|
+
function runImport(args = [], deps = {}) {
|
|
984
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
985
|
+
return { code: 0, stdout: USAGE4, stderr: "" };
|
|
986
|
+
}
|
|
987
|
+
const parsed = parseArgs4(args);
|
|
988
|
+
if ("error" in parsed) {
|
|
989
|
+
return {
|
|
990
|
+
code: 2,
|
|
991
|
+
stdout: "",
|
|
992
|
+
stderr: `${parsed.error}
|
|
993
|
+
Run \`takuhon import --help\` for usage.
|
|
994
|
+
`
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
998
|
+
return importFile(parsed, now);
|
|
999
|
+
}
|
|
1000
|
+
function parseArgs4(args) {
|
|
1001
|
+
const positionals = [];
|
|
1002
|
+
for (const arg of args) {
|
|
1003
|
+
if (arg.startsWith("-")) {
|
|
1004
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`import\`.` };
|
|
1005
|
+
}
|
|
1006
|
+
positionals.push(arg);
|
|
1007
|
+
}
|
|
1008
|
+
if (positionals.length === 0) {
|
|
1009
|
+
return { error: "takuhon: `import` requires an input <file>." };
|
|
1010
|
+
}
|
|
1011
|
+
if (positionals.length > 2) {
|
|
1012
|
+
return { error: "takuhon: `import` takes at most an input <file> and a target path." };
|
|
1013
|
+
}
|
|
1014
|
+
return { file: positionals[0], path: positionals[1] ?? DEFAULT_PATH4 };
|
|
1015
|
+
}
|
|
1016
|
+
function importFile(parsed, now) {
|
|
1017
|
+
const { file, path } = parsed;
|
|
1018
|
+
let raw;
|
|
1019
|
+
try {
|
|
1020
|
+
raw = readFileSync4(file, "utf8");
|
|
1021
|
+
} catch {
|
|
1022
|
+
return { code: 2, stdout: "", stderr: `takuhon: cannot read '${file}'.
|
|
1023
|
+
` };
|
|
1024
|
+
}
|
|
1025
|
+
let data;
|
|
1026
|
+
try {
|
|
1027
|
+
data = JSON.parse(raw);
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1030
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${file}' is not valid JSON: ${detail}
|
|
1031
|
+
` };
|
|
1032
|
+
}
|
|
1033
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
1034
|
+
return {
|
|
1035
|
+
code: 1,
|
|
1036
|
+
stdout: "",
|
|
1037
|
+
stderr: `takuhon: '${file}' is not a takuhon profile (expected a JSON object).
|
|
1038
|
+
`
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
const source = data.schemaVersion;
|
|
1042
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
1043
|
+
return {
|
|
1044
|
+
code: 1,
|
|
1045
|
+
stdout: "",
|
|
1046
|
+
stderr: `takuhon: '${file}' has no usable schemaVersion; cannot import.
|
|
1047
|
+
`
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
let candidate = data;
|
|
1051
|
+
if (source !== SCHEMA_VERSION) {
|
|
1052
|
+
try {
|
|
1053
|
+
candidate = migrateTakuhon(data, SCHEMA_VERSION);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (error instanceof MigrationError) {
|
|
1056
|
+
return {
|
|
1057
|
+
code: 1,
|
|
1058
|
+
stdout: "",
|
|
1059
|
+
stderr: `takuhon: cannot import '${file}': ${error.message}.
|
|
1060
|
+
`
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
let imported;
|
|
1067
|
+
try {
|
|
1068
|
+
imported = importTakuhon(candidate);
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
if (error instanceof ImportError) {
|
|
1071
|
+
const lines2 = (error.errors ?? []).map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1072
|
+
const detail = lines2.length > 0 ? `:
|
|
1073
|
+
${lines2.join("\n")}` : ".";
|
|
1074
|
+
return {
|
|
1075
|
+
code: 1,
|
|
1076
|
+
stdout: "",
|
|
1077
|
+
stderr: `takuhon: '${file}' is not a valid takuhon profile; refusing to import${detail}
|
|
1078
|
+
`
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
let currentRaw;
|
|
1084
|
+
try {
|
|
1085
|
+
currentRaw = readFileSync4(path, "utf8");
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
if (!isNotFound(error)) {
|
|
1088
|
+
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1089
|
+
` };
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
let savedPath;
|
|
1093
|
+
if (currentRaw !== void 0) {
|
|
1094
|
+
const stamp = now();
|
|
1095
|
+
try {
|
|
1096
|
+
savedPath = createBackup({
|
|
1097
|
+
targetPath: path,
|
|
1098
|
+
content: currentRaw,
|
|
1099
|
+
name: (withMillis) => preImportName(stamp, withMillis)
|
|
1100
|
+
});
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
const detail = error instanceof BackupError ? error.message : String(error);
|
|
1103
|
+
return {
|
|
1104
|
+
code: 2,
|
|
1105
|
+
stdout: "",
|
|
1106
|
+
stderr: `takuhon: refusing to import into '${path}' \u2014 pre-import backup failed: ${detail}
|
|
1107
|
+
`
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
writeFileAtomic(path, `${JSON.stringify(imported, null, 2)}
|
|
1113
|
+
`);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1116
|
+
return { code: 2, stdout: "", stderr: `takuhon: failed to write '${path}': ${detail}
|
|
1117
|
+
` };
|
|
1118
|
+
}
|
|
1119
|
+
const lines = [`imported ${file} -> ${path} (schemaVersion ${imported.schemaVersion})`];
|
|
1120
|
+
if (savedPath !== void 0) {
|
|
1121
|
+
lines.push(` previous profile saved to ${savedPath}`);
|
|
1122
|
+
}
|
|
1123
|
+
return { code: 0, stdout: `${lines.join("\n")}
|
|
1124
|
+
`, stderr: "" };
|
|
1125
|
+
}
|
|
1126
|
+
function isNotFound(error) {
|
|
1127
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/migrate-command.ts
|
|
1131
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1132
|
+
import { resolve as resolve2 } from "path";
|
|
1133
|
+
import {
|
|
1134
|
+
MigrationError as MigrationError2,
|
|
1135
|
+
SCHEMA_VERSION as SCHEMA_VERSION2,
|
|
1136
|
+
SUPPORTED_SCHEMA_VERSIONS,
|
|
1137
|
+
migrateTakuhon as migrateTakuhon2,
|
|
1138
|
+
validate as validate4
|
|
1139
|
+
} from "@takuhon/core";
|
|
1140
|
+
var DEFAULT_PATH5 = "takuhon.json";
|
|
1141
|
+
var USAGE5 = `Usage: takuhon migrate [path] [--to <version>] [--out <file>] [--dry-run]
|
|
1142
|
+
|
|
1143
|
+
Forward-migrate a takuhon.json to a newer schema version. The source version
|
|
1144
|
+
is read from the file's own schemaVersion. With no path, migrates
|
|
1145
|
+
./takuhon.json in the current working directory.
|
|
1146
|
+
|
|
1147
|
+
Options:
|
|
1148
|
+
--to <version> Target schema version (default: ${SCHEMA_VERSION2}).
|
|
1149
|
+
One of: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.
|
|
1150
|
+
--out <file> Write the result to <file> instead of in place. The source
|
|
1151
|
+
file is left unchanged and no backup is created.
|
|
1152
|
+
--dry-run Report the planned migration and backup path; write nothing.
|
|
1153
|
+
|
|
1154
|
+
Before an in-place write the current file is backed up to .takuhon-backups/
|
|
1155
|
+
beside it. Migrations are forward-only; to move to an older schema, restore
|
|
1156
|
+
from a backup with \`takuhon restore\`.
|
|
1157
|
+
|
|
1158
|
+
Exit codes: 0 = migrated / already current / dry-run, 1 = cannot migrate,
|
|
1159
|
+
2 = bad arguments / file missing / unreadable / not JSON.
|
|
1160
|
+
`;
|
|
1161
|
+
function runMigrate(args = [], deps = {}) {
|
|
1162
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1163
|
+
return { code: 0, stdout: USAGE5, stderr: "" };
|
|
1164
|
+
}
|
|
1165
|
+
const parsed = parseArgs5(args);
|
|
1166
|
+
if ("error" in parsed) {
|
|
1167
|
+
return {
|
|
1168
|
+
code: 2,
|
|
1169
|
+
stdout: "",
|
|
1170
|
+
stderr: `${parsed.error}
|
|
1171
|
+
Run \`takuhon migrate --help\` for usage.
|
|
1172
|
+
`
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1176
|
+
return migrateFile(parsed, now);
|
|
1177
|
+
}
|
|
1178
|
+
function parseArgs5(args) {
|
|
1179
|
+
let path;
|
|
1180
|
+
let to;
|
|
1181
|
+
let out;
|
|
1182
|
+
let dryRun = false;
|
|
1183
|
+
for (let i = 0; i < args.length; i++) {
|
|
1184
|
+
const arg = args[i];
|
|
1185
|
+
if (arg === "--dry-run") {
|
|
1186
|
+
dryRun = true;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (arg === "--to" || arg === "--out") {
|
|
1190
|
+
const value = args[i + 1];
|
|
1191
|
+
if (value === void 0 || value.startsWith("-")) {
|
|
1192
|
+
return { error: `takuhon: \`${arg}\` requires a value.` };
|
|
1193
|
+
}
|
|
1194
|
+
if (arg === "--to") to = value;
|
|
1195
|
+
else out = value;
|
|
1196
|
+
i++;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (arg.startsWith("--to=")) {
|
|
1200
|
+
to = arg.slice("--to=".length);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (arg.startsWith("--out=")) {
|
|
1204
|
+
out = arg.slice("--out=".length);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (arg.startsWith("-")) {
|
|
1208
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`migrate\`.` };
|
|
1209
|
+
}
|
|
1210
|
+
if (path !== void 0) {
|
|
1211
|
+
return { error: "takuhon: `migrate` takes at most one path argument." };
|
|
1212
|
+
}
|
|
1213
|
+
path = arg;
|
|
1214
|
+
}
|
|
1215
|
+
const target = to ?? SCHEMA_VERSION2;
|
|
1216
|
+
if (!SUPPORTED_SCHEMA_VERSIONS.includes(target)) {
|
|
1217
|
+
return {
|
|
1218
|
+
error: `takuhon: unsupported --to version "${target}". Supported: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}.`
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
return { path: path ?? DEFAULT_PATH5, to: target, out, dryRun };
|
|
1222
|
+
}
|
|
1223
|
+
function migrateFile(parsed, now) {
|
|
1224
|
+
const { path, to: target, out, dryRun } = parsed;
|
|
1225
|
+
let raw;
|
|
1226
|
+
try {
|
|
1227
|
+
raw = readFileSync5(path, "utf8");
|
|
1228
|
+
} catch {
|
|
1229
|
+
return {
|
|
1230
|
+
code: 2,
|
|
1231
|
+
stdout: "",
|
|
1232
|
+
stderr: `takuhon: cannot read '${path}'. Pass a path, or run from a directory containing a takuhon.json.
|
|
1233
|
+
`
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
let data;
|
|
1237
|
+
try {
|
|
1238
|
+
data = JSON.parse(raw);
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1241
|
+
return { code: 2, stdout: "", stderr: `takuhon: '${path}' is not valid JSON: ${detail}
|
|
1242
|
+
` };
|
|
1243
|
+
}
|
|
1244
|
+
const source = data.schemaVersion;
|
|
1245
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
1246
|
+
return {
|
|
1247
|
+
code: 1,
|
|
1248
|
+
stdout: "",
|
|
1249
|
+
stderr: `takuhon: '${path}' has no usable schemaVersion; cannot determine what to migrate from.
|
|
1250
|
+
`
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
if (source === target) {
|
|
1254
|
+
return {
|
|
1255
|
+
code: 0,
|
|
1256
|
+
stdout: `${path}: already at schemaVersion ${target}; nothing to do.
|
|
1257
|
+
`,
|
|
1258
|
+
stderr: ""
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const writeTarget = out ?? path;
|
|
1262
|
+
const inPlace = resolve2(writeTarget) === resolve2(path);
|
|
1263
|
+
let migrated;
|
|
1264
|
+
try {
|
|
1265
|
+
migrated = migrateTakuhon2(data, target);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
if (error instanceof MigrationError2) {
|
|
1268
|
+
return {
|
|
1269
|
+
code: 1,
|
|
1270
|
+
stdout: "",
|
|
1271
|
+
stderr: `takuhon: cannot migrate '${path}': ${error.message}.
|
|
1272
|
+
Migrations are forward-only; to move to an older schema, restore from a backup with \`takuhon restore\`.
|
|
1273
|
+
`
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
throw error;
|
|
1277
|
+
}
|
|
1278
|
+
const revalidated = validate4(migrated);
|
|
1279
|
+
if (!revalidated.ok) {
|
|
1280
|
+
const lines2 = revalidated.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1281
|
+
return {
|
|
1282
|
+
code: 1,
|
|
1283
|
+
stdout: "",
|
|
1284
|
+
stderr: `takuhon: migrated '${path}' to ${target} but the result failed validation:
|
|
1285
|
+
${lines2.join("\n")}
|
|
1286
|
+
`
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const stamp = now();
|
|
1290
|
+
if (dryRun) {
|
|
1291
|
+
const lines2 = [`${path}: would migrate ${source} -> ${target}`, ` write: ${writeTarget}`];
|
|
1292
|
+
if (inPlace) {
|
|
1293
|
+
lines2.push(` backup: ${backupDirFor(path)}/${migrateBackupName(source, stamp)}`);
|
|
1294
|
+
} else {
|
|
1295
|
+
lines2.push(" (source left unchanged; no backup created)");
|
|
1296
|
+
}
|
|
1297
|
+
return { code: 0, stdout: `${lines2.join("\n")}
|
|
1298
|
+
`, stderr: "" };
|
|
1299
|
+
}
|
|
1300
|
+
let backupPath;
|
|
1301
|
+
if (inPlace) {
|
|
1302
|
+
try {
|
|
1303
|
+
backupPath = createBackup({
|
|
1304
|
+
targetPath: path,
|
|
1305
|
+
content: raw,
|
|
1306
|
+
name: (withMillis) => migrateBackupName(source, stamp, withMillis)
|
|
1307
|
+
});
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
const detail = error instanceof BackupError ? error.message : String(error);
|
|
1310
|
+
return {
|
|
1311
|
+
code: 2,
|
|
1312
|
+
stdout: "",
|
|
1313
|
+
stderr: `takuhon: refusing to migrate '${path}' \u2014 backup failed: ${detail}
|
|
1314
|
+
`
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
writeFileAtomic(writeTarget, `${JSON.stringify(migrated, null, 2)}
|
|
1320
|
+
`);
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1323
|
+
return {
|
|
1324
|
+
code: 2,
|
|
1325
|
+
stdout: "",
|
|
1326
|
+
stderr: `takuhon: failed to write '${writeTarget}': ${detail}
|
|
1327
|
+
`
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const lines = [`migrated ${path}: ${source} -> ${target}`];
|
|
1331
|
+
if (inPlace) {
|
|
1332
|
+
lines.push(` backup: ${backupPath}`);
|
|
1333
|
+
} else {
|
|
1334
|
+
lines.push(` wrote: ${writeTarget} (source left unchanged; no backup created)`);
|
|
1335
|
+
}
|
|
1336
|
+
return { code: 0, stdout: `${lines.join("\n")}
|
|
1337
|
+
`, stderr: "" };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/restore-command.ts
|
|
1341
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
1342
|
+
import { validate as validate5 } from "@takuhon/core";
|
|
1343
|
+
var DEFAULT_PATH6 = "takuhon.json";
|
|
1344
|
+
var USAGE6 = `Usage: takuhon restore --from <backup> [path] [--yes]
|
|
1345
|
+
|
|
1346
|
+
Overwrite a profile with a previously saved backup. With no path, restores
|
|
1347
|
+
./takuhon.json in the current working directory.
|
|
1348
|
+
|
|
1349
|
+
Options:
|
|
1350
|
+
--from <backup> Backup file to restore from (required).
|
|
1351
|
+
--yes, -y Skip the confirmation prompt.
|
|
1352
|
+
|
|
1353
|
+
The backup is schema-validated first, and the current profile is saved to
|
|
1354
|
+
.takuhon-backups/pre-restore-<timestamp>.json before being overwritten.
|
|
1355
|
+
|
|
1356
|
+
Exit codes: 0 = restored / aborted, 1 = backup failed validation,
|
|
1357
|
+
2 = bad arguments / file missing / unreadable / not JSON / unconfirmed.
|
|
1358
|
+
`;
|
|
1359
|
+
async function runRestore(args = [], deps = {}) {
|
|
1360
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
1361
|
+
return { code: 0, stdout: USAGE6, stderr: "" };
|
|
1362
|
+
}
|
|
1363
|
+
const parsed = parseArgs6(args);
|
|
1364
|
+
if ("error" in parsed) {
|
|
1365
|
+
return {
|
|
1366
|
+
code: 2,
|
|
1367
|
+
stdout: "",
|
|
1368
|
+
stderr: `${parsed.error}
|
|
1369
|
+
Run \`takuhon restore --help\` for usage.
|
|
1370
|
+
`
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
1374
|
+
return restoreFile(parsed, now, deps.confirm);
|
|
1375
|
+
}
|
|
1376
|
+
function parseArgs6(args) {
|
|
1377
|
+
let from;
|
|
1378
|
+
let path;
|
|
1379
|
+
let yes = false;
|
|
1380
|
+
for (let i = 0; i < args.length; i++) {
|
|
1381
|
+
const arg = args[i];
|
|
1382
|
+
if (arg === "--yes" || arg === "-y") {
|
|
1383
|
+
yes = true;
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if (arg === "--from") {
|
|
1387
|
+
const value = args[i + 1];
|
|
1388
|
+
if (value === void 0 || value.startsWith("-")) {
|
|
1389
|
+
return { error: "takuhon: `--from` requires a value." };
|
|
1390
|
+
}
|
|
1391
|
+
from = value;
|
|
1392
|
+
i++;
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (arg.startsWith("--from=")) {
|
|
1396
|
+
from = arg.slice("--from=".length);
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
if (arg.startsWith("-")) {
|
|
1400
|
+
return { error: `takuhon: unknown option \`${arg}\` for \`restore\`.` };
|
|
1401
|
+
}
|
|
1402
|
+
if (path !== void 0) {
|
|
1403
|
+
return { error: "takuhon: `restore` takes at most one path argument." };
|
|
1404
|
+
}
|
|
1405
|
+
path = arg;
|
|
1406
|
+
}
|
|
1407
|
+
if (from === void 0 || from.length === 0) {
|
|
1408
|
+
return { error: "takuhon: `restore` requires `--from <backup>`." };
|
|
1409
|
+
}
|
|
1410
|
+
return { from, path: path ?? DEFAULT_PATH6, yes };
|
|
1411
|
+
}
|
|
1412
|
+
async function restoreFile(parsed, now, confirm) {
|
|
1413
|
+
const { from, path, yes } = parsed;
|
|
1414
|
+
let backupRaw;
|
|
1415
|
+
try {
|
|
1416
|
+
backupRaw = readFileSync6(from, "utf8");
|
|
1417
|
+
} catch {
|
|
1418
|
+
return { code: 2, stdout: "", stderr: `takuhon: cannot read backup '${from}'.
|
|
1419
|
+
` };
|
|
1420
|
+
}
|
|
1421
|
+
let backupData;
|
|
1422
|
+
try {
|
|
1423
|
+
backupData = JSON.parse(backupRaw);
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1426
|
+
return {
|
|
1427
|
+
code: 2,
|
|
1428
|
+
stdout: "",
|
|
1429
|
+
stderr: `takuhon: backup '${from}' is not valid JSON: ${detail}
|
|
1430
|
+
`
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
const result = validate5(backupData);
|
|
1434
|
+
if (!result.ok) {
|
|
1435
|
+
const lines2 = result.errors.map((e) => ` ${e.pointer || "/"}: ${e.message}`);
|
|
1436
|
+
return {
|
|
1437
|
+
code: 1,
|
|
1438
|
+
stdout: "",
|
|
1439
|
+
stderr: `takuhon: backup '${from}' is not a valid takuhon profile; refusing to restore:
|
|
1440
|
+
${lines2.join("\n")}
|
|
1441
|
+
`
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
let currentRaw;
|
|
1445
|
+
try {
|
|
1446
|
+
currentRaw = readFileSync6(path, "utf8");
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
if (!isNotFound2(error)) {
|
|
1449
|
+
return { code: 2, stdout: "", stderr: `takuhon: cannot read current profile '${path}'.
|
|
1450
|
+
` };
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const stamp = now();
|
|
1454
|
+
const preRestorePath = `${backupDirFor(path)}/${preRestoreName(stamp)}`;
|
|
1455
|
+
if (!yes) {
|
|
1456
|
+
if (!confirm) {
|
|
1457
|
+
return {
|
|
1458
|
+
code: 2,
|
|
1459
|
+
stdout: "",
|
|
1460
|
+
stderr: `takuhon: refusing to overwrite '${path}' without confirmation.
|
|
1461
|
+
Re-run interactively, or pass \`--yes\` to skip the prompt.
|
|
1462
|
+
`
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const decided = await confirm(
|
|
1466
|
+
confirmationMessage(path, from, result.data, currentRaw, preRestorePath)
|
|
1467
|
+
);
|
|
1468
|
+
if (!decided) {
|
|
1469
|
+
return { code: 0, stdout: "Aborted; no changes made.\n", stderr: "" };
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
let savedPath;
|
|
1473
|
+
if (currentRaw !== void 0) {
|
|
1474
|
+
try {
|
|
1475
|
+
savedPath = createBackup({
|
|
1476
|
+
targetPath: path,
|
|
1477
|
+
content: currentRaw,
|
|
1478
|
+
name: (withMillis) => preRestoreName(stamp, withMillis)
|
|
1479
|
+
});
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
const detail = error instanceof BackupError ? error.message : String(error);
|
|
1482
|
+
return {
|
|
1483
|
+
code: 2,
|
|
1484
|
+
stdout: "",
|
|
1485
|
+
stderr: `takuhon: refusing to restore '${path}' \u2014 pre-restore backup failed: ${detail}
|
|
1486
|
+
`
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
writeFileAtomic(path, backupRaw);
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1494
|
+
return { code: 2, stdout: "", stderr: `takuhon: failed to write '${path}': ${detail}
|
|
1495
|
+
` };
|
|
1496
|
+
}
|
|
1497
|
+
const lines = [`restored ${path} from ${from}`];
|
|
1498
|
+
if (savedPath !== void 0) {
|
|
1499
|
+
lines.push(` previous profile saved to ${savedPath}`);
|
|
1500
|
+
}
|
|
1501
|
+
return { code: 0, stdout: `${lines.join("\n")}
|
|
1502
|
+
`, stderr: "" };
|
|
1503
|
+
}
|
|
1504
|
+
function confirmationMessage(path, from, data, currentRaw, preRestorePath) {
|
|
1505
|
+
const when = typeof data.meta.updatedAt === "string" ? ` (from ${data.meta.updatedAt})` : "";
|
|
1506
|
+
const preNote = currentRaw !== void 0 ? `Your current profile will be saved as ${preRestorePath}.` : `(no existing profile at ${path} to preserve)`;
|
|
1507
|
+
return `This will overwrite the profile at ${path} with the backup ${from}${when}.
|
|
1508
|
+
${preNote}
|
|
1509
|
+
Continue? [y/N]`;
|
|
1510
|
+
}
|
|
1511
|
+
function isNotFound2(error) {
|
|
1512
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
1513
|
+
}
|
|
5
1514
|
|
|
6
1515
|
// src/validate-command.ts
|
|
7
|
-
import { readFileSync } from "fs";
|
|
8
|
-
import { validate } from "@takuhon/core";
|
|
9
|
-
var
|
|
10
|
-
var
|
|
1516
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
1517
|
+
import { validate as validate6 } from "@takuhon/core";
|
|
1518
|
+
var DEFAULT_PATH7 = "takuhon.json";
|
|
1519
|
+
var USAGE7 = `Usage: takuhon validate [path]
|
|
11
1520
|
|
|
12
1521
|
Validate a takuhon.json against the takuhon schema. With no path, validates
|
|
13
1522
|
./takuhon.json in the current working directory.
|
|
@@ -16,7 +1525,7 @@ Exit codes: 0 = valid, 1 = invalid, 2 = file missing / unreadable / not JSON.
|
|
|
16
1525
|
`;
|
|
17
1526
|
function runValidate(args = []) {
|
|
18
1527
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
19
|
-
return { code: 0, stdout:
|
|
1528
|
+
return { code: 0, stdout: USAGE7, stderr: "" };
|
|
20
1529
|
}
|
|
21
1530
|
if (args.length > 1) {
|
|
22
1531
|
return {
|
|
@@ -28,10 +1537,10 @@ function runValidate(args = []) {
|
|
|
28
1537
|
return validateFile(args[0]);
|
|
29
1538
|
}
|
|
30
1539
|
function validateFile(pathArg) {
|
|
31
|
-
const target = pathArg ??
|
|
1540
|
+
const target = pathArg ?? DEFAULT_PATH7;
|
|
32
1541
|
let raw;
|
|
33
1542
|
try {
|
|
34
|
-
raw =
|
|
1543
|
+
raw = readFileSync7(target, "utf8");
|
|
35
1544
|
} catch {
|
|
36
1545
|
return {
|
|
37
1546
|
code: 2,
|
|
@@ -52,7 +1561,7 @@ function validateFile(pathArg) {
|
|
|
52
1561
|
`
|
|
53
1562
|
};
|
|
54
1563
|
}
|
|
55
|
-
const result =
|
|
1564
|
+
const result = validate6(data);
|
|
56
1565
|
if (result.ok) {
|
|
57
1566
|
return {
|
|
58
1567
|
code: 0,
|
|
@@ -73,7 +1582,7 @@ ${lines.join("\n")}
|
|
|
73
1582
|
}
|
|
74
1583
|
|
|
75
1584
|
// src/index.ts
|
|
76
|
-
var pkg = JSON.parse(
|
|
1585
|
+
var pkg = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
|
|
77
1586
|
var VERSION = pkg.version;
|
|
78
1587
|
var HELP = `takuhon ${VERSION}
|
|
79
1588
|
|
|
@@ -84,18 +1593,32 @@ Usage:
|
|
|
84
1593
|
takuhon --help Show this help
|
|
85
1594
|
|
|
86
1595
|
Commands:
|
|
87
|
-
takuhon validate [path]
|
|
1596
|
+
takuhon validate [path] Validate a takuhon.json (default: ./takuhon.json)
|
|
1597
|
+
takuhon migrate [path] [--to <v>] Forward-migrate a takuhon.json to a newer schema
|
|
1598
|
+
version (default target: latest). Backs up first.
|
|
1599
|
+
Add --out <file> to write elsewhere, --dry-run to preview.
|
|
1600
|
+
takuhon restore --from <backup> Restore a profile from a backup (prompts before
|
|
1601
|
+
overwriting; pass --yes to skip).
|
|
1602
|
+
takuhon export [path] [--output <f>] Serialise a takuhon.json to stdout (or --output file).
|
|
1603
|
+
takuhon import <file> [path] Import an exported profile into a takuhon.json,
|
|
1604
|
+
migrating to the current schema version. Backs up first.
|
|
1605
|
+
takuhon build [path] [--output <d>] Render a takuhon.json into a static site (HTML +
|
|
1606
|
+
JSON-LD, one page per locale). --base-url <url> adds
|
|
1607
|
+
absolute canonical/hreflang links.
|
|
1608
|
+
takuhon dev [path] [--port <n>] Serve a takuhon.json as a local static preview,
|
|
1609
|
+
re-rendered on each request (default port: 4321).
|
|
1610
|
+
--base-url <url> adds canonical/hreflang links.
|
|
88
1611
|
|
|
89
1612
|
Scaffolding a new profile project:
|
|
90
1613
|
npx create-takuhon my-profile
|
|
91
1614
|
npx create-takuhon my-profile --license CC-BY-4.0
|
|
92
1615
|
|
|
93
|
-
|
|
94
|
-
|
|
1616
|
+
The sync subcommand is planned for a future release.
|
|
1617
|
+
Track progress at:
|
|
95
1618
|
|
|
96
1619
|
https://github.com/takuhon-dev/takuhon
|
|
97
1620
|
`;
|
|
98
|
-
function main(argv) {
|
|
1621
|
+
async function main(argv) {
|
|
99
1622
|
const first = argv[0];
|
|
100
1623
|
if (first === "--version" || first === "-v") {
|
|
101
1624
|
process.stdout.write(`${VERSION}
|
|
@@ -107,10 +1630,26 @@ function main(argv) {
|
|
|
107
1630
|
return 0;
|
|
108
1631
|
}
|
|
109
1632
|
if (first === "validate") {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
1633
|
+
return emit(runValidate(argv.slice(1)));
|
|
1634
|
+
}
|
|
1635
|
+
if (first === "migrate") {
|
|
1636
|
+
return emit(runMigrate(argv.slice(1)));
|
|
1637
|
+
}
|
|
1638
|
+
if (first === "export") {
|
|
1639
|
+
return emit(runExport(argv.slice(1)));
|
|
1640
|
+
}
|
|
1641
|
+
if (first === "build") {
|
|
1642
|
+
return emit(runBuild(argv.slice(1)));
|
|
1643
|
+
}
|
|
1644
|
+
if (first === "dev") {
|
|
1645
|
+
return runDev(argv.slice(1));
|
|
1646
|
+
}
|
|
1647
|
+
if (first === "import") {
|
|
1648
|
+
return emit(runImport(argv.slice(1)));
|
|
1649
|
+
}
|
|
1650
|
+
if (first === "restore") {
|
|
1651
|
+
const confirm = stdin.isTTY ? promptConfirm : void 0;
|
|
1652
|
+
return emit(await runRestore(argv.slice(1), { confirm }));
|
|
114
1653
|
}
|
|
115
1654
|
process.stderr.write(
|
|
116
1655
|
`takuhon: unknown command '${first}'
|
|
@@ -119,5 +1658,42 @@ Run \`takuhon --help\` for usage. For scaffolding a new project, use \`create-ta
|
|
|
119
1658
|
);
|
|
120
1659
|
return 2;
|
|
121
1660
|
}
|
|
122
|
-
|
|
1661
|
+
function emit(outcome) {
|
|
1662
|
+
if (outcome.stdout) process.stdout.write(outcome.stdout);
|
|
1663
|
+
if (outcome.stderr) process.stderr.write(outcome.stderr);
|
|
1664
|
+
return outcome.code;
|
|
1665
|
+
}
|
|
1666
|
+
async function promptConfirm(message) {
|
|
1667
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1668
|
+
try {
|
|
1669
|
+
const answer = await rl.question(`${message} `);
|
|
1670
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
1671
|
+
} finally {
|
|
1672
|
+
rl.close();
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
async function run(argv = process.argv.slice(2)) {
|
|
1676
|
+
try {
|
|
1677
|
+
process.exit(await main(argv));
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
process.stderr.write(`takuhon: ${error instanceof Error ? error.message : String(error)}
|
|
1680
|
+
`);
|
|
1681
|
+
process.exit(1);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
function isEntrypoint() {
|
|
1685
|
+
const entry = process.argv[1];
|
|
1686
|
+
if (entry === void 0) return false;
|
|
1687
|
+
try {
|
|
1688
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
1689
|
+
} catch {
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (isEntrypoint()) {
|
|
1694
|
+
void run();
|
|
1695
|
+
}
|
|
1696
|
+
export {
|
|
1697
|
+
run
|
|
1698
|
+
};
|
|
123
1699
|
//# sourceMappingURL=index.js.map
|