Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/web/src/components/sandbox/content/redirect-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
extractRedirects,
generateRedirectBlockKey,
getRedirectPayload,
redirectStatus,
} from "./redirect-data";

test("REDIRECT_STATUS locks the HTTP status contract", () => {
Expand Down Expand Up @@ -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<string, unknown>) =>
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");
Expand Down
87 changes: 76 additions & 11 deletions apps/web/src/components/sandbox/content/redirect-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +31,12 @@ export const REDIRECT_LOADER_RESOLVE_TYPES: ReadonlySet<string> = 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. */
Expand All @@ -31,29 +45,54 @@ export const REDIRECT_STATUS: Record<RedirectType, number> = {
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<string, unknown>): 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<string, unknown> | undefined,
): RedirectPayload {
if (block?.__resolveType === FLAT_REDIRECT_RESOLVE_TYPE) {
return readFlatRedirect(block);
}
const raw =
block &&
typeof block.redirect === "object" &&
Expand All @@ -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<string, unknown>;
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;
}
Expand All @@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown> = {
from: payload.from,
to: payload.to,
Expand Down
55 changes: 52 additions & 3 deletions apps/web/src/components/sandbox/content/redirect-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -118,7 +123,11 @@ export function RedirectEditor({
{TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey, {
status: REDIRECT_STATUS[option.value],
status: redirectStatus({
...payload,
type: option.value,
status: undefined,
}),
})}
</SelectItem>
))}
Expand All @@ -131,6 +140,46 @@ export function RedirectEditor({
</p>
</div>

{payload.flat && (
<div className="space-y-2">
<Label htmlFor="redirect-status">
{t("sandbox.redirectEditor.statusLabel")}
</Label>
<Select
value={
payload.status ? String(payload.status) : STATUS_FROM_TYPE
}
onValueChange={(v) =>
setField({
status:
v === STATUS_FROM_TYPE
? undefined
: (Number(v) as RedirectStatusCode),
})
}
>
<SelectTrigger id="redirect-status" className="h-10 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={STATUS_FROM_TYPE}>
{t("sandbox.redirectEditor.statusFromType", {
status: redirectStatus({ ...payload, status: undefined }),
})}
</SelectItem>
{REDIRECT_STATUS_CODES.map((code) => (
<SelectItem key={code} value={String(code)}>
{code}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("sandbox.redirectEditor.statusDescription")}
</p>
</div>
)}

<label className="flex cursor-pointer items-center gap-2.5">
<Checkbox
checked={payload.discardQueryParameters}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/sections-editor/matcher-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function listSavedMatcherBlocks(
const entries: SavedMatcherBlock[] = [];

for (const [key, val] of Object.entries(decofile)) {
if (key.includes("/") || !isSavedBlockResolveType(key)) continue;
if (key.includes("/") || !isSavedBlockResolveType(key, meta)) continue;
if (isAutoPreviewBlockKey(key)) continue;
if (!val || typeof val !== "object" || Array.isArray(val)) continue;

Expand All @@ -85,7 +85,9 @@ export function isSavedMatcherBlockReference(
): boolean {
if (!rule) return false;
const rt = rule.__resolveType;
if (typeof rt !== "string" || !isSavedBlockResolveType(rt)) return false;
if (typeof rt !== "string" || !isSavedBlockResolveType(rt, meta)) {
return false;
}
if (!Object.hasOwn(decofile, rt)) return false;

const blockData = decofile[rt] as Record<string, unknown>;
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/sections-editor/page-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ describe("page-list", () => {
]);
});

it("extractPages follows the manifest's pages group", () => {
const meta = {
manifest: { blocks: { pages: { page: {}, post: {} }, sections: {} } },
schema: {},
} as LiveMeta;
const decofile = {
home: { __resolveType: "page", path: "/", sections: [] },
"hello-world": { __resolveType: "post", path: "/blog/hello" },
hero: { __resolveType: "hero", path: "/not-a-page" },
legacy: { __resolveType: "website/pages/Page.tsx", path: "/old" },
};
expect(extractPages(decofile, meta).map((p) => p.key)).toEqual([
"home",
"hello-world",
"legacy",
]);
// Without a schema only the legacy page types are pages.
expect(extractPages(decofile).map((p) => p.key)).toEqual(["legacy"]);
});

it("findPageForPath prefers an explicit block key when paths collide", () => {
const pages = [
{
Expand Down
Loading
Loading