From 13eac406e56f96891034b680fb6ecef39f460d2a Mon Sep 17 00:00:00 2001
From: gimenes
Date: Thu, 8 Oct 2026 15:00:23 -0300
Subject: [PATCH] feat(web): flat redirects keep unknown fields; pages by
manifest
Flat redirects keep fields the editor does not know when they
round-trip (plus an explicit status code field), and the page list
reads pages from the manifest.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
---
.../sandbox/content/redirect-data.test.ts | 65 ++++++++++++++
.../sandbox/content/redirect-data.ts | 87 ++++++++++++++++---
.../sandbox/content/redirect-editor.tsx | 55 +++++++++++-
.../sections-editor/matcher-rules.ts | 6 +-
.../sections-editor/page-list.test.ts | 20 +++++
.../components/sections-editor/page-list.tsx | 28 ++++--
apps/web/src/i18n/en/sandbox.ts | 4 +
apps/web/src/i18n/pt-br/sandbox.ts | 4 +
8 files changed, 248 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/sandbox/content/redirect-data.test.ts b/apps/web/src/components/sandbox/content/redirect-data.test.ts
index 5d808ba81e..abdf924643 100644
--- a/apps/web/src/components/sandbox/content/redirect-data.test.ts
+++ b/apps/web/src/components/sandbox/content/redirect-data.test.ts
@@ -6,6 +6,7 @@ import {
extractRedirects,
generateRedirectBlockKey,
getRedirectPayload,
+ redirectStatus,
} from "./redirect-data";
test("REDIRECT_STATUS locks the HTTP status contract", () => {
@@ -120,6 +121,70 @@ describe("buildRedirectBlock / getRedirectPayload round-trip", () => {
});
});
+describe("flat redirects (next-major `redirect`)", () => {
+ const flat = {
+ __resolveType: "redirect",
+ from: "/summer",
+ to: "/sale",
+ permanent: false,
+ status: 308,
+ discardQueryParameters: true,
+ };
+
+ test("are listed next to nested ones", () => {
+ const decofile = {
+ "redirects-flat": flat,
+ "redirects-nested": redirectBlock({ from: "/a", to: "/b" }),
+ };
+ const byKey = Object.fromEntries(
+ extractRedirects(decofile).map((r) => [r.key, r]),
+ );
+ expect(byKey["redirects-flat"]).toEqual({
+ key: "redirects-flat",
+ from: "/summer",
+ to: "/sale",
+ type: "temporary",
+ discardQueryParameters: true,
+ flat: true,
+ status: 308,
+ });
+ expect(byKey["redirects-nested"]?.flat).toBeUndefined();
+ });
+
+ test("write back in the shape they were read", () => {
+ expect(buildRedirectBlock(getRedirectPayload(flat))).toEqual(flat);
+ const minimal = {
+ __resolveType: "redirect",
+ from: "/x",
+ to: "/y",
+ permanent: true,
+ };
+ expect(buildRedirectBlock(getRedirectPayload(minimal))).toEqual(minimal);
+ });
+
+ test("keep the fields the editor doesn't know", () => {
+ const block = { ...flat, name: "Summer sale", status: 308 };
+ const edited = buildRedirectBlock(
+ { ...getRedirectPayload(block), status: undefined },
+ block,
+ );
+ expect(edited.name).toBe("Summer sale");
+ expect(edited).not.toHaveProperty("status");
+ });
+
+ test("answer with the status that wins", () => {
+ const read = (block: Record) =>
+ redirectStatus(getRedirectPayload(block));
+ expect(read(flat)).toBe(308);
+ expect(read({ ...flat, status: undefined })).toBe(302);
+ expect(read({ ...flat, status: 418, permanent: true })).toBe(301);
+ expect(read(redirectBlock({ from: "/a", to: "/b" }))).toBe(307);
+ expect(
+ read(redirectBlock({ from: "/a", to: "/b", type: "permanent" })),
+ ).toBe(301);
+ });
+});
+
describe("generateRedirectBlockKey", () => {
test("derives a slug from the `from` path, stripping the query string", () => {
const key = generateRedirectBlockKey({}, "/Bazar-Farm/Short?map=c");
diff --git a/apps/web/src/components/sandbox/content/redirect-data.ts b/apps/web/src/components/sandbox/content/redirect-data.ts
index 6e4d1953ab..266a77c28e 100644
--- a/apps/web/src/components/sandbox/content/redirect-data.ts
+++ b/apps/web/src/components/sandbox/content/redirect-data.ts
@@ -9,6 +9,14 @@
* The site's routes include an inline `website/loaders/redirects.ts` (plural)
* that auto-discovers ALL such blocks via `resolveTypeSelector`, so CRUD here
* is just create/update/delete of these blocks — no routes/site wiring needed.
+ *
+ * Next-major Blocks also reads a flat shape, the built-in `redirect`:
+ *
+ * { "__resolveType": "redirect", from, to, permanent, status?, discardQueryParameters? }
+ *
+ * where `status` (301/302/307/308) wins over `permanent` (301, else 302). An
+ * entry keeps the shape it was read in; new redirects use the nested shape,
+ * which every site understands.
*/
export const REDIRECT_RESOLVE_TYPE = "website/loaders/redirect.ts";
@@ -23,6 +31,12 @@ export const REDIRECT_LOADER_RESOLVE_TYPES: ReadonlySet = new Set([
"website/loaders/redirects.ts",
]);
+/** The next-major built-in, stored flat. */
+const FLAT_REDIRECT_RESOLVE_TYPE = "redirect";
+
+export const REDIRECT_STATUS_CODES = [301, 302, 307, 308] as const;
+export type RedirectStatusCode = (typeof REDIRECT_STATUS_CODES)[number];
+
export type RedirectType = "temporary" | "permanent";
/** HTTP status the deco redirect handler emits for each type. */
@@ -31,29 +45,54 @@ export const REDIRECT_STATUS: Record = {
permanent: 301,
};
-export interface RedirectEntry {
- key: string;
+export interface RedirectPayload {
from: string;
to: string;
type: RedirectType;
discardQueryParameters: boolean;
+ /** Stored in the flat (`redirect`) shape; absent for the nested one. */
+ flat?: true;
+ /** Flat shape only: an explicit status, which wins over `type`. */
+ status?: RedirectStatusCode;
}
-export interface RedirectPayload {
- from: string;
- to: string;
- type: RedirectType;
- discardQueryParameters: boolean;
+export interface RedirectEntry extends RedirectPayload {
+ key: string;
+}
+
+/** The status code a redirect answers with. A flat temporary one is a 302. */
+export function redirectStatus(payload: RedirectPayload): number {
+ if (!payload.flat) return REDIRECT_STATUS[payload.type];
+ return payload.status ?? (payload.type === "permanent" ? 301 : 302);
}
const asStr = (v: unknown): string => (typeof v === "string" ? v : "");
const asType = (v: unknown): RedirectType =>
v === "permanent" ? "permanent" : "temporary";
+const asStatus = (v: unknown): RedirectStatusCode | undefined =>
+ REDIRECT_STATUS_CODES.find((code) => code === v);
+
+/** A flat `redirect` block, defensively narrowed. */
+function readFlatRedirect(block: Record): RedirectPayload {
+ const status = asStatus(block.status);
+ return {
+ from: asStr(block.from),
+ to: asStr(block.to),
+ type: block.permanent === true ? "permanent" : "temporary",
+ discardQueryParameters: block.discardQueryParameters === true,
+ flat: true,
+ ...(status ? { status } : {}),
+ };
+}
+
/** The `redirect` sub-object of a redirect block, defensively narrowed. */
function readRedirect(
block: Record | undefined,
): RedirectPayload {
+ if (block?.__resolveType === FLAT_REDIRECT_RESOLVE_TYPE) {
+ return readFlatRedirect(block);
+ }
const raw =
block &&
typeof block.redirect === "object" &&
@@ -77,9 +116,13 @@ export function extractRedirects(
for (const [key, val] of Object.entries(decofile)) {
if (!val || typeof val !== "object" || Array.isArray(val)) continue;
const obj = val as Record;
- if (obj.__resolveType !== REDIRECT_RESOLVE_TYPE) continue;
- const { from, to, type, discardQueryParameters } = readRedirect(obj);
- out.push({ key, from, to, type, discardQueryParameters });
+ if (
+ obj.__resolveType !== REDIRECT_RESOLVE_TYPE &&
+ obj.__resolveType !== FLAT_REDIRECT_RESOLVE_TYPE
+ ) {
+ continue;
+ }
+ out.push({ key, ...readRedirect(obj) });
}
return out;
}
@@ -91,10 +134,32 @@ export function getRedirectPayload(
return readRedirect(block);
}
-/** Build the decofile block for a redirect. Omits falsy optional fields. */
+/**
+ * Build the decofile block for a redirect. Omits falsy optional fields. A flat
+ * block keeps the fields of `base` (the stored block) the editor doesn't know.
+ */
export function buildRedirectBlock(
payload: RedirectPayload,
+ base?: Record,
): Record {
+ if (payload.flat) {
+ const {
+ status: _status,
+ discardQueryParameters: _discard,
+ ...kept
+ } = base ?? {};
+ return {
+ ...kept,
+ __resolveType: FLAT_REDIRECT_RESOLVE_TYPE,
+ from: payload.from,
+ to: payload.to,
+ permanent: payload.type === "permanent",
+ ...(payload.status ? { status: payload.status } : {}),
+ ...(payload.discardQueryParameters
+ ? { discardQueryParameters: true }
+ : {}),
+ };
+ }
const redirect: Record = {
from: payload.from,
to: payload.to,
diff --git a/apps/web/src/components/sandbox/content/redirect-editor.tsx b/apps/web/src/components/sandbox/content/redirect-editor.tsx
index e2bba539bc..b8ae3ddd61 100644
--- a/apps/web/src/components/sandbox/content/redirect-editor.tsx
+++ b/apps/web/src/components/sandbox/content/redirect-editor.tsx
@@ -16,11 +16,16 @@ import type { TranslationKey } from "@/i18n/use-t.ts";
import {
buildRedirectBlock,
getRedirectPayload,
- REDIRECT_STATUS,
+ REDIRECT_STATUS_CODES,
type RedirectPayload,
+ type RedirectStatusCode,
type RedirectType,
+ redirectStatus,
} from "./redirect-data";
+/** The status select's value for "no explicit status". */
+const STATUS_FROM_TYPE = "type";
+
const TYPE_OPTIONS: Array<{ value: RedirectType; labelKey: TranslationKey }> = [
{ value: "temporary", labelKey: "sandbox.redirectEditor.typeTemporary" },
{ value: "permanent", labelKey: "sandbox.redirectEditor.typePermanent" },
@@ -51,7 +56,7 @@ export function RedirectEditor({
const [payload, setPayload] = useAutosave(
initial,
(next) => {
- save.mutate({ blockKey, data: buildRedirectBlock(next) });
+ save.mutate({ blockKey, data: buildRedirectBlock(next, block) });
},
{ isSaving: save.isPending },
);
@@ -118,7 +123,11 @@ export function RedirectEditor({
{TYPE_OPTIONS.map((option) => (
{t(option.labelKey, {
- status: REDIRECT_STATUS[option.value],
+ status: redirectStatus({
+ ...payload,
+ type: option.value,
+ status: undefined,
+ }),
})}
))}
@@ -131,6 +140,46 @@ export function RedirectEditor({
+ {payload.flat && (
+
+
+
+
+ {t("sandbox.redirectEditor.statusDescription")}
+
+
+ )}
+