@blocofy/cli 0.8.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 +26 -1
- package/bin/blocofy.mjs +145 -23
- package/lib/args.mjs +1 -1
- package/lib/content-sync.mjs +272 -44
- package/lib/page-files.mjs +308 -0
- package/lib/page-path-codec.mjs +193 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,12 +46,37 @@ blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
|
46
46
|
# items without idempotency_key get a random UUID. Exit 0 applied
|
|
47
47
|
# (or "No changes" when already recorded), 1 usage/auth/network/5xx
|
|
48
48
|
# (no retry), 2 server refusal (4xx) — {error} JSON on stderr.
|
|
49
|
+
blocofy pages pull [dir] [--strict]
|
|
50
|
+
# Download published pages, one folder per language:
|
|
51
|
+
# pages/<locale>/index.json and pages/<locale>/routes/<path>/index.json.
|
|
52
|
+
# Old-layout files are reported, never deleted or overwritten. If the site
|
|
53
|
+
# cannot export every page, nothing is written (PAGES_EXPORT_INCOMPLETE).
|
|
54
|
+
blocofy pages push [dir] [--dry-run] [--strict]
|
|
55
|
+
# Update EXISTING pages only. Every file is checked first; any invalid file,
|
|
56
|
+
# duplicate target or folder/locale mismatch changes NO page. An error after
|
|
57
|
+
# that check may leave some pages applied: the per-file result is printed,
|
|
58
|
+
# exit 1, and re-running is safe. --dry-run checks on the server, writes nothing.
|
|
59
|
+
blocofy pages check [dir] [--strict]
|
|
60
|
+
# Offline file/path/layout checks; with login also the server dry run.
|
|
61
|
+
blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
|
|
62
|
+
# Move old-layout files (pages/<slug>.json) to language folders. Any
|
|
63
|
+
# ambiguity or conflict moves nothing (exit 1).
|
|
49
64
|
blocofy --version
|
|
50
65
|
blocofy --help
|
|
51
66
|
```
|
|
52
67
|
|
|
53
68
|
## Changelog
|
|
54
69
|
|
|
70
|
+
- **0.9.0** — Locale-aware page files for multilingual sites (needs a platform with page file protocol 2;
|
|
71
|
+
against an older server page commands stop with `PAGES_SERVER_UPGRADE_REQUIRED` and write nothing).
|
|
72
|
+
`pages pull` writes `pages/<locale>/index.json` and `pages/<locale>/routes/<path>/index.json`, so the same
|
|
73
|
+
URL in two languages no longer shares one file. `pages push` preflights the whole directory and the server
|
|
74
|
+
refuses the batch before any write on an invalid file, duplicate target or folder/locale mismatch; a
|
|
75
|
+
failure after preflight prints the per-file result. New `pages check`, `pages push --dry-run` and
|
|
76
|
+
`pages migrate-layout [--dry-run | --write]`. Pull validates every server path, never follows symlinks,
|
|
77
|
+
never writes outside the target directory and stages its writes (0.8.0 could write a server-supplied
|
|
78
|
+
`../` path outside it). An incomplete export prints every reason with `PAGES_EXPORT_INCOMPLETE` and writes
|
|
79
|
+
nothing. Limits: 500 page files, 2 MiB per file, 4 MiB total.
|
|
55
80
|
- **0.8.0** — Page media decisions over the public v1 API: `pages media-uses <page-handle>` lists a
|
|
56
81
|
page's localized-media decisions on its newest draft, and `pages media-decide <page-handle>
|
|
57
82
|
--decisions <file.json>` applies a batch of decisions to that draft atomically (all or nothing;
|
|
@@ -140,7 +165,7 @@ A theme is a directory of files grouped by top-level folder:
|
|
|
140
165
|
| `block/` | Repeatable pieces used inside sections |
|
|
141
166
|
| `partial/` | Shared snippets (header/footer, …) |
|
|
142
167
|
| `asset/` | CSS and static files (`theme.css`) |
|
|
143
|
-
| `pages/<
|
|
168
|
+
| `pages/<locale>/index.json`, `pages/<locale>/routes/<path>/index.json` | Page content, one folder per language (`blocofy pages pull/push/check/migrate-layout`). The old `pages/<slug>.json` layout is still read, with a warning |
|
|
144
169
|
| `config/settings.json` | Theme settings + color schemes |
|
|
145
170
|
| `config/settings_schema.json` | Theme settings panel schema (synced with `pull`/`push`) |
|
|
146
171
|
|
package/bin/blocofy.mjs
CHANGED
|
@@ -15,7 +15,8 @@ import { resolve } from "node:path";
|
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
16
16
|
|
|
17
17
|
import { parseArgs } from "../lib/args.mjs";
|
|
18
|
-
import { pullContent, pushContent } from "../lib/content-sync.mjs";
|
|
18
|
+
import { checkPages, migrateLayout, pullContent, pushContent } from "../lib/content-sync.mjs";
|
|
19
|
+
import { PagesCliError, formatDiagnostic } from "../lib/page-files.mjs";
|
|
19
20
|
import { credentialsPath, loadApiCredentials, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
|
|
20
21
|
import { startDevServer } from "../lib/dev-server.mjs";
|
|
21
22
|
import { readLocalTemplates } from "../lib/local-theme.mjs";
|
|
@@ -45,6 +46,10 @@ const KNOWN = {
|
|
|
45
46
|
themePublish: ["instance"],
|
|
46
47
|
themeRename: ["name"],
|
|
47
48
|
content: [],
|
|
49
|
+
pagesPull: ["strict"],
|
|
50
|
+
pagesPush: ["dry-run", "strict"],
|
|
51
|
+
pagesCheck: ["strict"],
|
|
52
|
+
pagesMigrate: ["dry-run", "write", "strict"],
|
|
48
53
|
};
|
|
49
54
|
function parseArgsOrExit(rest, known) {
|
|
50
55
|
const parsed = parseArgs(rest, new Set([...known, "help", "version"]));
|
|
@@ -125,10 +130,32 @@ Usage
|
|
|
125
130
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
126
131
|
(ok / live_instance_empty / pages_split). Run before/after publishing.
|
|
127
132
|
|
|
128
|
-
blocofy pages pull [dir]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
blocofy pages pull [dir] [--strict]
|
|
134
|
+
Download published pages, one folder per language:
|
|
135
|
+
pages/<locale>/index.json the home page ("/")
|
|
136
|
+
pages/<locale>/routes/<path>/index.json every other page ("/about" → routes/about/index.json)
|
|
137
|
+
Files from the old layout (pages/<slug>.json) are reported, never deleted or overwritten.
|
|
138
|
+
If the site cannot export every published page, nothing is written, every reason is
|
|
139
|
+
printed (PAGES_EXPORT_INCOMPLETE) and the exit code is 1 (--strict: warnings also exit 1).
|
|
140
|
+
|
|
141
|
+
blocofy pages push [dir] [--dry-run] [--strict]
|
|
142
|
+
Write pages/**.json to the site. Updates EXISTING pages only — never creates or
|
|
143
|
+
deletes a page; unchanged pages are skipped. Every file is checked first: if any
|
|
144
|
+
file is invalid, two files point at the same page, or a folder's language does not
|
|
145
|
+
match the file's "locale", NO page is changed. If publishing then stops unexpectedly
|
|
146
|
+
(PAGES_APPLY_FAILED or another publish error), some pages may already be applied: the per-file
|
|
147
|
+
result printed is authoritative, the exit code is 1, and running the push again is safe.
|
|
148
|
+
--dry-run: check on the server, write nothing.
|
|
149
|
+
Needs a platform that supports language folders (else PAGES_SERVER_UPGRADE_REQUIRED).
|
|
150
|
+
|
|
151
|
+
blocofy pages check [dir] [--strict]
|
|
152
|
+
Check page files. Offline: paths, JSON, layout, duplicates. Logged in: also the
|
|
153
|
+
site's languages and the server-side dry run. Exit 1 on errors (--strict: warnings too).
|
|
154
|
+
|
|
155
|
+
blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
|
|
156
|
+
Move old-layout files (pages/<slug>.json) to language folders. --dry-run (default)
|
|
157
|
+
prints the plan; --write moves only proven files. Any ambiguity or conflict: nothing
|
|
158
|
+
is moved, exit 1. Files without "locale" use the site's default language (login needed).
|
|
132
159
|
|
|
133
160
|
blocofy pages media-uses <page-handle> [--json]
|
|
134
161
|
List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
|
|
@@ -534,26 +561,123 @@ async function themePush(rest) {
|
|
|
534
561
|
}
|
|
535
562
|
}
|
|
536
563
|
|
|
537
|
-
|
|
538
|
-
|
|
564
|
+
/** PS-19 — print platform/CLI findings; returns the exit code they imply. */
|
|
565
|
+
function reportPageDiagnostics(diagnostics, { strict = false } = {}) {
|
|
566
|
+
for (const d of diagnostics) (d.level === "error" ? console.error : console.warn)(formatDiagnostic(d));
|
|
567
|
+
const errors = diagnostics.filter((d) => d.level === "error").length;
|
|
568
|
+
const warnings = diagnostics.length - errors;
|
|
569
|
+
return errors > 0 || (strict && warnings > 0) ? 1 : 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function exitForPagesError(error) {
|
|
573
|
+
if (error instanceof PagesCliError) {
|
|
574
|
+
if (error.diagnostics?.length) reportPageDiagnostics(error.diagnostics);
|
|
575
|
+
else console.error(`error [${error.code}]:\n ${error.message}`);
|
|
576
|
+
// The stable top code always prints, after the individual findings it summarises.
|
|
577
|
+
if (error.diagnostics?.length) console.error(`\nerror [${error.code}]:\n ${error.message}`);
|
|
578
|
+
if (Array.isArray(error.pages) && error.pages.length) {
|
|
579
|
+
console.error("Per-file result:");
|
|
580
|
+
for (const p of error.pages) console.error(` ${p.outcome ?? p.action} ${p.path}`);
|
|
581
|
+
}
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
console.error(error?.message ?? error);
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function localeLabel(p) {
|
|
589
|
+
return `${p.locale} ${p.slug}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function pagesPush(rest) {
|
|
593
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPush);
|
|
539
594
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
540
595
|
if (!existsSync(dir)) {
|
|
541
596
|
console.error(`Directory not found: ${dir}`);
|
|
542
597
|
process.exit(1);
|
|
543
598
|
}
|
|
544
599
|
const creds = requireCreds();
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
600
|
+
const dryRun = Boolean(flags["dry-run"]);
|
|
601
|
+
const result = await pushContent({ dir, url: creds.url, token: creds.token, scope: "pages", dryRun });
|
|
602
|
+
const code = reportPageDiagnostics(result.diagnostics ?? [], { strict: Boolean(flags.strict) });
|
|
603
|
+
const pages = result.pages ?? [];
|
|
604
|
+
const count = (k, v) => pages.filter((p) => p[k] === v).length;
|
|
605
|
+
const warnings = (result.diagnostics ?? []).filter((d) => d.level === "warning").length;
|
|
606
|
+
if (dryRun) {
|
|
607
|
+
console.log(`Preflight passed: ${count("action", "publish")} updates, ${count("action", "draft")} drafts, ${count("action", "unchanged")} unchanged, 0 conflicts, ${warnings} warning(s).`);
|
|
608
|
+
console.log("Dry run only; no pages were changed.");
|
|
551
609
|
} else {
|
|
610
|
+
for (const p of pages) if (p.outcome !== "unchanged") console.log(` ${p.outcome} ${localeLabel(p)} (${p.path})`);
|
|
552
611
|
console.log(
|
|
553
|
-
`Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped ` +
|
|
612
|
+
`Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped, ${warnings} warning(s) ` +
|
|
554
613
|
`(only existing pages are updated — none created or deleted).`,
|
|
555
614
|
);
|
|
556
615
|
}
|
|
616
|
+
process.exit(code);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function pagesPull(rest) {
|
|
620
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPull);
|
|
621
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
622
|
+
const creds = requireCreds();
|
|
623
|
+
const { count, diagnostics } = await pullContent({ dir, url: creds.url, token: creds.token, scope: "pages" });
|
|
624
|
+
const code = reportPageDiagnostics(diagnostics, { strict: Boolean(flags.strict) });
|
|
625
|
+
console.log(`Downloaded ${count} page file(s) → ${dir}`);
|
|
626
|
+
process.exit(code);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function pagesCheck(rest) {
|
|
630
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesCheck);
|
|
631
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
632
|
+
if (!existsSync(dir)) {
|
|
633
|
+
console.error(`Directory not found: ${dir}`);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
const creds = loadCredentials();
|
|
637
|
+
const online = Boolean(creds?.url && creds?.token);
|
|
638
|
+
const r = await checkPages({ dir, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
639
|
+
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
640
|
+
const errors = r.diagnostics.filter((d) => d.level === "error").length;
|
|
641
|
+
console.log(`Checked ${r.fileCount} page file(s) ${r.online ? "(with the site's languages and a server dry run)" : "(offline — log in to also check against the site)"}: ${errors} error(s), ${r.diagnostics.length - errors} warning(s).`);
|
|
642
|
+
process.exit(code);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function pagesMigrate(rest) {
|
|
646
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesMigrate);
|
|
647
|
+
if (flags["dry-run"] && flags.write) {
|
|
648
|
+
console.error("Use either --dry-run or --write, not both. Nothing was moved.");
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
652
|
+
if (!existsSync(dir)) {
|
|
653
|
+
console.error(`Directory not found: ${dir}`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
const creds = loadCredentials();
|
|
657
|
+
const online = Boolean(creds?.url && creds?.token);
|
|
658
|
+
const write = Boolean(flags.write);
|
|
659
|
+
const r = await migrateLayout({ dir, write, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
660
|
+
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
661
|
+
for (const m of r.moves) console.log(` ${write && !r.refused ? "moved" : "move"} ${m.from} → ${m.to}`);
|
|
662
|
+
if (r.refused) console.error(`Migration refused; no files were moved.`);
|
|
663
|
+
else if (write) console.log(`Moved ${r.moved} file(s) to the language-folder layout.`);
|
|
664
|
+
else console.log(`${r.moves.length} file(s) would move. Dry run only; run with --write to move them.`);
|
|
665
|
+
process.exit(r.refused ? 1 : code);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function contentPush(scope, rest) {
|
|
669
|
+
const { positionals } = parseArgsOrExit(rest, KNOWN.content);
|
|
670
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
671
|
+
if (!existsSync(dir)) {
|
|
672
|
+
console.error(`Directory not found: ${dir}`);
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
const creds = requireCreds();
|
|
676
|
+
const result = await pushContent({ dir, url: creds.url, token: creds.token, scope });
|
|
677
|
+
console.log(
|
|
678
|
+
`Settings push: ${result.settingsUpdated ? "theme settings updated" : "theme settings unchanged"}, ` +
|
|
679
|
+
`${result.schemesUpserted} color scheme(s) upserted (${result.fileCount} file).`,
|
|
680
|
+
);
|
|
557
681
|
}
|
|
558
682
|
|
|
559
683
|
async function contentPull(scope, rest) {
|
|
@@ -561,7 +685,7 @@ async function contentPull(scope, rest) {
|
|
|
561
685
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
562
686
|
const creds = requireCreds();
|
|
563
687
|
const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
|
|
564
|
-
console.log(`Downloaded ${count}
|
|
688
|
+
console.log(`Downloaded ${count} settings file(s) → ${dir}`);
|
|
565
689
|
}
|
|
566
690
|
|
|
567
691
|
/** Resolve the v1 API pair (blcf_live_ key only) or exit 1 with guidance. Never prints the key. */
|
|
@@ -967,15 +1091,13 @@ if (first === "--version" || first === "-v") {
|
|
|
967
1091
|
} else if (first === "pages" && rest[0] === "media-decide") {
|
|
968
1092
|
pagesMediaDecide(rest.slice(1)).catch(exitForApiError);
|
|
969
1093
|
} else if (first === "pages" && rest[0] === "pull") {
|
|
970
|
-
|
|
971
|
-
console.error(error?.message ?? error);
|
|
972
|
-
process.exit(1);
|
|
973
|
-
});
|
|
1094
|
+
pagesPull(rest.slice(1)).catch(exitForPagesError);
|
|
974
1095
|
} else if (first === "pages" && rest[0] === "push") {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1096
|
+
pagesPush(rest.slice(1)).catch(exitForPagesError);
|
|
1097
|
+
} else if (first === "pages" && rest[0] === "check") {
|
|
1098
|
+
pagesCheck(rest.slice(1)).catch(exitForPagesError);
|
|
1099
|
+
} else if (first === "pages" && rest[0] === "migrate-layout") {
|
|
1100
|
+
pagesMigrate(rest.slice(1)).catch(exitForPagesError);
|
|
979
1101
|
} else if (first === "settings" && rest[0] === "pull") {
|
|
980
1102
|
contentPull("settings", rest.slice(1)).catch((error) => {
|
|
981
1103
|
console.error(error?.message ?? error);
|
package/lib/args.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
|
|
9
9
|
* gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
|
|
10
10
|
*/
|
|
11
|
-
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json"]);
|
|
11
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write"]);
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|
package/lib/content-sync.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
2
|
-
import { dirname, join
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PagesCliError,
|
|
6
|
+
checkLocalBatch,
|
|
7
|
+
folderConflicts,
|
|
8
|
+
classifyPageFile,
|
|
9
|
+
collectPageFiles,
|
|
10
|
+
pageFileSemanticKey,
|
|
11
|
+
pageFilePath,
|
|
12
|
+
resolveContained,
|
|
13
|
+
serializeV2PageFile,
|
|
14
|
+
stagedWrite,
|
|
15
|
+
} from "./page-files.mjs";
|
|
16
|
+
import { checkRelativePagePath, parsePageFilePath } from "./page-path-codec.mjs";
|
|
3
17
|
|
|
4
18
|
/**
|
|
5
|
-
* `blocofy pages pull/push` + `settings pull/push` (#119).
|
|
6
|
-
*
|
|
7
|
-
* `/api/dev/content`
|
|
8
|
-
*
|
|
9
|
-
* günceller, yeni oluşturmaz, silmez.
|
|
19
|
+
* `blocofy pages pull/push/check/migrate-layout` + `settings pull/push` (#119, PS-19). Page files use the
|
|
20
|
+
* locale-aware layout (`pages/<locale>/index.json`, `pages/<locale>/routes/…/index.json`); the platform's
|
|
21
|
+
* `/api/dev/content` protocol v2 is required for every page operation. Tema KODU `theme pull/push`'a gider.
|
|
22
|
+
* Güvenlik platformda: push yalnız mevcut sayfayı günceller, yeni oluşturmaz, silmez.
|
|
10
23
|
*/
|
|
11
24
|
|
|
25
|
+
export const PAGES_PROTOCOL_VERSION = 2;
|
|
26
|
+
|
|
12
27
|
async function errorText(res) {
|
|
13
28
|
const text = await res.text();
|
|
14
29
|
try {
|
|
@@ -18,7 +33,9 @@ async function errorText(res) {
|
|
|
18
33
|
}
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
const baseOf = (url) => url.replace(/\/+$/, "");
|
|
37
|
+
|
|
38
|
+
/** scope "pages" → pages/**.json (lexical order, no symlinks); "settings" → config/settings.json. `{path: content}`. */
|
|
22
39
|
export function readContentFiles(dir, scope) {
|
|
23
40
|
const out = {};
|
|
24
41
|
if (scope === "settings") {
|
|
@@ -26,52 +43,263 @@ export function readContentFiles(dir, scope) {
|
|
|
26
43
|
if (existsSync(p)) out["config/settings.json"] = readFileSync(p, "utf8");
|
|
27
44
|
return out;
|
|
28
45
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const { files, errors } = collectPageFiles(dir);
|
|
47
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
48
|
+
for (const [path, content] of files) out[path] = content;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The server's page capabilities, or `PAGES_SERVER_UPGRADE_REQUIRED`. An old server answers `scope=capabilities`
|
|
54
|
+
* as a full export without `protocol_version`; nothing is written either way.
|
|
55
|
+
*/
|
|
56
|
+
export async function probePagesServer({ url, token }) {
|
|
57
|
+
const res = await fetch(`${baseOf(url)}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } });
|
|
58
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
59
|
+
const body = await res.json().catch(() => null);
|
|
60
|
+
if (body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
61
|
+
throw new PagesCliError(
|
|
62
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
63
|
+
"This site's server does not support locale-aware page files yet. Nothing was changed; update the platform or use an older CLI.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { defaultLocale: body.default_locale, supportedLocales: body.supported_locales ?? [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Site içeriğini diske çek (pull). Pages require protocol v2; every path is contained and staged. */
|
|
70
|
+
export async function pullContent({ dir, url, token, scope, writeFile }) {
|
|
71
|
+
const res = await fetch(`${baseOf(url)}/api/dev/content?scope=${encodeURIComponent(scope)}`, {
|
|
72
|
+
headers: { authorization: `Bearer ${token}` },
|
|
73
|
+
});
|
|
74
|
+
const body = await res.json().catch(() => null);
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
// Nothing is written on a non-2xx: an incomplete export (PAGES_EXPORT_INCOMPLETE) carries every finding and no files.
|
|
77
|
+
throw new PagesCliError(body?.code ?? "PAGES_PULL_FAILED", body?.error ?? `Pull failed (HTTP ${res.status}); nothing was written.`, {
|
|
78
|
+
status: res.status,
|
|
79
|
+
diagnostics: Array.isArray(body?.diagnostics) ? body.diagnostics : [],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const files = body?.files ?? {};
|
|
83
|
+
const wantsPages = scope !== "settings";
|
|
84
|
+
if (wantsPages && body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
85
|
+
throw new PagesCliError(
|
|
86
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
87
|
+
"This site's server still uses the old page layout, which cannot keep languages apart. Nothing was written.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const diagnostics = [...(body?.diagnostics ?? [])];
|
|
92
|
+
const errors = [];
|
|
93
|
+
const entries = [];
|
|
94
|
+
for (const path of Object.keys(files).sort()) {
|
|
95
|
+
const content = files[path];
|
|
96
|
+
if (path === "config/settings.json" && scope !== "pages") {
|
|
97
|
+
entries.push([path, content]);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const unsafe = checkRelativePagePath(path);
|
|
101
|
+
const parsed = unsafe ? null : parsePageFilePath(path);
|
|
102
|
+
if (unsafe || !parsed || (parsed.kind !== "canonical" && parsed.kind !== "hashed") || !wantsPages) {
|
|
103
|
+
errors.push({ level: "error", code: unsafe?.code ?? "PAGES_INVALID_PATH", message: `server sent an unexpected path (${unsafe?.reason ?? "not a canonical page path"})`, path });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const c = classifyPageFile(path, typeof content === "string" ? content : "", {});
|
|
107
|
+
if (!c.ok || c.layout !== "v2") {
|
|
108
|
+
errors.push({ level: "error", code: c.ok ? "PAGES_INVALID_PATH" : c.code, message: `server sent a page file that does not match its path (${c.ok ? "legacy payload" : c.message})`, path });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
entries.push([path, content]);
|
|
112
|
+
}
|
|
113
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "The server response failed validation; nothing was written.", { diagnostics: errors });
|
|
114
|
+
|
|
115
|
+
// Local files this pull does not own: reported, never deleted or overwritten.
|
|
116
|
+
if (wantsPages && existsSync(dir)) {
|
|
117
|
+
const local = collectPageFiles(dir);
|
|
118
|
+
for (const e of local.errors) diagnostics.push({ ...e, level: "warning" });
|
|
119
|
+
const remote = new Set(Object.keys(files));
|
|
120
|
+
for (const [path, content] of local.files) {
|
|
121
|
+
if (remote.has(path)) continue;
|
|
122
|
+
const c = classifyPageFile(path, content, { defaultLocale: body.default_locale });
|
|
123
|
+
if (c.ok && c.layout === "legacy") {
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
level: "warning",
|
|
126
|
+
code: "PAGES_STALE_LEGACY_FILE",
|
|
127
|
+
message: `${path} was not written by this pull. Canonical replacement: ${pageFilePath(c.locale, c.slug)}`,
|
|
128
|
+
path,
|
|
129
|
+
locale: c.locale,
|
|
130
|
+
slug: c.slug,
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
diagnostics.push({ level: "warning", code: "PAGES_STALE_FILE", message: `${path} is not on the site and was not written by this pull.`, path });
|
|
38
134
|
}
|
|
39
|
-
if (!name.endsWith(".json")) continue;
|
|
40
|
-
const key = "pages/" + relative(pagesDir, full).split(sep).join("/");
|
|
41
|
-
out[key] = readFileSync(full, "utf8");
|
|
42
135
|
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const count = stagedWrite(fsRoot(dir), entries, writeFile ? { writeFile } : {});
|
|
139
|
+
return { count, diagnostics, defaultLocale: body?.default_locale ?? null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fsRoot(dir) {
|
|
143
|
+
mkdirSync(dir, { recursive: true });
|
|
144
|
+
return collectPageFiles(dir).root;
|
|
46
145
|
}
|
|
47
146
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Push. `settings` keeps the v1 body. `pages`: server capability probe → local preflight (nothing is sent on a
|
|
149
|
+
* local error) → `POST {protocol_version: 2, dry_run, files}`. The server preflights the whole batch again and
|
|
150
|
+
* writes nothing unless every file passes.
|
|
151
|
+
*/
|
|
152
|
+
export async function pushContent({ dir, url, token, scope, dryRun = false }) {
|
|
153
|
+
const base = baseOf(url);
|
|
154
|
+
if (scope === "settings") {
|
|
155
|
+
const files = readContentFiles(dir, scope);
|
|
156
|
+
const res = await fetch(`${base}/api/dev/content`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
159
|
+
body: JSON.stringify({ files }),
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
162
|
+
return { ...(await res.json()), fileCount: Object.keys(files).length };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const server = await probePagesServer({ url, token });
|
|
166
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
167
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
168
|
+
const localDiagnostics = checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server)));
|
|
169
|
+
const localErrors = localDiagnostics.filter((d) => d.level === "error");
|
|
170
|
+
if (localErrors.length > 0) {
|
|
171
|
+
throw new PagesCliError(localErrors[0].code, "Preflight failed locally; no pages were sent or changed.", { diagnostics: localDiagnostics });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const files = {};
|
|
175
|
+
for (const [path, content] of local) files[path] = content;
|
|
52
176
|
const res = await fetch(`${base}/api/dev/content`, {
|
|
53
177
|
method: "POST",
|
|
54
178
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
55
|
-
body: JSON.stringify({ files }),
|
|
179
|
+
body: JSON.stringify({ protocol_version: PAGES_PROTOCOL_VERSION, dry_run: dryRun, files }),
|
|
56
180
|
});
|
|
57
|
-
|
|
58
|
-
|
|
181
|
+
const body = await res.json().catch(() => null);
|
|
182
|
+
if (!res.ok || body?.ok !== true) {
|
|
183
|
+
throw new PagesCliError(body?.code ?? "PAGES_PUSH_FAILED", body?.error ?? `Push failed (HTTP ${res.status}).`, {
|
|
184
|
+
diagnostics: body?.diagnostics ?? [],
|
|
185
|
+
pages: body?.pages ?? null,
|
|
186
|
+
status: res.status,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return { ...body, fileCount: local.length };
|
|
59
190
|
}
|
|
60
191
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
192
|
+
/**
|
|
193
|
+
* `pages check`. Offline: path, JSON, format, layout and local duplicates. With `server` credentials it also
|
|
194
|
+
* resolves the default/supported locales and asks the server for a dry-run preflight (targets, documents).
|
|
195
|
+
*/
|
|
196
|
+
export async function checkPages({ dir, url, token }) {
|
|
197
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
198
|
+
const diagnostics = [...errors];
|
|
199
|
+
let server = null;
|
|
200
|
+
if (url && token) server = await probePagesServer({ url, token });
|
|
201
|
+
diagnostics.push(...checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server ?? {}))));
|
|
202
|
+
|
|
203
|
+
let plan = null;
|
|
204
|
+
if (server && !diagnostics.some((d) => d.level === "error")) {
|
|
205
|
+
try {
|
|
206
|
+
plan = await pushContent({ dir, url, token, scope: "pages", dryRun: true });
|
|
207
|
+
for (const d of plan.diagnostics ?? []) {
|
|
208
|
+
if (!diagnostics.some((x) => x.code === d.code && x.path === d.path)) diagnostics.push(d);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof PagesCliError)) throw error;
|
|
212
|
+
diagnostics.push(...(error.diagnostics.length ? error.diagnostics : [{ level: "error", code: error.code, message: error.message }]));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { fileCount: local.length, diagnostics, plan, online: Boolean(server) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `pages migrate-layout`. Plans a move for every legacy file whose (locale, slug) is proven, and refuses the
|
|
220
|
+
* whole migration — zero moves — on any error or ambiguity. `write: true` rewrites each file as v2 at its
|
|
221
|
+
* canonical path (contained, staged per file, verified) and only then removes the legacy file.
|
|
222
|
+
*/
|
|
223
|
+
export async function migrateLayout({ dir, url, token, write = false }) {
|
|
224
|
+
const { root, files: local, errors } = collectPageFiles(dir);
|
|
225
|
+
const diagnostics = [...errors];
|
|
226
|
+
const needsDefault = local.some(([, c]) => {
|
|
227
|
+
try {
|
|
228
|
+
const j = JSON.parse(c);
|
|
229
|
+
return j && typeof j === "object" && !("format_version" in j) && !("locale" in j);
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
66
233
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
234
|
+
let server = null;
|
|
235
|
+
if (url && token) server = await probePagesServer({ url, token });
|
|
236
|
+
|
|
237
|
+
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
238
|
+
diagnostics.push(...checkLocalBatch(classified).filter((d) => d.code !== "PAGES_LEGACY_LAYOUT"));
|
|
239
|
+
if (needsDefault && !server) {
|
|
240
|
+
for (const c of classified) {
|
|
241
|
+
if (c.ok && c.localeSource === "unresolved") {
|
|
242
|
+
diagnostics.push({ level: "error", code: "PAGES_AMBIGUOUS_LAYOUT", message: "file has no locale and the site's default language is unknown offline; log in (blocofy login) or add \"locale\" to the file", path: c.path, slug: c.slug });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const byPath = new Map(classified.filter((c) => c.ok).map((c) => [c.path, c]));
|
|
248
|
+
const moves = [];
|
|
249
|
+
const claimed = new Map();
|
|
250
|
+
for (const c of classified) {
|
|
251
|
+
if (!c.ok || c.layout !== "legacy" || !c.locale) continue;
|
|
252
|
+
const to = pageFilePath(c.locale, c.slug);
|
|
253
|
+
const existing = byPath.get(to);
|
|
254
|
+
if (existing) {
|
|
255
|
+
if (pageFileSemanticKey(existing.parsed) === pageFileSemanticKey(c.parsed)) {
|
|
256
|
+
diagnostics.push({ level: "warning", code: "PAGES_DUPLICATE_EQUIVALENT", message: `${to} already holds the same content; ${c.path} was left in place (remove it yourself)`, path: c.path, locale: c.locale, slug: c.slug });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// checkLocalBatch has already reported the conflicting pair as PAGES_DUPLICATE_TARGET.
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (claimed.has(to)) {
|
|
263
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: `${c.path} and ${claimed.get(to)} both move to ${to}`, path: c.path, locale: c.locale, slug: c.slug });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
claimed.set(to, c.path);
|
|
267
|
+
moves.push({ from: c.path, to, locale: c.locale, slug: c.slug, content: serializeV2PageFile(c.parsed, c.locale) });
|
|
268
|
+
}
|
|
269
|
+
moves.sort((a, b) => (a.from < b.from ? -1 : 1));
|
|
270
|
+
const remaining = local.map(([p]) => p).filter((p) => !moves.some((m) => m.from === p));
|
|
271
|
+
diagnostics.push(...folderConflicts([...remaining, ...moves.map((m) => m.to)]));
|
|
272
|
+
|
|
273
|
+
const refused = diagnostics.some((d) => d.level === "error");
|
|
274
|
+
if (!write || refused) return { moves, moved: 0, diagnostics, refused };
|
|
275
|
+
|
|
276
|
+
// Every target validated before the first move.
|
|
277
|
+
for (const m of moves) {
|
|
278
|
+
const r = resolveContained(root, m.to);
|
|
279
|
+
if (r.error) {
|
|
280
|
+
diagnostics.push({ level: "error", code: r.error.code, message: r.error.reason, path: m.to });
|
|
281
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
282
|
+
}
|
|
283
|
+
if (existsSync(r.target)) {
|
|
284
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: "target appeared during migration", path: m.to });
|
|
285
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
let moved = 0;
|
|
289
|
+
for (const m of moves) {
|
|
290
|
+
const verify = classifyPageFile(m.to, m.content, { defaultLocale: m.locale });
|
|
291
|
+
if (!verify.ok || verify.layout !== "v2") throw new PagesCliError("PAGES_INVALID_PATH", `internal: ${m.to} would not be a valid v2 file; stopped after ${moved} move(s).`);
|
|
292
|
+
const resolved = resolveContained(root, m.to);
|
|
293
|
+
if (resolved.error) throw new PagesCliError(resolved.error.code, `${m.to}: ${resolved.error.reason}; stopped after ${moved} move(s).`);
|
|
294
|
+
const { target } = resolved;
|
|
295
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
296
|
+
const temp = `${target}.blocofy-tmp`;
|
|
297
|
+
writeFileSync(temp, m.content, { flag: "wx" });
|
|
298
|
+
renameSync(temp, target);
|
|
299
|
+
const legacy = resolveContained(root, m.from);
|
|
300
|
+
if (legacy.error) throw new PagesCliError(legacy.error.code, `${m.from}: ${legacy.error.reason}; stopped after ${moved} move(s).`);
|
|
301
|
+
rmSync(legacy.target);
|
|
302
|
+
moved += 1;
|
|
75
303
|
}
|
|
76
|
-
return {
|
|
304
|
+
return { moves, moved, diagnostics, refused: false };
|
|
77
305
|
}
|
|
@@ -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
|
+
}
|