@bygghammar/cms-platform 0.91.1 → 0.92.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +20 -9
- package/docs/NEW-SITE.md +29 -14
- package/docs/PAGES.md +79 -21
- package/docs/PUBLICATION-REVIEW.md +298 -0
- package/migrations/20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql +3298 -0
- package/package.json +8 -2
- package/scripts/cms-review.mjs +832 -0
- package/scripts/review-fingerprint.sql +43 -0
- package/scripts/review-scan.sql +56 -0
- package/src/backup/datasets.ts +203 -2
- package/src/backup/restore-core.ts +382 -11
- package/src/platform/contract.ts +82 -60
- package/src/platform/digest.ts +203 -0
- package/src/platform/migration-manifest.ts +310 -0
- package/src/platform/publication-review.ts +413 -0
- package/src/server/backup-core.ts +291 -18
- package/src/server/page-seo-core.ts +56 -10
- package/src/server/pages-core.ts +90 -53
- package/src/server/redirects-core.ts +51 -9
- package/src/server/schema-drift.ts +21 -65
- package/src/site/route-index.ts +641 -0
- package/src/site/routes.ts +2 -8
|
@@ -8,10 +8,25 @@ import {
|
|
|
8
8
|
LINEAGE_KEYS,
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
10
|
groupById,
|
|
11
|
+
readSchemaVersion,
|
|
11
12
|
type ArchiveManifest,
|
|
12
13
|
type ImportMode,
|
|
13
14
|
type TableSpec,
|
|
14
15
|
} from "../backup/datasets";
|
|
16
|
+
import {
|
|
17
|
+
buildClearPlan,
|
|
18
|
+
describeReviewConflicts,
|
|
19
|
+
indexUsersByEmail,
|
|
20
|
+
needsReconciliation,
|
|
21
|
+
replaceRefusals,
|
|
22
|
+
resolveActors,
|
|
23
|
+
reviewConflicts,
|
|
24
|
+
writableColumns,
|
|
25
|
+
type ClearStep,
|
|
26
|
+
type LocalReviewState,
|
|
27
|
+
type ReviewConflict,
|
|
28
|
+
type UnlinkedActor,
|
|
29
|
+
} from "../backup/restore-core";
|
|
15
30
|
import {
|
|
16
31
|
hasAllowedMediaExtension,
|
|
17
32
|
isSafeArchivePath,
|
|
@@ -348,10 +363,9 @@ export async function analyzeArchiveCore(deps: BackupDeps, bytes: Uint8Array): P
|
|
|
348
363
|
`Archive format ${manifest.format} does not match this site (${ARCHIVE_FORMAT_VERSION}).`,
|
|
349
364
|
);
|
|
350
365
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
);
|
|
366
|
+
const reading = readSchemaVersion(manifest.schemaVersion);
|
|
367
|
+
if (!reading.supported) {
|
|
368
|
+
problems.push(`This archive cannot be restored — ${reading.reason}.`);
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
const media = measureMedia(entries);
|
|
@@ -408,6 +422,168 @@ export async function analyzeArchiveCore(deps: BackupDeps, bytes: Uint8Array): P
|
|
|
408
422
|
};
|
|
409
423
|
}
|
|
410
424
|
|
|
425
|
+
/* ------------------------------------------------- local review history */
|
|
426
|
+
|
|
427
|
+
const REVIEW_LOOKUP_CHUNK = 200;
|
|
428
|
+
|
|
429
|
+
/** Read rows whose key is in `values`, in chunks small enough for a URL. */
|
|
430
|
+
async function selectByKey(
|
|
431
|
+
supabaseAdmin: SupabaseClient,
|
|
432
|
+
table: string,
|
|
433
|
+
columns: string,
|
|
434
|
+
keyColumn: string,
|
|
435
|
+
values: string[],
|
|
436
|
+
): Promise<Record<string, unknown>[]> {
|
|
437
|
+
const out: Record<string, unknown>[] = [];
|
|
438
|
+
for (let i = 0; i < values.length; i += REVIEW_LOOKUP_CHUNK) {
|
|
439
|
+
const chunk = values.slice(i, i + REVIEW_LOOKUP_CHUNK);
|
|
440
|
+
const { data, error } = await supabaseAdmin
|
|
441
|
+
.from(table as never)
|
|
442
|
+
.select(columns as never)
|
|
443
|
+
.in(keyColumn as never, chunk as never);
|
|
444
|
+
if (error) throw new Error(`Could not read ${table} to check for conflicts: ${error.message}`);
|
|
445
|
+
out.push(...((data ?? []) as unknown as Record<string, unknown>[]));
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function archivedIds(
|
|
451
|
+
entries: Record<string, Uint8Array>,
|
|
452
|
+
table: string,
|
|
453
|
+
column: string,
|
|
454
|
+
): string[] {
|
|
455
|
+
const seen = new Set<string>();
|
|
456
|
+
for (const row of tableRows(entries, table)) {
|
|
457
|
+
const v = row[column];
|
|
458
|
+
if (typeof v === "string" && v) seen.add(v);
|
|
459
|
+
}
|
|
460
|
+
return [...seen];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The local review rows the archive could possibly disagree with.
|
|
465
|
+
*
|
|
466
|
+
* Scoped to the ids the archive actually mentions rather than the whole
|
|
467
|
+
* history, which on a busy site is one row per page per scan and grows without
|
|
468
|
+
* bound. A row the archive says nothing about cannot conflict with it, so
|
|
469
|
+
* reading it would cost time to learn nothing.
|
|
470
|
+
*/
|
|
471
|
+
async function readLocalReviewState(
|
|
472
|
+
supabaseAdmin: SupabaseClient,
|
|
473
|
+
entries: Record<string, Uint8Array>,
|
|
474
|
+
): Promise<LocalReviewState> {
|
|
475
|
+
const runIds = archivedIds(entries, "cms_publication_review_runs", "id");
|
|
476
|
+
const itemRunIds = archivedIds(entries, "cms_publication_review_items", "run_id");
|
|
477
|
+
const decisionItemIds = archivedIds(entries, "cms_publication_review_decisions", "item_id");
|
|
478
|
+
|
|
479
|
+
const runs = runIds.length
|
|
480
|
+
? await selectByKey(
|
|
481
|
+
supabaseAdmin,
|
|
482
|
+
"cms_publication_review_runs",
|
|
483
|
+
"id,input_fingerprint",
|
|
484
|
+
"id",
|
|
485
|
+
runIds,
|
|
486
|
+
)
|
|
487
|
+
: [];
|
|
488
|
+
const items = itemRunIds.length
|
|
489
|
+
? await selectByKey(
|
|
490
|
+
supabaseAdmin,
|
|
491
|
+
"cms_publication_review_items",
|
|
492
|
+
"id,run_id,page_id,classification",
|
|
493
|
+
"run_id",
|
|
494
|
+
itemRunIds,
|
|
495
|
+
)
|
|
496
|
+
: [];
|
|
497
|
+
const decisions = decisionItemIds.length
|
|
498
|
+
? await selectByKey(
|
|
499
|
+
supabaseAdmin,
|
|
500
|
+
"cms_publication_review_decisions",
|
|
501
|
+
"item_id,decision",
|
|
502
|
+
"item_id",
|
|
503
|
+
decisionItemIds,
|
|
504
|
+
)
|
|
505
|
+
: [];
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
runs: runs.map((r) => ({
|
|
509
|
+
id: String(r["id"]),
|
|
510
|
+
input_fingerprint: r["input_fingerprint"] == null ? null : String(r["input_fingerprint"]),
|
|
511
|
+
})),
|
|
512
|
+
items: items.map((r) => ({
|
|
513
|
+
id: String(r["id"]),
|
|
514
|
+
run_id: String(r["run_id"]),
|
|
515
|
+
page_id: String(r["page_id"]),
|
|
516
|
+
classification: r["classification"] == null ? null : String(r["classification"]),
|
|
517
|
+
})),
|
|
518
|
+
decisions: decisions.map((r) => ({
|
|
519
|
+
item_id: String(r["item_id"]),
|
|
520
|
+
decision: r["decision"] == null ? null : String(r["decision"]),
|
|
521
|
+
})),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Whether this database holds any review history at all.
|
|
527
|
+
*
|
|
528
|
+
* Asked separately from `readLocalReviewState`, and about the whole table
|
|
529
|
+
* rather than the archived ids, because the Replace refusal does not turn on
|
|
530
|
+
* overlap: a Replace that empties `pages` is blocked by *any* review item
|
|
531
|
+
* pointing at any page, whether or not the archive has ever heard of it.
|
|
532
|
+
*/
|
|
533
|
+
async function localHasReviewHistory(supabaseAdmin: SupabaseClient): Promise<boolean> {
|
|
534
|
+
const { count, error } = await supabaseAdmin
|
|
535
|
+
.from("cms_publication_review_items" as never)
|
|
536
|
+
.select("id" as never, { count: "exact", head: true });
|
|
537
|
+
if (error) {
|
|
538
|
+
// A database without the review tables has no history to protect, and a
|
|
539
|
+
// site that has not migrated to v3 yet must still be able to restore.
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return (count ?? 0) > 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* One clear step through supabase-js.
|
|
547
|
+
*
|
|
548
|
+
* `buildClear` renders the same step as SQL for the standalone tool; this is
|
|
549
|
+
* the same plan spoken to PostgREST, which has no SQL channel. Both need a
|
|
550
|
+
* row filter — PostgREST refuses an unqualified DELETE or UPDATE, on purpose —
|
|
551
|
+
* and `pk IS NOT NULL` is the one that matches every row, since a primary key
|
|
552
|
+
* is never null.
|
|
553
|
+
*/
|
|
554
|
+
async function runClearStep(
|
|
555
|
+
supabaseAdmin: SupabaseClient,
|
|
556
|
+
step: ClearStep,
|
|
557
|
+
pk: string,
|
|
558
|
+
): Promise<string | null> {
|
|
559
|
+
if (step.kind === "release") {
|
|
560
|
+
const patch: Record<string, unknown> = {};
|
|
561
|
+
for (const c of step.columns) patch[c] = null;
|
|
562
|
+
const { error } = await supabaseAdmin
|
|
563
|
+
.from(step.table as never)
|
|
564
|
+
.update(patch as never)
|
|
565
|
+
.not(pk, "is", null);
|
|
566
|
+
return error ? error.message : null;
|
|
567
|
+
}
|
|
568
|
+
const { error } = await supabaseAdmin
|
|
569
|
+
.from(step.table as never)
|
|
570
|
+
.delete()
|
|
571
|
+
.not(pk, "is", null);
|
|
572
|
+
return error ? error.message : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Address → local account id, read once and reused for every actor column. */
|
|
576
|
+
async function readUsersByEmail(supabaseAdmin: SupabaseClient): Promise<Map<string, string>> {
|
|
577
|
+
const { data, error } = await supabaseAdmin.from("app_users" as never).select("id,email" as never);
|
|
578
|
+
if (error) return new Map();
|
|
579
|
+
return indexUsersByEmail(
|
|
580
|
+
((data ?? []) as unknown as Record<string, unknown>[]).map((r) => ({
|
|
581
|
+
id: String(r["id"]),
|
|
582
|
+
email: r["email"] == null ? null : String(r["email"]),
|
|
583
|
+
})),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
411
587
|
export type ImportResult = {
|
|
412
588
|
/** False when any row, table or media file could not be restored. */
|
|
413
589
|
ok: boolean;
|
|
@@ -429,6 +605,23 @@ export type ImportResult = {
|
|
|
429
605
|
mediaBytesInArchive: number;
|
|
430
606
|
/** Archive entries that were not media and were deliberately not restored. */
|
|
431
607
|
quarantinedMedia: string[];
|
|
608
|
+
/**
|
|
609
|
+
* Actor columns whose address matched no account here. The address itself
|
|
610
|
+
* was restored — it is the part that stays readable — but the pointer back
|
|
611
|
+
* to `app_users` was dropped, and an operator may want to relink these.
|
|
612
|
+
*/
|
|
613
|
+
unlinkedActors: UnlinkedActor[];
|
|
614
|
+
/**
|
|
615
|
+
* Whether a fresh publication review scan was owed after this restore, and
|
|
616
|
+
* whether it ran. A schema-2 archive carries no review history, so the
|
|
617
|
+
* content it restores has no classification until something looks at it.
|
|
618
|
+
*/
|
|
619
|
+
reconciliation: {
|
|
620
|
+
needed: boolean;
|
|
621
|
+
ran: boolean;
|
|
622
|
+
/** Present when it was owed and did not run. */
|
|
623
|
+
reason?: string;
|
|
624
|
+
};
|
|
432
625
|
warnings: string[];
|
|
433
626
|
};
|
|
434
627
|
|
|
@@ -454,10 +647,9 @@ export async function importArchiveCore(
|
|
|
454
647
|
`Incompatible archive — this site reads format ${ARCHIVE_FORMAT_VERSION} archives, this one is format ${manifest.format}. Re-export it from the site that produced it.`,
|
|
455
648
|
);
|
|
456
649
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
);
|
|
650
|
+
const reading = readSchemaVersion(manifest.schemaVersion);
|
|
651
|
+
if (!reading.supported) {
|
|
652
|
+
throw new Error(`Incompatible archive — ${reading.reason}.`);
|
|
461
653
|
}
|
|
462
654
|
|
|
463
655
|
const selected = opts.groups.filter((g) => (manifest.groups ?? []).includes(g));
|
|
@@ -500,9 +692,23 @@ export async function importArchiveCore(
|
|
|
500
692
|
}
|
|
501
693
|
}
|
|
502
694
|
|
|
695
|
+
// Nothing below this point is reversible, so everything that can refuse the
|
|
696
|
+
// restore outright happens here, before the first write. The old code could
|
|
697
|
+
// report "completed with conflicts" after it had already applied half the
|
|
698
|
+
// content; there is no such outcome any more.
|
|
699
|
+
const refusals = replaceRefusals(
|
|
700
|
+
{ groups: selected, mode: opts.mode },
|
|
701
|
+
await localHasReviewHistory(supabaseAdmin),
|
|
702
|
+
);
|
|
703
|
+
if (refusals.length) throw new Error(refusals.join(" "));
|
|
704
|
+
|
|
705
|
+
const conflicts = reviewConflicts(entries, await readLocalReviewState(supabaseAdmin, entries));
|
|
706
|
+
if (conflicts.length) throw new Error(describeReviewConflicts(conflicts));
|
|
707
|
+
|
|
503
708
|
const applied: ImportResult["applied"] = [];
|
|
504
709
|
const aborted: string[] = [];
|
|
505
710
|
const warnings: string[] = [];
|
|
711
|
+
const unlinkedActors: UnlinkedActor[] = [];
|
|
506
712
|
const failedTables = new Set<string>();
|
|
507
713
|
const deferredWork: {
|
|
508
714
|
table: string;
|
|
@@ -511,6 +717,35 @@ export async function importArchiveCore(
|
|
|
511
717
|
cols: string[];
|
|
512
718
|
}[] = [];
|
|
513
719
|
|
|
720
|
+
// Replace mode: empty the selected tables once, here, in reverse dependency
|
|
721
|
+
// order — not inside the per-table loop below, where a later table's DELETE
|
|
722
|
+
// would remove rows an earlier one had just written, and where a table the
|
|
723
|
+
// archive is silent about was skipped by `if (!rows.length) continue;` and
|
|
724
|
+
// so never cleared at all.
|
|
725
|
+
if (opts.mode === "replace") {
|
|
726
|
+
const pkOf = new Map([...specs].map(([table, e]) => [table, e.spec.pk]));
|
|
727
|
+
for (const step of buildClearPlan(selected)) {
|
|
728
|
+
const pk = pkOf.get(step.table);
|
|
729
|
+
if (!pk) continue;
|
|
730
|
+
const error = await runClearStep(supabaseAdmin, step, pk);
|
|
731
|
+
if (!error) continue;
|
|
732
|
+
// A clear that fails is not a warning. Everything downstream of it would
|
|
733
|
+
// be written into a table still holding the rows this restore was
|
|
734
|
+
// supposed to replace, so the table is marked failed and its children
|
|
735
|
+
// are aborted by the dependency check in the loop.
|
|
736
|
+
warnings.push(`Could not clear ${step.table}: ${error}`);
|
|
737
|
+
failedTables.add(step.table);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Read at most once, and only if some selected table actually has actor
|
|
742
|
+
// columns. Most restores do not touch the review tables at all.
|
|
743
|
+
let usersByEmail: Map<string, string> | null = null;
|
|
744
|
+
const users = async (): Promise<Map<string, string>> => {
|
|
745
|
+
usersByEmail ??= await readUsersByEmail(supabaseAdmin);
|
|
746
|
+
return usersByEmail;
|
|
747
|
+
};
|
|
748
|
+
|
|
514
749
|
const base = opts.rewriteMediaBase?.trim();
|
|
515
750
|
const prepare = (table: string, row: Record<string, unknown>) => {
|
|
516
751
|
let out = base ? rewriteMediaUrls(row, base) : row;
|
|
@@ -556,21 +791,23 @@ export async function importArchiveCore(
|
|
|
556
791
|
const mode: ImportMode = insertOnly ? "merge" : opts.mode;
|
|
557
792
|
const conflict = entry.spec.conflict ?? entry.spec.pk;
|
|
558
793
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
794
|
+
// Actor pointers are settled before the insert, never after it. The review
|
|
795
|
+
// tables refuse UPDATE by trigger, so a deferred write-back would fail
|
|
796
|
+
// *after* the row was already in — a restore reporting success on a table
|
|
797
|
+
// whose every actor pointer had been rejected.
|
|
798
|
+
const resolved = (entry.spec.actors ?? []).length
|
|
799
|
+
? resolveActors(table, entry.spec, rows, await users())
|
|
800
|
+
: { rows, unlinked: [] as UnlinkedActor[] };
|
|
801
|
+
rows = resolved.rows;
|
|
802
|
+
unlinkedActors.push(...resolved.unlinked);
|
|
569
803
|
|
|
570
804
|
const deferred = entry.spec.deferred ?? [];
|
|
805
|
+
const generated = new Set(entry.spec.generated ?? []);
|
|
571
806
|
const payload = rows.map((r) => {
|
|
572
807
|
const copy = prepare(table, { ...r });
|
|
573
808
|
for (const c of deferred) copy[c] = null;
|
|
809
|
+
// PostgreSQL rejects a write that names a generated column outright.
|
|
810
|
+
for (const c of generated) delete copy[c];
|
|
574
811
|
return copy;
|
|
575
812
|
});
|
|
576
813
|
if (deferred.length) {
|
|
@@ -684,6 +921,36 @@ export async function importArchiveCore(
|
|
|
684
921
|
|
|
685
922
|
const ok = failedTables.size === 0 && aborted.length === 0;
|
|
686
923
|
|
|
924
|
+
// Reconciliation. A schema-2 archive carries no review history, so the
|
|
925
|
+
// content it just restored has no classification and nothing else will ever
|
|
926
|
+
// give it one.
|
|
927
|
+
//
|
|
928
|
+
// It runs only when the restore actually finished: a run states
|
|
929
|
+
// expected_page_count, which is a claim about a database that has stopped
|
|
930
|
+
// changing, and half a restore is not that. A partial restore therefore
|
|
931
|
+
// leaves the site unclassified and the result says so, rather than recording
|
|
932
|
+
// a run that would read as "this database has been reviewed".
|
|
933
|
+
const reconciliationNeeded = needsReconciliation(manifest) && selected.includes("content");
|
|
934
|
+
let reconciliationRan = false;
|
|
935
|
+
let reconciliationReason: string | undefined;
|
|
936
|
+
if (reconciliationNeeded) {
|
|
937
|
+
if (!ok) {
|
|
938
|
+
reconciliationReason =
|
|
939
|
+
"the restore did not complete cleanly, so no review scan was recorded — " +
|
|
940
|
+
"the pages that did arrive have no publication classification until one is run.";
|
|
941
|
+
} else {
|
|
942
|
+
const { error } = await supabaseAdmin.rpc("cms_publication_review_reconcile" as never, {
|
|
943
|
+
_operator_email: caller.email,
|
|
944
|
+
} as never);
|
|
945
|
+
if (error) {
|
|
946
|
+
reconciliationReason = `the review scan could not be recorded: ${error.message}`;
|
|
947
|
+
} else {
|
|
948
|
+
reconciliationRan = true;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (reconciliationReason) warnings.push(reconciliationReason);
|
|
952
|
+
}
|
|
953
|
+
|
|
687
954
|
await audit(deps, caller, "backup.import", {
|
|
688
955
|
groups: selected,
|
|
689
956
|
mode: opts.mode,
|
|
@@ -708,6 +975,12 @@ export async function importArchiveCore(
|
|
|
708
975
|
mediaFilesInArchive: archiveMedia.files,
|
|
709
976
|
mediaBytesInArchive: archiveMedia.bytes,
|
|
710
977
|
quarantinedMedia: archiveMedia.quarantined,
|
|
978
|
+
unlinkedActors,
|
|
979
|
+
reconciliation: {
|
|
980
|
+
needed: reconciliationNeeded,
|
|
981
|
+
ran: reconciliationRan,
|
|
982
|
+
...(reconciliationReason ? { reason: reconciliationReason } : {}),
|
|
983
|
+
},
|
|
711
984
|
warnings,
|
|
712
985
|
};
|
|
713
986
|
}
|
|
@@ -4,34 +4,80 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { normalizePath } from "./redirects-core";
|
|
6
6
|
import type { PageSlugRegistry } from "./redirects-core";
|
|
7
|
+
import { normalizeRoutePath, servedPath, type RoutePathPolicy } from "../site/route-index";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A page whose address is already taken.
|
|
11
|
+
*
|
|
12
|
+
* `path` is the address as the route authority spells it, and `heldBy` names
|
|
13
|
+
* the page that reached it first. "That slug is taken" is not something an
|
|
14
|
+
* editor can act on: the two pages need not look alike, and on a site that
|
|
15
|
+
* folds case or normalizes Unicode they need not even be spelled alike.
|
|
16
|
+
*/
|
|
17
|
+
export type SlugConflict = {
|
|
18
|
+
locale: string;
|
|
19
|
+
pageKey: string;
|
|
20
|
+
path: string;
|
|
21
|
+
heldBy: string;
|
|
22
|
+
};
|
|
9
23
|
|
|
10
24
|
/**
|
|
11
|
-
* No two pages may resolve to the same URL
|
|
12
|
-
*
|
|
25
|
+
* No two pages may resolve to the same URL.
|
|
26
|
+
*
|
|
27
|
+
* Not "in the same locale": in the same *site*. The locale prefix is part of
|
|
28
|
+
* the address, so a Swedish page whose slug is `en` and the English front page
|
|
29
|
+
* both answer at `/en`, and a comparison run once per locale calls that pair
|
|
30
|
+
* fine. Every locale's addresses go into one table here for that reason.
|
|
31
|
+
*
|
|
32
|
+
* The addresses come from `servedPath` and `normalizeRoutePath` — the two
|
|
33
|
+
* functions the site builds its own links with — so this check cannot disagree
|
|
34
|
+
* with what the site serves. It used to compare bare slug strings, which made
|
|
35
|
+
* `/Om-Oss` and `/om-oss` two pages to the editor and one page to the browser.
|
|
36
|
+
*
|
|
37
|
+
* It does not consult a list of reserved paths. The release's own claims are
|
|
38
|
+
* the authority on those, and a second list of forbidden strings kept here
|
|
39
|
+
* would be a second authority waiting to disagree with the first.
|
|
40
|
+
*
|
|
41
|
+
* `existing` is the stored override map, `slugs` the edit being saved.
|
|
13
42
|
*/
|
|
14
43
|
export function detectSlugConflictsCore(input: {
|
|
15
44
|
pages: PageSlugRegistry;
|
|
16
45
|
locales: readonly string[];
|
|
46
|
+
defaultLocale: string;
|
|
17
47
|
existing: Record<string, Record<string, string>>;
|
|
18
48
|
pageKey: string;
|
|
19
49
|
slugs: Record<string, string>;
|
|
50
|
+
policy?: RoutePathPolicy;
|
|
20
51
|
}): SlugConflict[] {
|
|
21
52
|
const pendingByKey = new Map<string, Record<string, string>>(Object.entries(input.existing));
|
|
22
53
|
pendingByKey.set(input.pageKey, input.slugs);
|
|
23
54
|
|
|
55
|
+
// Registry pages, pages that exist only as an override row, and the page
|
|
56
|
+
// being saved. A builder-created page is in no static registry, and leaving
|
|
57
|
+
// it out of the comparison is exactly how it comes to take an address the
|
|
58
|
+
// registry already serves.
|
|
59
|
+
const keys = [...new Set([...Object.keys(input.pages), ...pendingByKey.keys()])];
|
|
60
|
+
|
|
61
|
+
const policy: RoutePathPolicy = { locales: input.locales, ...(input.policy ?? {}) };
|
|
24
62
|
const conflicts: SlugConflict[] = [];
|
|
63
|
+
const seen = new Map<string, string>();
|
|
64
|
+
|
|
25
65
|
for (const locale of input.locales) {
|
|
26
|
-
const
|
|
27
|
-
for (const key of Object.keys(input.pages)) {
|
|
28
|
-
const base = input.pages[key].slug[locale];
|
|
66
|
+
for (const key of keys) {
|
|
29
67
|
const override = pendingByKey.get(key)?.[locale];
|
|
30
|
-
const
|
|
68
|
+
const base = input.pages[key]?.slug[locale];
|
|
69
|
+
// A page with no slug in this locale is still served here when it has
|
|
70
|
+
// one in the default locale: path building falls back, so the address
|
|
71
|
+
// exists and can collide whether or not anyone translated the slug.
|
|
72
|
+
const fallback =
|
|
73
|
+
pendingByKey.get(key)?.[input.defaultLocale] ?? input.pages[key]?.slug[input.defaultLocale];
|
|
74
|
+
const effective = override ?? base ?? fallback;
|
|
31
75
|
if (effective === undefined) continue;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
|
|
77
|
+
const path = normalizeRoutePath(servedPath(effective, locale, input.defaultLocale), policy);
|
|
78
|
+
const holder = seen.get(path);
|
|
79
|
+
if (holder === undefined) seen.set(path, key);
|
|
80
|
+
else if (holder !== key) conflicts.push({ locale, pageKey: key, path, heldBy: holder });
|
|
35
81
|
}
|
|
36
82
|
}
|
|
37
83
|
return conflicts;
|