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")} +

+
+ )} +