@blocofy/cli 0.7.0 → 0.9.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/README.md +60 -2
- package/bin/blocofy.mjs +360 -27
- package/lib/args.mjs +5 -2
- package/lib/content-sync.mjs +272 -44
- package/lib/credentials.mjs +57 -15
- package/lib/media-uses.mjs +82 -0
- package/lib/page-files.mjs +308 -0
- package/lib/page-path-codec.mjs +193 -0
- package/lib/secret-prompt.mjs +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PAGE_FILE_FORMAT_VERSION,
|
|
6
|
+
PageFileError,
|
|
7
|
+
canonicalizeLocaleCode,
|
|
8
|
+
checkPageSlug,
|
|
9
|
+
checkRelativePagePath,
|
|
10
|
+
isCanonicalLayoutPath,
|
|
11
|
+
legacyPageFilePath,
|
|
12
|
+
pageFilePath,
|
|
13
|
+
parsePageFilePath,
|
|
14
|
+
} from "./page-path-codec.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PS-19 — local page files: what a file is (mirror of the platform's `page-files/file-format.ts`), how the CLI
|
|
18
|
+
* reads and writes them without leaving the target directory, and how findings are printed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const PAGE_META_KEYS = ["title", "seo_title", "seo_description", "og_image", "canonical_url"];
|
|
22
|
+
const NULLABLE_STRING_KEYS = [...PAGE_META_KEYS, "status", "template"];
|
|
23
|
+
const V2_KEY_ORDER = ["format_version", "slug", "title", "status", "seo_title", "seo_description", "og_image", "canonical_url", "locale", "template", "data"];
|
|
24
|
+
|
|
25
|
+
/** A refusal the CLI prints with its stable code. `diagnostics` are platform-shaped findings. */
|
|
26
|
+
export class PagesCliError extends Error {
|
|
27
|
+
constructor(code, message, { diagnostics = [], pages = null, status = null } = {}) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "PagesCliError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.diagnostics = diagnostics;
|
|
32
|
+
this.pages = pages;
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
38
|
+
const isCanonicalLocale = (v) => typeof v === "string" && canonicalizeLocaleCode(v) === v;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify one page file. `ctx.defaultLocale` null means offline: a legacy file with no locale key is accepted
|
|
42
|
+
* with `locale: null` (the server decides). `ctx.supportedLocales` omitted → not checked.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyPageFile(path, content, ctx = {}) {
|
|
45
|
+
const fail = (code, message, extra = {}) => ({ ok: false, path, code, message, ...extra });
|
|
46
|
+
const unsafe = checkRelativePagePath(path);
|
|
47
|
+
if (unsafe) return fail(unsafe.code, unsafe.reason);
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = JSON.parse(content);
|
|
51
|
+
} catch {
|
|
52
|
+
return fail("PAGES_INVALID_JSON", "file is not valid JSON");
|
|
53
|
+
}
|
|
54
|
+
if (!isPlainObject(json)) return fail("PAGES_INVALID_JSON", "file must contain a JSON object");
|
|
55
|
+
const isV2 = Object.prototype.hasOwnProperty.call(json, "format_version");
|
|
56
|
+
if (isV2 && json.format_version !== PAGE_FILE_FORMAT_VERSION) {
|
|
57
|
+
return fail("PAGES_UNSUPPORTED_FORMAT", `format_version ${JSON.stringify(json.format_version)} is not supported`);
|
|
58
|
+
}
|
|
59
|
+
const slugReason = checkPageSlug(json.slug);
|
|
60
|
+
if (slugReason) return fail("PAGES_INVALID_SLUG", `slug ${JSON.stringify(json.slug)}: ${slugReason}`);
|
|
61
|
+
const slug = json.slug;
|
|
62
|
+
if (!isPlainObject(json.data)) return fail("PAGES_INVALID_JSON", "data must be a JSON object", { slug });
|
|
63
|
+
for (const key of NULLABLE_STRING_KEYS) {
|
|
64
|
+
if (key in json && json[key] !== null && typeof json[key] !== "string") return fail("PAGES_INVALID_JSON", `${key} must be a string or null`, { slug });
|
|
65
|
+
}
|
|
66
|
+
const hasLocale = Object.prototype.hasOwnProperty.call(json, "locale");
|
|
67
|
+
let locale = null;
|
|
68
|
+
let localeSource;
|
|
69
|
+
if (hasLocale) {
|
|
70
|
+
if (!isCanonicalLocale(json.locale)) return fail("PAGES_INVALID_LOCALE", `locale ${JSON.stringify(json.locale)} is not a canonical locale code`, { slug });
|
|
71
|
+
locale = json.locale;
|
|
72
|
+
localeSource = "file";
|
|
73
|
+
} else if (isV2) {
|
|
74
|
+
return fail("PAGES_INVALID_LOCALE", "format_version 2 files must carry an explicit locale", { slug });
|
|
75
|
+
} else {
|
|
76
|
+
locale = ctx.defaultLocale ?? null;
|
|
77
|
+
localeSource = ctx.defaultLocale ? "default" : "unresolved";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isV2) {
|
|
81
|
+
const parsed = parsePageFilePath(path);
|
|
82
|
+
if (parsed.kind === "invalid") return fail(parsed.code, parsed.reason, { locale, slug });
|
|
83
|
+
if (parsed.kind === "other") return fail("PAGES_INVALID_PATH", `format_version 2 files must use the canonical layout (${pageFilePath(locale, slug)})`, { locale, slug });
|
|
84
|
+
if (parsed.locale !== locale) return fail("PAGES_LOCALE_PATH_MISMATCH", `folder locale "${parsed.locale}" does not match file locale "${locale}"`, { locale, slug });
|
|
85
|
+
const expected = pageFilePath(locale, slug);
|
|
86
|
+
if (expected !== path) return fail("PAGES_INVALID_PATH", `slug ${JSON.stringify(slug)} belongs at ${expected}`, { locale, slug });
|
|
87
|
+
} else if (isCanonicalLayoutPath(path) || legacyPageFilePath(slug) !== path) {
|
|
88
|
+
return fail("PAGES_AMBIGUOUS_LAYOUT", `file has no format_version but is not at its legacy location (${legacyPageFilePath(slug)})`, { ...(locale ? { locale } : {}), slug });
|
|
89
|
+
}
|
|
90
|
+
if (locale && ctx.supportedLocales && !ctx.supportedLocales.includes(locale)) {
|
|
91
|
+
return fail("PAGES_UNSUPPORTED_LOCALE", `locale "${locale}" is not published on this site`, { locale, slug });
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, path, layout: isV2 ? "v2" : "legacy", locale, slug, localeSource, parsed: json };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stableStringify(value) {
|
|
97
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
98
|
+
if (isPlainObject(value)) return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
99
|
+
return JSON.stringify(value) ?? "null";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function pageFileSemanticKey(parsed) {
|
|
103
|
+
const meta = {};
|
|
104
|
+
for (const key of PAGE_META_KEYS) meta[key] = typeof parsed[key] === "string" ? parsed[key] : null;
|
|
105
|
+
return stableStringify({ meta, data: parsed.data ?? null });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A legacy payload re-serialised as a v2 file: `format_version` first, explicit locale, unknown keys kept. */
|
|
109
|
+
export function serializeV2PageFile(parsed, locale) {
|
|
110
|
+
const out = {};
|
|
111
|
+
const source = { ...parsed, format_version: PAGE_FILE_FORMAT_VERSION, locale };
|
|
112
|
+
for (const key of V2_KEY_ORDER) if (key in source) out[key] = source[key];
|
|
113
|
+
for (const key of Object.keys(parsed)) if (!(key in out)) out[key] = parsed[key];
|
|
114
|
+
return JSON.stringify(out, null, 2) + "\n";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Local files checked for errors that only the local tree can have: duplicates between legacy and v2 files for
|
|
119
|
+
* the same (locale, slug). Files whose locale is unresolved offline are left to the server.
|
|
120
|
+
*/
|
|
121
|
+
export function checkLocalBatch(classified) {
|
|
122
|
+
const diagnostics = [];
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
for (const c of classified) {
|
|
125
|
+
if (!c.ok) {
|
|
126
|
+
diagnostics.push({ level: "error", code: c.code, message: c.message, path: c.path, ...(c.locale ? { locale: c.locale } : {}), ...(c.slug ? { slug: c.slug } : {}) });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c.layout === "legacy") {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
level: "warning",
|
|
132
|
+
code: "PAGES_LEGACY_LAYOUT",
|
|
133
|
+
message: c.locale ? `legacy layout (deprecated); canonical location is ${pageFilePath(c.locale, c.slug)}` : "legacy layout (deprecated); its language is decided by the site's default locale",
|
|
134
|
+
path: c.path,
|
|
135
|
+
...(c.locale ? { locale: c.locale } : {}),
|
|
136
|
+
slug: c.slug,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!c.locale) continue;
|
|
140
|
+
const key = `${c.locale} ${c.slug}`;
|
|
141
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
142
|
+
}
|
|
143
|
+
for (const group of groups.values()) {
|
|
144
|
+
if (group.length < 2) continue;
|
|
145
|
+
const { locale, slug } = group[0];
|
|
146
|
+
if (new Set(group.map((c) => pageFileSemanticKey(c.parsed))).size === 1) {
|
|
147
|
+
for (const c of group.slice(1)) diagnostics.push({ level: "warning", code: "PAGES_DUPLICATE_EQUIVALENT", message: `same content as ${group[0].path}`, path: c.path, locale, slug });
|
|
148
|
+
} else {
|
|
149
|
+
for (const c of group) {
|
|
150
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: `Two files resolve to locale "${locale}", slug "${slug}": ${group.map((g) => g.path).join(", ")}`, path: c.path, locale, slug });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return diagnostics;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── filesystem ────────────────────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/** Generic repository-relative path safety (pages and config). */
|
|
160
|
+
export function checkRelativePath(rel) {
|
|
161
|
+
if (typeof rel !== "string" || rel === "") return { code: "PAGES_INVALID_PATH", reason: "empty path" };
|
|
162
|
+
if (rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || isAbsolute(rel)) return { code: "PAGES_PATH_ESCAPE", reason: "absolute path" };
|
|
163
|
+
if (rel.includes("\\")) return { code: "PAGES_PATH_ESCAPE", reason: "backslash in path" };
|
|
164
|
+
const parts = rel.split("/");
|
|
165
|
+
if (parts.includes("..")) return { code: "PAGES_PATH_ESCAPE", reason: "'..' in path" };
|
|
166
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(rel)) return { code: "PAGES_INVALID_PATH", reason: "control character in path" };
|
|
167
|
+
if (parts.some((p) => p === "" || p === ".")) return { code: "PAGES_INVALID_PATH", reason: "empty or '.' path segment" };
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lstatOrNull(p) {
|
|
172
|
+
try {
|
|
173
|
+
return lstatSync(p);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error && error.code === "ENOENT") return null;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `root` (already realpath'd) + a relative path → the absolute target, refusing anything that would leave the
|
|
182
|
+
* root: traversal, absolute paths, backslashes, a symlink at any existing ancestor or at the target, and a
|
|
183
|
+
* target that exists but is not a regular file. Null error means safe.
|
|
184
|
+
*/
|
|
185
|
+
export function resolveContained(root, rel) {
|
|
186
|
+
const unsafe = checkRelativePath(rel);
|
|
187
|
+
if (unsafe) return { error: { ...unsafe, path: rel } };
|
|
188
|
+
const parts = rel.split("/");
|
|
189
|
+
const target = join(root, ...parts);
|
|
190
|
+
const back = relative(root, target);
|
|
191
|
+
if (back.startsWith("..") || isAbsolute(back)) return { error: { code: "PAGES_PATH_ESCAPE", reason: "path leaves the target directory", path: rel } };
|
|
192
|
+
let current = root;
|
|
193
|
+
for (let i = 0; i < parts.length; i++) {
|
|
194
|
+
current = join(current, parts[i]);
|
|
195
|
+
const st = lstatOrNull(current);
|
|
196
|
+
if (!st) break;
|
|
197
|
+
if (st.isSymbolicLink()) return { error: { code: "PAGES_PATH_ESCAPE", reason: `${parts.slice(0, i + 1).join("/")} is a symbolic link`, path: rel } };
|
|
198
|
+
const last = i === parts.length - 1;
|
|
199
|
+
if (!last && !st.isDirectory()) return { error: { code: "PAGES_INVALID_PATH", reason: `${parts.slice(0, i + 1).join("/")} is not a directory`, path: rel } };
|
|
200
|
+
if (last && !st.isFile()) return { error: { code: "PAGES_INVALID_PATH", reason: "target exists and is not a regular file", path: rel } };
|
|
201
|
+
}
|
|
202
|
+
return { target };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Every `pages/**.json` under `dir`, in lexical path order, WITHOUT following symlinks. Symlinks and non-regular
|
|
207
|
+
* files are findings, not files. Other extensions are ignored (a README next to the pages is fine).
|
|
208
|
+
*/
|
|
209
|
+
export function collectPageFiles(dir) {
|
|
210
|
+
const root = realpathSync(dir);
|
|
211
|
+
const files = [];
|
|
212
|
+
const errors = [];
|
|
213
|
+
const pagesDir = join(root, "pages");
|
|
214
|
+
const top = lstatOrNull(pagesDir);
|
|
215
|
+
if (!top) return { root, files, errors };
|
|
216
|
+
if (top.isSymbolicLink()) return { root, files, errors: [{ level: "error", code: "PAGES_PATH_ESCAPE", message: "pages/ is a symbolic link", path: "pages" }] };
|
|
217
|
+
if (!top.isDirectory()) return { root, files, errors: [{ level: "error", code: "PAGES_INVALID_PATH", message: "pages is not a directory", path: "pages" }] };
|
|
218
|
+
const walk = (abs, relParts) => {
|
|
219
|
+
const names = readdirSync(abs).sort();
|
|
220
|
+
for (const name of names) {
|
|
221
|
+
const childAbs = join(abs, name);
|
|
222
|
+
const childRel = ["pages", ...relParts, name].join("/");
|
|
223
|
+
const st = lstatSync(childAbs);
|
|
224
|
+
if (st.isSymbolicLink()) {
|
|
225
|
+
errors.push({ level: "error", code: "PAGES_PATH_ESCAPE", message: "symbolic links are not followed", path: childRel });
|
|
226
|
+
} else if (st.isDirectory()) {
|
|
227
|
+
walk(childAbs, [...relParts, name]);
|
|
228
|
+
} else if (!st.isFile()) {
|
|
229
|
+
if (name.endsWith(".json")) errors.push({ level: "error", code: "PAGES_INVALID_PATH", message: "not a regular file", path: childRel });
|
|
230
|
+
} else if (name.endsWith(".json")) {
|
|
231
|
+
files.push([childRel, readFileSync(childAbs, "utf8")]);
|
|
232
|
+
} else if (/\.json\s*$/i.test(name)) {
|
|
233
|
+
// `about.JSON` or `about.json ` would otherwise be skipped silently while the author believes it was pushed.
|
|
234
|
+
errors.push({ level: "error", code: "PAGES_INVALID_PATH", message: "page file names must end in lowercase .json", path: childRel });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(pagesDir, []);
|
|
239
|
+
files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
240
|
+
errors.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
241
|
+
return { root, files, errors };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A target that another target needs as a folder (`a/index.json` vs `a/index.json/index.json`) cannot coexist. */
|
|
245
|
+
export function folderConflicts(rels) {
|
|
246
|
+
const set = new Set(rels);
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const rel of rels) {
|
|
249
|
+
const parts = rel.split("/");
|
|
250
|
+
for (let i = 1; i < parts.length; i++) {
|
|
251
|
+
const prefix = parts.slice(0, i).join("/");
|
|
252
|
+
if (set.has(prefix)) out.push({ level: "error", code: "PAGES_INVALID_PATH", message: `${prefix} would have to be both a file and a folder`, path: rel });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Write `entries` ([rel, content]) under `root` in two phases. Every path is validated first; then all content is
|
|
260
|
+
* written to a staging directory inside `root`; only when every file is staged are they renamed into place.
|
|
261
|
+
* A failure before the rename phase leaves the destination exactly as it was.
|
|
262
|
+
*/
|
|
263
|
+
export function stagedWrite(root, entries, { writeFile = writeFileSync, rename = renameSync } = {}) {
|
|
264
|
+
const errors = folderConflicts(entries.map(([rel]) => rel));
|
|
265
|
+
const targets = [];
|
|
266
|
+
for (const [rel, content] of entries) {
|
|
267
|
+
const r = resolveContained(root, rel);
|
|
268
|
+
if (r.error) errors.push({ level: "error", code: r.error.code, message: r.error.reason, path: rel });
|
|
269
|
+
else targets.push([rel, r.target, content]);
|
|
270
|
+
}
|
|
271
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Refusing to write outside the target directory; nothing was written.", { diagnostics: errors });
|
|
272
|
+
|
|
273
|
+
const staging = mkdtempSync(join(root, ".blocofy-staging-"));
|
|
274
|
+
try {
|
|
275
|
+
targets.forEach(([rel, , content], i) => {
|
|
276
|
+
const staged = join(staging, String(i));
|
|
277
|
+
writeFile(staged, typeof content === "string" ? content : "");
|
|
278
|
+
});
|
|
279
|
+
} catch (error) {
|
|
280
|
+
rmSync(staging, { recursive: true, force: true });
|
|
281
|
+
throw new PagesCliError("PAGES_WRITE_FAILED", `Could not stage files (${error?.message ?? error}); nothing was written.`);
|
|
282
|
+
}
|
|
283
|
+
let written = 0;
|
|
284
|
+
try {
|
|
285
|
+
targets.forEach(([, target], i) => {
|
|
286
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
287
|
+
rename(join(staging, String(i)), target);
|
|
288
|
+
written += 1;
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new PagesCliError(
|
|
292
|
+
"PAGES_WRITE_FAILED",
|
|
293
|
+
`Stopped after writing ${written} of ${targets.length} files (${error?.message ?? error}); written: ${targets.slice(0, written).map(([rel]) => rel).join(", ") || "none"}.`,
|
|
294
|
+
);
|
|
295
|
+
} finally {
|
|
296
|
+
rmSync(staging, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
return written;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── output ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
export function formatDiagnostic(d) {
|
|
304
|
+
const where = d.path ? `\n ${d.path}` : d.slug ? `\n ${d.locale ? `locale "${d.locale}", ` : ""}slug "${d.slug}"` : "";
|
|
305
|
+
return `${d.level} [${d.code}]:\n ${d.message}${where}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export { PageFileError, pageFilePath };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PS-19 — page file layout, mirrored from the platform (Blocofy/blocofy `packages/cms/src/page-files/path-codec.ts`).
|
|
5
|
+
* The platform is the authority; this copy lets `pages check` and `pages migrate-layout` judge files offline.
|
|
6
|
+
* `test/fixtures/page-path-vectors.json` is byte-identical on both sides and is the drift oracle.
|
|
7
|
+
*
|
|
8
|
+
* pages/<locale>/index.json slug "/"
|
|
9
|
+
* pages/<locale>/routes/<seg>/…/index.json any other slug ("/index" included)
|
|
10
|
+
* pages/<locale>/hashed/<sha256 hex>/index.json a slug over the segment/depth/path limits
|
|
11
|
+
*
|
|
12
|
+
* Segments: safe bytes [a-z0-9-_.] stay readable; everything else (uppercase included) becomes `~hh` (lowercase
|
|
13
|
+
* hex per UTF-8 byte); `~` itself is `~7e`; a leading/trailing dot and the first byte of a Windows reserved name
|
|
14
|
+
* are escaped. Nothing is normalised: a non-canonical slug or locale is refused.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const PAGE_FILE_FORMAT_VERSION = 2;
|
|
18
|
+
export const PAGE_SEGMENT_MAX_BYTES = 100;
|
|
19
|
+
export const PAGE_ROUTE_DEPTH_MAX = 16;
|
|
20
|
+
export const PAGE_PATH_MAX_BYTES = 200;
|
|
21
|
+
export const PAGE_SLUG_MAX_BYTES = 2048;
|
|
22
|
+
|
|
23
|
+
export class PageFileError extends Error {
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "PageFileError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const utf8 = new TextEncoder();
|
|
32
|
+
const utf8Strict = new TextDecoder("utf-8", { fatal: true });
|
|
33
|
+
|
|
34
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
35
|
+
const CONTROL = /[\x00-\x1f\x7f-\x9f]/;
|
|
36
|
+
// COM0/LPT0 included: Microsoft lists them as reserved too.
|
|
37
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
|
38
|
+
const LOCALE_CODE_PATTERN = /^[A-Za-z]{2,3}(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?$/;
|
|
39
|
+
|
|
40
|
+
/** `en-us` → `en-US`; null for anything outside the platform's accepted shapes (mirror of locale-route-key.ts). */
|
|
41
|
+
export function canonicalizeLocaleCode(code) {
|
|
42
|
+
if (typeof code !== "string") return null;
|
|
43
|
+
const trimmed = code.trim();
|
|
44
|
+
if (!LOCALE_CODE_PATTERN.test(trimmed)) return null;
|
|
45
|
+
return trimmed
|
|
46
|
+
.split("-")
|
|
47
|
+
.map((part, index) => {
|
|
48
|
+
if (index === 0) return part.toLowerCase();
|
|
49
|
+
if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
|
|
50
|
+
return part.toUpperCase();
|
|
51
|
+
})
|
|
52
|
+
.join("-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkPageSlug(slug) {
|
|
56
|
+
if (typeof slug !== "string") return "slug must be a string";
|
|
57
|
+
if (slug === "/") return null;
|
|
58
|
+
if (!slug.startsWith("/")) return "slug must start with /";
|
|
59
|
+
if (slug.endsWith("/")) return "slug must not end with /";
|
|
60
|
+
if (LONE_SURROGATE.test(slug)) return "slug is not well-formed Unicode";
|
|
61
|
+
if (utf8.encode(slug).length > PAGE_SLUG_MAX_BYTES) return `slug is longer than ${PAGE_SLUG_MAX_BYTES} bytes`;
|
|
62
|
+
if (slug.normalize("NFC") !== slug) return "slug is not in Unicode NFC form";
|
|
63
|
+
if (CONTROL.test(slug)) return "slug contains a control character";
|
|
64
|
+
if (slug.includes("\\")) return "slug contains a backslash";
|
|
65
|
+
if (slug.includes("?") || slug.includes("#")) return "slug contains a query or fragment";
|
|
66
|
+
if (/%(2f|5c)/i.test(slug)) return "slug contains an encoded slash or backslash";
|
|
67
|
+
for (const segment of slug.slice(1).split("/")) {
|
|
68
|
+
if (segment === "") return "slug contains an empty segment";
|
|
69
|
+
const dotted = segment.replace(/%2e/gi, ".");
|
|
70
|
+
if (dotted === "." || dotted === "..") return "slug contains a . or .. segment";
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isSafeByte = (b) => (b >= 0x61 && b <= 0x7a) || (b >= 0x30 && b <= 0x39) || b === 0x2d || b === 0x5f || b === 0x2e;
|
|
76
|
+
|
|
77
|
+
export function encodeSegment(segment) {
|
|
78
|
+
const bytes = utf8.encode(segment);
|
|
79
|
+
// A segment spelled `index.json` would make a route's own file name (`…/<seg>/index.json`) the folder of a
|
|
80
|
+
// child route (`/blog` vs `/blog/index.json`), which no filesystem or git tree can hold. Escaping its first
|
|
81
|
+
// byte keeps the two apart.
|
|
82
|
+
const reserved = WINDOWS_RESERVED.test(segment.split(".")[0] ?? "") || segment === "index.json";
|
|
83
|
+
let out = "";
|
|
84
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
85
|
+
const b = bytes[i];
|
|
86
|
+
const edgeDot = b === 0x2e && (i === 0 || i === bytes.length - 1);
|
|
87
|
+
if (!isSafeByte(b) || edgeDot || (reserved && i === 0)) out += "~" + b.toString(16).padStart(2, "0");
|
|
88
|
+
else out += String.fromCharCode(b);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decodeSegment(encoded) {
|
|
94
|
+
const bytes = [];
|
|
95
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
96
|
+
const c = encoded.charCodeAt(i);
|
|
97
|
+
if (c === 0x7e) {
|
|
98
|
+
const pair = encoded.slice(i + 1, i + 3);
|
|
99
|
+
if (!/^[0-9a-f]{2}$/.test(pair)) return null;
|
|
100
|
+
bytes.push(parseInt(pair, 16));
|
|
101
|
+
i += 2;
|
|
102
|
+
} else if (isSafeByte(c)) {
|
|
103
|
+
bytes.push(c);
|
|
104
|
+
} else {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let decoded;
|
|
109
|
+
try {
|
|
110
|
+
decoded = utf8Strict.decode(new Uint8Array(bytes));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return encodeSegment(decoded) === encoded ? decoded : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function pageFilePath(locale, slug) {
|
|
118
|
+
if (typeof locale !== "string" || canonicalizeLocaleCode(locale) !== locale) {
|
|
119
|
+
throw new PageFileError("PAGES_INVALID_LOCALE", `locale ${JSON.stringify(locale)} is not a canonical locale code`);
|
|
120
|
+
}
|
|
121
|
+
const reason = checkPageSlug(slug);
|
|
122
|
+
if (reason) throw new PageFileError("PAGES_INVALID_SLUG", `${JSON.stringify(slug)}: ${reason}`);
|
|
123
|
+
if (slug === "/") return `pages/${locale}/index.json`;
|
|
124
|
+
const segments = slug.slice(1).split("/").map(encodeSegment);
|
|
125
|
+
const path = `pages/${locale}/routes/${segments.join("/")}/index.json`;
|
|
126
|
+
const tooLong =
|
|
127
|
+
segments.length > PAGE_ROUTE_DEPTH_MAX || segments.some((s) => s.length > PAGE_SEGMENT_MAX_BYTES) || path.length > PAGE_PATH_MAX_BYTES;
|
|
128
|
+
if (!tooLong) return path;
|
|
129
|
+
return `pages/${locale}/hashed/${createHash("sha256").update(utf8.encode(slug)).digest("hex")}/index.json`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The pre-PS-19 formula; only for recognising and migrating legacy files. */
|
|
133
|
+
export function legacyPageFilePath(slug) {
|
|
134
|
+
const clean = slug.replace(/^\/+|\/+$/g, "").trim();
|
|
135
|
+
return `pages/${clean || "index"}.json`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function checkRelativePagePath(path) {
|
|
139
|
+
if (typeof path !== "string" || path === "") return { code: "PAGES_INVALID_PATH", reason: "empty path" };
|
|
140
|
+
if (path.startsWith("/") || /^[A-Za-z]:/.test(path)) return { code: "PAGES_PATH_ESCAPE", reason: "absolute path" };
|
|
141
|
+
if (path.includes("\\")) return { code: "PAGES_PATH_ESCAPE", reason: "backslash in path" };
|
|
142
|
+
const parts = path.split("/");
|
|
143
|
+
if (parts.includes("..")) return { code: "PAGES_PATH_ESCAPE", reason: "'..' in path" };
|
|
144
|
+
if (CONTROL.test(path) || LONE_SURROGATE.test(path)) return { code: "PAGES_INVALID_PATH", reason: "control character in path" };
|
|
145
|
+
if (parts.some((p) => p === "" || p === ".")) return { code: "PAGES_INVALID_PATH", reason: "empty or '.' path segment" };
|
|
146
|
+
if (parts[0] !== "pages" || parts.length < 2) return { code: "PAGES_INVALID_PATH", reason: "path must be under pages/" };
|
|
147
|
+
if (!path.endsWith(".json")) return { code: "PAGES_INVALID_PATH", reason: "page files end in .json" };
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const invalid = (reason) => ({ kind: "invalid", code: "PAGES_INVALID_PATH", reason });
|
|
152
|
+
|
|
153
|
+
export function parsePageFilePath(path) {
|
|
154
|
+
const unsafe = checkRelativePagePath(path);
|
|
155
|
+
if (unsafe) return { kind: "invalid", ...unsafe };
|
|
156
|
+
const parts = path.split("/");
|
|
157
|
+
const folder = parts[1];
|
|
158
|
+
const rest = parts.slice(2);
|
|
159
|
+
const canonical = canonicalizeLocaleCode(folder);
|
|
160
|
+
if (canonical === null || rest.length === 0) return { kind: "other" };
|
|
161
|
+
const shaped = rest[0] === "index.json" || rest[0] === "routes" || rest[0] === "hashed";
|
|
162
|
+
if (!shaped) return { kind: "other" };
|
|
163
|
+
if (canonical !== folder) return invalid(`locale folder ${JSON.stringify(folder)} is not canonical (${canonical})`);
|
|
164
|
+
if (rest[0] === "index.json") {
|
|
165
|
+
return rest.length === 1 ? { kind: "canonical", locale: folder, slug: "/" } : invalid("unexpected path after index.json");
|
|
166
|
+
}
|
|
167
|
+
if (rest[rest.length - 1] !== "index.json") return invalid("canonical page files are named index.json");
|
|
168
|
+
if (rest[0] === "hashed") {
|
|
169
|
+
return rest.length === 3 && /^[0-9a-f]{64}$/.test(rest[1])
|
|
170
|
+
? { kind: "hashed", locale: folder, hash: rest[1] }
|
|
171
|
+
: invalid("hashed page paths are hashed/<sha256 hex>/index.json");
|
|
172
|
+
}
|
|
173
|
+
const encoded = rest.slice(1, -1);
|
|
174
|
+
if (encoded.length === 0) return invalid("routes/ needs at least one segment");
|
|
175
|
+
const decoded = [];
|
|
176
|
+
for (const segment of encoded) {
|
|
177
|
+
const value = decodeSegment(segment);
|
|
178
|
+
if (value === null) return invalid(`segment ${JSON.stringify(segment)} is not canonically encoded`);
|
|
179
|
+
decoded.push(value);
|
|
180
|
+
}
|
|
181
|
+
const slug = "/" + decoded.join("/");
|
|
182
|
+
if (checkPageSlug(slug) !== null) return invalid("path decodes to an invalid slug");
|
|
183
|
+
if (pageFilePath(folder, slug) !== path) return invalid("path is not the canonical spelling of its slug");
|
|
184
|
+
return { kind: "canonical", locale: folder, slug };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isCanonicalLayoutPath(path) {
|
|
188
|
+
if (checkRelativePagePath(path)) return false;
|
|
189
|
+
const parts = path.split("/");
|
|
190
|
+
const rest = parts.slice(2);
|
|
191
|
+
if (canonicalizeLocaleCode(parts[1] ?? "") === null || rest.length === 0) return false;
|
|
192
|
+
return rest[0] === "index.json" || rest[0] === "routes" || rest[0] === "hashed";
|
|
193
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hidden (non-echoing) secret prompt for `blocofy login --api-key` (0.8.0, D3 §4.7).
|
|
3
|
+
*
|
|
4
|
+
* Reads one line from a TTY in raw mode so the key never echoes to the terminal (and so it is
|
|
5
|
+
* never in argv or shell history — the flag takes no value). On a non-TTY stdin it resolves
|
|
6
|
+
* `null` WITHOUT reading anything: the caller must then use BLOCOFY_API_KEY or fail closed.
|
|
7
|
+
* Backspace edits, Enter finishes, Ctrl-C / Ctrl-D (empty) cancel with `null`. Raw mode is
|
|
8
|
+
* always restored.
|
|
9
|
+
*/
|
|
10
|
+
export function promptSecret(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
11
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") return Promise.resolve(null);
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let buffer = "";
|
|
14
|
+
const finish = (value) => {
|
|
15
|
+
input.removeListener("data", onData);
|
|
16
|
+
input.setRawMode(false);
|
|
17
|
+
input.pause();
|
|
18
|
+
output.write("\n");
|
|
19
|
+
resolve(value);
|
|
20
|
+
};
|
|
21
|
+
const onData = (chunk) => {
|
|
22
|
+
for (const ch of String(chunk)) {
|
|
23
|
+
if (ch === "\r" || ch === "\n") return finish(buffer);
|
|
24
|
+
if (ch === "") return finish(null); // Ctrl-C
|
|
25
|
+
if (ch === "") return finish(buffer.length ? buffer : null); // Ctrl-D
|
|
26
|
+
if (ch === "" || ch === "\b") {
|
|
27
|
+
buffer = buffer.slice(0, -1);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
buffer += ch;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
output.write(question);
|
|
34
|
+
input.setRawMode(true);
|
|
35
|
+
input.setEncoding("utf8");
|
|
36
|
+
input.resume();
|
|
37
|
+
input.on("data", onData);
|
|
38
|
+
});
|
|
39
|
+
}
|