bsmnt 0.14.0 → 0.15.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/dist/core/create/internal-ids.d.ts +1 -0
- package/dist/core/create/internal-ids.d.ts.map +1 -1
- package/dist/core/create/internal-ids.js +1 -0
- package/dist/core/create/internal-ids.js.map +1 -1
- package/dist/domain/skills.d.ts +5 -0
- package/dist/domain/skills.d.ts.map +1 -1
- package/dist/domain/skills.js +9 -1
- package/dist/domain/skills.js.map +1 -1
- package/dist/infrastructure/create/format-project.d.ts +9 -0
- package/dist/infrastructure/create/format-project.d.ts.map +1 -0
- package/dist/infrastructure/create/format-project.js +21 -0
- package/dist/infrastructure/create/format-project.js.map +1 -0
- package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
- package/dist/infrastructure/create/lifecycle-registry.js +5 -0
- package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-skills/launch-ready/SKILL.md +114 -0
- package/src/agent-skills/launch-ready/references/a11y.md +74 -0
- package/src/agent-skills/launch-ready/references/content.md +65 -0
- package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
- package/src/agent-skills/launch-ready/references/launch.md +44 -0
- package/src/agent-skills/launch-ready/references/metadata.md +83 -0
- package/src/agent-skills/launch-ready/references/performance.md +36 -0
- package/src/agent-skills/launch-ready/references/routing.md +47 -0
- package/src/agent-skills/launch-ready/references/security.md +39 -0
- package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
- package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
- package/src/templates/next-default/.env.example +3 -1
- package/src/templates/next-default/app/layout.tsx +2 -1
- package/src/templates/next-default/app/robots.ts +9 -3
- package/src/templates/next-default/lib/utils/metadata.ts +2 -1
- package/src/templates/next-default/lib/utils/url.ts +20 -3
- package/src/templates/next-default/next.config.ts +16 -0
- package/src/templates/next-experiments/.env.example +3 -1
- package/src/templates/next-experiments/app/layout.tsx +2 -1
- package/src/templates/next-experiments/app/robots.ts +9 -3
- package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
- package/src/templates/next-experiments/lib/utils/url.ts +20 -3
- package/src/templates/next-experiments/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/.env.example +3 -1
- package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
- package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
- package/src/templates/next-pagebuilder/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder/biome.json +2 -1
- package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/package.json +1 -1
- package/src/templates/next-pagebuilder-cache/.env.example +3 -1
- package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder-cache/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
- package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
- package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder-cache/biome.json +2 -1
- package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
- package/src/templates/next-pagebuilder-cache/package.json +1 -1
- package/src/templates/next-webgl/.env.example +3 -1
- package/src/templates/next-webgl/app/layout.tsx +2 -1
- package/src/templates/next-webgl/app/robots.ts +9 -3
- package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
- package/src/templates/next-webgl/lib/utils/url.ts +20 -3
- package/src/templates/next-webgl/next.config.ts +16 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { DownloadIcon, UploadIcon } from "@sanity/icons";
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
Dialog,
|
|
9
|
+
Flex,
|
|
10
|
+
Spinner,
|
|
11
|
+
Stack,
|
|
12
|
+
Tab,
|
|
13
|
+
TabList,
|
|
14
|
+
Text,
|
|
15
|
+
} from "@sanity/ui";
|
|
16
|
+
import { type DragEvent, useRef, useState, useSyncExternalStore } from "react";
|
|
17
|
+
import { type LayoutProps, useClient } from "sanity";
|
|
18
|
+
import { formatCsv } from "@/lib/integrations/sanity/csv";
|
|
19
|
+
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
20
|
+
import { PINNED_PAGE_ENTRIES } from "@/lib/integrations/sanity/pinned-pages";
|
|
21
|
+
import { PAGE_SITEMAP_QUERY } from "@/lib/integrations/sanity/queries";
|
|
22
|
+
import {
|
|
23
|
+
commitRedirectImport,
|
|
24
|
+
describePath,
|
|
25
|
+
type ExistingRedirect,
|
|
26
|
+
importReportRows,
|
|
27
|
+
MAX_FILE_BYTES,
|
|
28
|
+
normalizePath,
|
|
29
|
+
type PlannedRow,
|
|
30
|
+
planRedirectImport,
|
|
31
|
+
REDIRECT_CSV_TEMPLATE,
|
|
32
|
+
RedirectImportError,
|
|
33
|
+
type RowStatus,
|
|
34
|
+
readRedirectCsv,
|
|
35
|
+
rowsToWrite,
|
|
36
|
+
statusLabel,
|
|
37
|
+
} from "@/lib/integrations/sanity/redirect-import";
|
|
38
|
+
import type { PAGE_SITEMAP_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types";
|
|
39
|
+
|
|
40
|
+
const VISIBLE_ROWS = 300;
|
|
41
|
+
|
|
42
|
+
let isOpen = false;
|
|
43
|
+
const listeners = new Set<() => void>();
|
|
44
|
+
const subscribe = (listener: () => void) => {
|
|
45
|
+
listeners.add(listener);
|
|
46
|
+
return () => {
|
|
47
|
+
listeners.delete(listener);
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const setOpen = (value: boolean) => {
|
|
51
|
+
isOpen = value;
|
|
52
|
+
for (const listener of listeners) listener();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Opened from the Redirects list menu; rendered by `StudioLayout`. */
|
|
56
|
+
export const openRedirectImport = () => setOpen(true);
|
|
57
|
+
|
|
58
|
+
export function StudioLayout(props: LayoutProps) {
|
|
59
|
+
const open = useSyncExternalStore(
|
|
60
|
+
subscribe,
|
|
61
|
+
() => isOpen,
|
|
62
|
+
() => false,
|
|
63
|
+
);
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{props.renderDefault(props)}
|
|
67
|
+
{open ? <RedirectImportDialog onClose={() => setOpen(false)} /> : null}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Full URLs on these hosts import as paths; the launch domain counts with or without www.
|
|
73
|
+
const getSiteHosts = () => {
|
|
74
|
+
const hosts = new Set([window.location.host]);
|
|
75
|
+
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
|
76
|
+
if (baseUrl && URL.canParse(baseUrl)) {
|
|
77
|
+
const { host } = new URL(baseUrl);
|
|
78
|
+
hosts.add(host);
|
|
79
|
+
hosts.add(host.startsWith("www.") ? host.slice(4) : `www.${host}`);
|
|
80
|
+
}
|
|
81
|
+
return [...hosts];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type Stage =
|
|
85
|
+
| { kind: "pick"; error?: string }
|
|
86
|
+
| { kind: "reading"; fileName: string }
|
|
87
|
+
| { kind: "review"; fileName: string; rows: PlannedRow[]; notes: string[] }
|
|
88
|
+
| { kind: "importing"; fileName: string; written: number; total: number }
|
|
89
|
+
| {
|
|
90
|
+
kind: "done";
|
|
91
|
+
fileName: string;
|
|
92
|
+
rows: PlannedRow[];
|
|
93
|
+
written: Set<PlannedRow>;
|
|
94
|
+
error?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Filter = "write" | "attention" | "rest" | "all";
|
|
98
|
+
|
|
99
|
+
const TONES: Record<RowStatus, BadgeTone> = {
|
|
100
|
+
create: "positive",
|
|
101
|
+
update: "caution",
|
|
102
|
+
unchanged: "default",
|
|
103
|
+
duplicate: "default",
|
|
104
|
+
skipped: "default",
|
|
105
|
+
error: "critical",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const ALL_FILTER = { id: "all", title: "All", test: () => true } as const;
|
|
109
|
+
|
|
110
|
+
const FILTERS: {
|
|
111
|
+
id: Filter;
|
|
112
|
+
title: string;
|
|
113
|
+
test: (row: PlannedRow) => boolean;
|
|
114
|
+
}[] = [
|
|
115
|
+
{
|
|
116
|
+
id: "write",
|
|
117
|
+
title: "To import",
|
|
118
|
+
test: (row) => row.status === "create" || row.status === "update",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "attention",
|
|
122
|
+
title: "Needs a look",
|
|
123
|
+
test: (row) => row.status === "error" || row.notes.length > 0,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "rest",
|
|
127
|
+
title: "Left alone",
|
|
128
|
+
test: (row) =>
|
|
129
|
+
row.status === "unchanged" ||
|
|
130
|
+
row.status === "duplicate" ||
|
|
131
|
+
row.status === "skipped",
|
|
132
|
+
},
|
|
133
|
+
ALL_FILTER,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const plural = (count: number, word: string) =>
|
|
137
|
+
`${count} ${word}${count === 1 ? "" : "s"}`;
|
|
138
|
+
|
|
139
|
+
const statusCode = (permanent: boolean) => (permanent ? "308" : "307");
|
|
140
|
+
|
|
141
|
+
const download = (fileName: string, text: string) => {
|
|
142
|
+
const url = URL.createObjectURL(
|
|
143
|
+
new Blob([text], { type: "text/csv;charset=utf-8" }),
|
|
144
|
+
);
|
|
145
|
+
const link = document.createElement("a");
|
|
146
|
+
link.href = url;
|
|
147
|
+
link.download = fileName;
|
|
148
|
+
link.click();
|
|
149
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const reportName = (fileName: string) =>
|
|
153
|
+
`${fileName.replace(/\.[^.]+$/, "")}-import-report.csv`;
|
|
154
|
+
|
|
155
|
+
function RowItem({ row }: { row: PlannedRow }) {
|
|
156
|
+
const target = row.redirect
|
|
157
|
+
? describePath(row.redirect.destination)
|
|
158
|
+
: row.destination || "—";
|
|
159
|
+
const source = row.redirect ? describePath(row.redirect.source) : row.source;
|
|
160
|
+
return (
|
|
161
|
+
<Card borderBottom paddingY={2} paddingX={1}>
|
|
162
|
+
<Flex align="flex-start" gap={3}>
|
|
163
|
+
<Box style={{ minWidth: 48 }}>
|
|
164
|
+
<Text muted size={1}>
|
|
165
|
+
Row {row.line}
|
|
166
|
+
</Text>
|
|
167
|
+
</Box>
|
|
168
|
+
<Stack flex={1} space={2}>
|
|
169
|
+
<Text size={1} style={{ overflowWrap: "anywhere" }}>
|
|
170
|
+
{source || "—"} → {target}
|
|
171
|
+
{row.redirect?.permanent === false ? " (307)" : ""}
|
|
172
|
+
</Text>
|
|
173
|
+
{row.previous ? (
|
|
174
|
+
<Text muted size={1} style={{ overflowWrap: "anywhere" }}>
|
|
175
|
+
Was {describePath(row.previous.destination)} (
|
|
176
|
+
{statusCode(row.previous.permanent)})
|
|
177
|
+
</Text>
|
|
178
|
+
) : null}
|
|
179
|
+
{row.problem ? (
|
|
180
|
+
<Text
|
|
181
|
+
size={1}
|
|
182
|
+
style={{
|
|
183
|
+
color:
|
|
184
|
+
row.status === "error"
|
|
185
|
+
? "var(--card-badge-critical-fg-color)"
|
|
186
|
+
: undefined,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{row.problem}
|
|
190
|
+
</Text>
|
|
191
|
+
) : null}
|
|
192
|
+
{row.notes.map((note) => (
|
|
193
|
+
<Text key={note} muted size={1}>
|
|
194
|
+
{note}
|
|
195
|
+
</Text>
|
|
196
|
+
))}
|
|
197
|
+
</Stack>
|
|
198
|
+
<Badge tone={TONES[row.status]}>{statusLabel(row.status)}</Badge>
|
|
199
|
+
</Flex>
|
|
200
|
+
</Card>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function Review({ rows, notes }: { rows: PlannedRow[]; notes: string[] }) {
|
|
205
|
+
const [filter, setFilter] = useState<Filter>(
|
|
206
|
+
rows.some((row) => row.status === "error") ? "attention" : "write",
|
|
207
|
+
);
|
|
208
|
+
const active = FILTERS.find(({ id }) => id === filter) ?? ALL_FILTER;
|
|
209
|
+
const shown = rows.filter(active.test);
|
|
210
|
+
const counts = new Map<RowStatus, number>();
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Stack space={4}>
|
|
217
|
+
<Flex gap={2} wrap="wrap">
|
|
218
|
+
{(Object.keys(TONES) as RowStatus[]).map((status) =>
|
|
219
|
+
counts.get(status) ? (
|
|
220
|
+
<Badge key={status} tone={TONES[status]}>
|
|
221
|
+
{statusLabel(status)}: {counts.get(status)}
|
|
222
|
+
</Badge>
|
|
223
|
+
) : null,
|
|
224
|
+
)}
|
|
225
|
+
</Flex>
|
|
226
|
+
{notes.map((note) => (
|
|
227
|
+
<Card key={note} padding={3} radius={2} tone="caution">
|
|
228
|
+
<Text size={1}>{note}</Text>
|
|
229
|
+
</Card>
|
|
230
|
+
))}
|
|
231
|
+
<TabList space={1}>
|
|
232
|
+
{FILTERS.map(({ id, title, test }) => (
|
|
233
|
+
<Tab
|
|
234
|
+
aria-controls="redirect-import-rows"
|
|
235
|
+
id={`redirect-import-${id}`}
|
|
236
|
+
key={id}
|
|
237
|
+
label={`${title} (${rows.filter(test).length})`}
|
|
238
|
+
onClick={() => setFilter(id)}
|
|
239
|
+
selected={filter === id}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
</TabList>
|
|
243
|
+
<Stack id="redirect-import-rows">
|
|
244
|
+
{shown.length === 0 ? (
|
|
245
|
+
<Text muted size={1}>
|
|
246
|
+
Nothing here.
|
|
247
|
+
</Text>
|
|
248
|
+
) : (
|
|
249
|
+
shown
|
|
250
|
+
.slice(0, VISIBLE_ROWS)
|
|
251
|
+
.map((row) => <RowItem key={row.line} row={row} />)
|
|
252
|
+
)}
|
|
253
|
+
{shown.length > VISIBLE_ROWS ? (
|
|
254
|
+
<Box paddingTop={3}>
|
|
255
|
+
<Text muted size={1}>
|
|
256
|
+
Showing {VISIBLE_ROWS} of {shown.length}. The report lists every
|
|
257
|
+
row.
|
|
258
|
+
</Text>
|
|
259
|
+
</Box>
|
|
260
|
+
) : null}
|
|
261
|
+
</Stack>
|
|
262
|
+
</Stack>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function RedirectImportDialog({ onClose }: { onClose: () => void }) {
|
|
267
|
+
const client = useClient({ apiVersion });
|
|
268
|
+
const input = useRef<HTMLInputElement>(null);
|
|
269
|
+
const [stage, setStage] = useState<Stage>({ kind: "pick" });
|
|
270
|
+
const [dragging, setDragging] = useState(false);
|
|
271
|
+
|
|
272
|
+
const read = async (file: File) => {
|
|
273
|
+
if (file.size > MAX_FILE_BYTES) {
|
|
274
|
+
setStage({
|
|
275
|
+
kind: "pick",
|
|
276
|
+
error: "That file is over 5 MB. Split it into smaller files.",
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
setStage({ kind: "reading", fileName: file.name });
|
|
281
|
+
let parsed: ReturnType<typeof readRedirectCsv>;
|
|
282
|
+
try {
|
|
283
|
+
parsed = readRedirectCsv(await file.text());
|
|
284
|
+
} catch {
|
|
285
|
+
setStage({ kind: "pick", error: `Couldn't open ${file.name}.` });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if ("error" in parsed) {
|
|
289
|
+
setStage({ kind: "pick", error: parsed.error });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let existing: ExistingRedirect[];
|
|
294
|
+
let pages: PAGE_SITEMAP_QUERY_RESULT;
|
|
295
|
+
try {
|
|
296
|
+
[existing, pages] = await Promise.all([
|
|
297
|
+
client
|
|
298
|
+
.withConfig({ perspective: "raw" })
|
|
299
|
+
.fetch<ExistingRedirect[]>(
|
|
300
|
+
`*[_type == "redirect"]{ _id, _rev, "source": source.current, destination, permanent }`,
|
|
301
|
+
),
|
|
302
|
+
client
|
|
303
|
+
.withConfig({ perspective: "published" })
|
|
304
|
+
.fetch<PAGE_SITEMAP_QUERY_RESULT>(PAGE_SITEMAP_QUERY, {
|
|
305
|
+
pinnedIds: PINNED_PAGE_ENTRIES.map((entry) => entry.id),
|
|
306
|
+
}),
|
|
307
|
+
]);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
setStage({
|
|
310
|
+
kind: "pick",
|
|
311
|
+
error: `Couldn't load the current redirects: ${error instanceof Error ? error.message : String(error)}`,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
setStage({
|
|
317
|
+
kind: "review",
|
|
318
|
+
fileName: file.name,
|
|
319
|
+
notes: parsed.notes,
|
|
320
|
+
rows: planRedirectImport(parsed.rows, {
|
|
321
|
+
existing,
|
|
322
|
+
routes: new Set(pages.map((page) => normalizePath(page.slug ?? ""))),
|
|
323
|
+
siteHosts: getSiteHosts(),
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const runImport = async (fileName: string, rows: PlannedRow[]) => {
|
|
329
|
+
const writes = rowsToWrite(rows);
|
|
330
|
+
setStage({ kind: "importing", fileName, written: 0, total: writes.length });
|
|
331
|
+
try {
|
|
332
|
+
const written = await commitRedirectImport(client, rows, (count) =>
|
|
333
|
+
setStage({
|
|
334
|
+
kind: "importing",
|
|
335
|
+
fileName,
|
|
336
|
+
written: count,
|
|
337
|
+
total: writes.length,
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
setStage({ kind: "done", fileName, rows, written: new Set(written) });
|
|
341
|
+
} catch (error) {
|
|
342
|
+
setStage({
|
|
343
|
+
kind: "done",
|
|
344
|
+
fileName,
|
|
345
|
+
rows,
|
|
346
|
+
written: new Set(
|
|
347
|
+
error instanceof RedirectImportError ? error.written : [],
|
|
348
|
+
),
|
|
349
|
+
error: error instanceof Error ? error.message : String(error),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const onDrop = (event: DragEvent) => {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
setDragging(false);
|
|
357
|
+
const file = event.dataTransfer.files[0];
|
|
358
|
+
if (file) void read(file);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const busy = stage.kind === "reading" || stage.kind === "importing";
|
|
362
|
+
const writes = stage.kind === "review" ? rowsToWrite(stage.rows) : [];
|
|
363
|
+
|
|
364
|
+
const footer = (
|
|
365
|
+
<Flex gap={2} justify="space-between" padding={2}>
|
|
366
|
+
{stage.kind === "review" || stage.kind === "done" ? (
|
|
367
|
+
<Button
|
|
368
|
+
icon={DownloadIcon}
|
|
369
|
+
mode="bleed"
|
|
370
|
+
onClick={() =>
|
|
371
|
+
download(
|
|
372
|
+
reportName(stage.fileName),
|
|
373
|
+
formatCsv(
|
|
374
|
+
["row", "source", "destination", "type", "result", "details"],
|
|
375
|
+
importReportRows(
|
|
376
|
+
stage.rows,
|
|
377
|
+
stage.kind === "done" ? stage.written : new Set(),
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
text="Download report"
|
|
383
|
+
/>
|
|
384
|
+
) : (
|
|
385
|
+
<Box />
|
|
386
|
+
)}
|
|
387
|
+
<Flex gap={2}>
|
|
388
|
+
{stage.kind === "review" ? (
|
|
389
|
+
<>
|
|
390
|
+
<Button
|
|
391
|
+
mode="ghost"
|
|
392
|
+
onClick={() => setStage({ kind: "pick" })}
|
|
393
|
+
text="Choose another file"
|
|
394
|
+
/>
|
|
395
|
+
<Button
|
|
396
|
+
disabled={writes.length === 0}
|
|
397
|
+
onClick={() => void runImport(stage.fileName, stage.rows)}
|
|
398
|
+
text={
|
|
399
|
+
writes.length === 0
|
|
400
|
+
? "Nothing to import"
|
|
401
|
+
: `Import ${plural(writes.length, "redirect")}`
|
|
402
|
+
}
|
|
403
|
+
tone="primary"
|
|
404
|
+
/>
|
|
405
|
+
</>
|
|
406
|
+
) : null}
|
|
407
|
+
{stage.kind === "done" ? (
|
|
408
|
+
<Button onClick={onClose} text="Close" tone="primary" />
|
|
409
|
+
) : null}
|
|
410
|
+
</Flex>
|
|
411
|
+
</Flex>
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<Dialog
|
|
416
|
+
footer={footer}
|
|
417
|
+
header={
|
|
418
|
+
stage.kind === "pick" ? "Import redirects" : `Import ${stage.fileName}`
|
|
419
|
+
}
|
|
420
|
+
id="redirect-import"
|
|
421
|
+
{...(busy ? {} : { onClickOutside: onClose, onClose })}
|
|
422
|
+
width={2}
|
|
423
|
+
>
|
|
424
|
+
<Box padding={4}>
|
|
425
|
+
{stage.kind === "pick" ? (
|
|
426
|
+
<Stack space={4}>
|
|
427
|
+
<Text size={1}>
|
|
428
|
+
A CSV with a <code>source</code> and a <code>destination</code>{" "}
|
|
429
|
+
column, and optionally <code>type</code>: permanent (308, the
|
|
430
|
+
default) or temporary (307). Paths or full URLs on this site both
|
|
431
|
+
work. Rows without a destination are skipped, and existing
|
|
432
|
+
redirects with the same source are updated.
|
|
433
|
+
</Text>
|
|
434
|
+
{stage.error ? (
|
|
435
|
+
<Card padding={3} radius={2} role="alert" tone="critical">
|
|
436
|
+
<Text size={1}>{stage.error}</Text>
|
|
437
|
+
</Card>
|
|
438
|
+
) : null}
|
|
439
|
+
<Card
|
|
440
|
+
border
|
|
441
|
+
onDragLeave={() => setDragging(false)}
|
|
442
|
+
onDragOver={(event) => {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
setDragging(true);
|
|
445
|
+
}}
|
|
446
|
+
onDrop={onDrop}
|
|
447
|
+
padding={5}
|
|
448
|
+
radius={2}
|
|
449
|
+
style={{ borderStyle: "dashed" }}
|
|
450
|
+
tone={dragging ? "primary" : "default"}
|
|
451
|
+
>
|
|
452
|
+
<Stack space={3} style={{ justifyItems: "center" }}>
|
|
453
|
+
<Button
|
|
454
|
+
icon={UploadIcon}
|
|
455
|
+
onClick={() => input.current?.click()}
|
|
456
|
+
text="Choose a CSV file"
|
|
457
|
+
/>
|
|
458
|
+
<Text muted size={1}>
|
|
459
|
+
or drop it here
|
|
460
|
+
</Text>
|
|
461
|
+
</Stack>
|
|
462
|
+
<input
|
|
463
|
+
accept=".csv,.tsv,.txt,text/csv,text/tab-separated-values,text/plain"
|
|
464
|
+
hidden
|
|
465
|
+
onChange={(event) => {
|
|
466
|
+
const file = event.currentTarget.files?.[0];
|
|
467
|
+
event.currentTarget.value = "";
|
|
468
|
+
if (file) void read(file);
|
|
469
|
+
}}
|
|
470
|
+
ref={input}
|
|
471
|
+
type="file"
|
|
472
|
+
/>
|
|
473
|
+
</Card>
|
|
474
|
+
<Box>
|
|
475
|
+
<Button
|
|
476
|
+
icon={DownloadIcon}
|
|
477
|
+
mode="bleed"
|
|
478
|
+
onClick={() =>
|
|
479
|
+
download("redirects-template.csv", REDIRECT_CSV_TEMPLATE)
|
|
480
|
+
}
|
|
481
|
+
text="Download a template"
|
|
482
|
+
/>
|
|
483
|
+
</Box>
|
|
484
|
+
</Stack>
|
|
485
|
+
) : null}
|
|
486
|
+
|
|
487
|
+
{stage.kind === "reading" ? (
|
|
488
|
+
<Flex align="center" gap={3}>
|
|
489
|
+
<Spinner muted />
|
|
490
|
+
<Text size={1}>Checking every row against the site…</Text>
|
|
491
|
+
</Flex>
|
|
492
|
+
) : null}
|
|
493
|
+
|
|
494
|
+
{stage.kind === "review" ? (
|
|
495
|
+
<Stack space={4}>
|
|
496
|
+
<Text size={1}>
|
|
497
|
+
Rows marked Error are left out. Imported redirects are published
|
|
498
|
+
straight away.
|
|
499
|
+
</Text>
|
|
500
|
+
<Review notes={stage.notes} rows={stage.rows} />
|
|
501
|
+
</Stack>
|
|
502
|
+
) : null}
|
|
503
|
+
|
|
504
|
+
{stage.kind === "importing" ? (
|
|
505
|
+
<Flex align="center" gap={3}>
|
|
506
|
+
<Spinner muted />
|
|
507
|
+
<Text size={1}>
|
|
508
|
+
Imported {stage.written} of {stage.total}… Keep this open until it
|
|
509
|
+
finishes.
|
|
510
|
+
</Text>
|
|
511
|
+
</Flex>
|
|
512
|
+
) : null}
|
|
513
|
+
|
|
514
|
+
{stage.kind === "done" ? (
|
|
515
|
+
<Stack space={4}>
|
|
516
|
+
{stage.error ? (
|
|
517
|
+
<Card padding={3} radius={2} role="alert" tone="critical">
|
|
518
|
+
<Text size={1}>
|
|
519
|
+
Imported {stage.written.size} of{" "}
|
|
520
|
+
{rowsToWrite(stage.rows).length} before the import stopped:{" "}
|
|
521
|
+
{stage.error}. Importing the same file again is safe: rows
|
|
522
|
+
already done show as Unchanged.
|
|
523
|
+
</Text>
|
|
524
|
+
</Card>
|
|
525
|
+
) : (
|
|
526
|
+
<Card padding={3} radius={2} tone="positive">
|
|
527
|
+
<Text size={1}>
|
|
528
|
+
Imported {plural(stage.written.size, "redirect")}.
|
|
529
|
+
</Text>
|
|
530
|
+
</Card>
|
|
531
|
+
)}
|
|
532
|
+
<Review notes={[]} rows={stage.rows} />
|
|
533
|
+
</Stack>
|
|
534
|
+
) : null}
|
|
535
|
+
</Box>
|
|
536
|
+
</Dialog>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
2
|
+
const DELIMITERS = [",", ";", "\t"] as const;
|
|
3
|
+
|
|
4
|
+
// Excel writes `;` where the decimal separator is a comma, and TSV exports use tabs.
|
|
5
|
+
const detectDelimiter = (text: string) => {
|
|
6
|
+
const counts = new Map<string, number>();
|
|
7
|
+
let quoted = false;
|
|
8
|
+
for (const char of text) {
|
|
9
|
+
if (char === '"') quoted = !quoted;
|
|
10
|
+
else if (!quoted && (char === "\n" || char === "\r")) break;
|
|
11
|
+
else if (!quoted) counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
12
|
+
}
|
|
13
|
+
return DELIMITERS.reduce((best, delimiter) =>
|
|
14
|
+
(counts.get(delimiter) ?? 0) > (counts.get(best) ?? 0) ? delimiter : best,
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Blank lines stay in, so a record's index + 1 is its row number in a spreadsheet.
|
|
19
|
+
export const parseCsvRecords = (input: string): string[][] => {
|
|
20
|
+
const text = input.startsWith(BOM) ? input.slice(1) : input;
|
|
21
|
+
const delimiter = detectDelimiter(text);
|
|
22
|
+
const records: string[][] = [];
|
|
23
|
+
let record: string[] = [];
|
|
24
|
+
let field = "";
|
|
25
|
+
let quoted = false;
|
|
26
|
+
let quoteRow = 0;
|
|
27
|
+
|
|
28
|
+
for (let index = 0; index < text.length; index++) {
|
|
29
|
+
const char = text[index];
|
|
30
|
+
if (quoted) {
|
|
31
|
+
if (char === '"' && text[index + 1] === '"') {
|
|
32
|
+
field += '"';
|
|
33
|
+
index++;
|
|
34
|
+
} else if (char === '"') {
|
|
35
|
+
quoted = false;
|
|
36
|
+
} else {
|
|
37
|
+
field += char;
|
|
38
|
+
}
|
|
39
|
+
} else if (char === '"') {
|
|
40
|
+
quoted = true;
|
|
41
|
+
quoteRow = records.length + 1;
|
|
42
|
+
} else if (char === delimiter) {
|
|
43
|
+
record.push(field);
|
|
44
|
+
field = "";
|
|
45
|
+
} else if (char === "\n" || char === "\r") {
|
|
46
|
+
if (char === "\r" && text[index + 1] === "\n") index++;
|
|
47
|
+
record.push(field);
|
|
48
|
+
records.push(record);
|
|
49
|
+
record = [];
|
|
50
|
+
field = "";
|
|
51
|
+
} else {
|
|
52
|
+
field += char;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Otherwise every row after it would vanish into one field.
|
|
57
|
+
if (quoted) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Row ${quoteRow} opens a quote that never closes. Close or remove it, then try again.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (field || record.length > 0) {
|
|
64
|
+
record.push(field);
|
|
65
|
+
records.push(record);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return records;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Spreadsheets run a cell starting with one of these as a formula.
|
|
72
|
+
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
|
|
73
|
+
|
|
74
|
+
const escapeField = (value: string) => {
|
|
75
|
+
const safe = FORMULA_PREFIX.test(value) ? `'${value}` : value;
|
|
76
|
+
return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const formatCsv = (
|
|
80
|
+
columns: readonly string[],
|
|
81
|
+
rows: readonly Record<string, string | number | null | undefined>[],
|
|
82
|
+
) =>
|
|
83
|
+
`${[
|
|
84
|
+
columns.join(","),
|
|
85
|
+
...rows.map((row) =>
|
|
86
|
+
columns.map((column) => escapeField(String(row[column] ?? ""))).join(","),
|
|
87
|
+
),
|
|
88
|
+
].join("\n")}\n`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
import { fetchSanity } from "@/lib/integrations/sanity/live";
|
|
3
|
+
import { BREADCRUMB_PAGES_QUERY } from "@/lib/integrations/sanity/queries";
|
|
4
|
+
import type { BREADCRUMB_PAGES_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types";
|
|
5
|
+
import { BREADCRUMBS_TAG } from "@/lib/integrations/sanity/tags";
|
|
6
|
+
import { breadcrumbPaths } from "@/lib/utils/breadcrumbs";
|
|
7
|
+
|
|
8
|
+
// Only the pages a path's crumbs can name: the homepage, its ancestors and
|
|
9
|
+
// itself. Tagged apart from the page's own slug tag: publishing an ancestor can
|
|
10
|
+
// rename a crumb.
|
|
11
|
+
export const getBreadcrumbPages = cache(
|
|
12
|
+
async (path: string | null): Promise<BREADCRUMB_PAGES_QUERY_RESULT> => {
|
|
13
|
+
const { data } = await fetchSanity<BREADCRUMB_PAGES_QUERY_RESULT>({
|
|
14
|
+
query: BREADCRUMB_PAGES_QUERY,
|
|
15
|
+
params: { paths: breadcrumbPaths(path) },
|
|
16
|
+
tags: [BREADCRUMBS_TAG],
|
|
17
|
+
stega: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return data ?? [];
|
|
21
|
+
},
|
|
22
|
+
);
|
|
@@ -31,6 +31,9 @@ type PageBuilderBlockMember = {
|
|
|
31
31
|
title: string;
|
|
32
32
|
pageTypes: readonly PageBuilderGroupName[];
|
|
33
33
|
category: ComponentCategory;
|
|
34
|
+
// Renders the page's H1, which replaces the title heading the page would
|
|
35
|
+
// otherwise render.
|
|
36
|
+
rendersPageHeading?: true;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
export const pageBuilderPageTypes: Array<{
|
|
@@ -56,6 +59,7 @@ export const pageBuilderBlockMembers: PageBuilderBlockMember[] = [
|
|
|
56
59
|
title: "Heading",
|
|
57
60
|
pageTypes: ["generic"],
|
|
58
61
|
category: "hero",
|
|
62
|
+
rendersPageHeading: true,
|
|
59
63
|
},
|
|
60
64
|
{
|
|
61
65
|
documentType: "description",
|
|
@@ -141,6 +145,17 @@ export const getInvalidPageBuilderDocumentTypes = (
|
|
|
141
145
|
return !isMemberVisibleForPageType(member, pageType);
|
|
142
146
|
});
|
|
143
147
|
|
|
148
|
+
export const countPageHeadingBlocks = (
|
|
149
|
+
blocks: ReadonlyArray<{ _key?: string; _type?: string }>,
|
|
150
|
+
hiddenKeys: readonly string[] = [],
|
|
151
|
+
) =>
|
|
152
|
+
blocks.filter(
|
|
153
|
+
(block) =>
|
|
154
|
+
!(block._key && hiddenKeys.includes(block._key)) &&
|
|
155
|
+
getPageBuilderBlockMember(block._type as PageBuilderDocumentType)
|
|
156
|
+
?.rendersPageHeading,
|
|
157
|
+
).length;
|
|
158
|
+
|
|
144
159
|
export const pageBuilderInsertMenuGroups: Array<{
|
|
145
160
|
name: ComponentCategory;
|
|
146
161
|
title: string;
|