bsmnt 0.13.1 → 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.
Files changed (177) hide show
  1. package/dist/core/create/internal-ids.d.ts +1 -0
  2. package/dist/core/create/internal-ids.d.ts.map +1 -1
  3. package/dist/core/create/internal-ids.js +1 -0
  4. package/dist/core/create/internal-ids.js.map +1 -1
  5. package/dist/domain/skills.d.ts +5 -0
  6. package/dist/domain/skills.d.ts.map +1 -1
  7. package/dist/domain/skills.js +9 -1
  8. package/dist/domain/skills.js.map +1 -1
  9. package/dist/infrastructure/create/format-project.d.ts +9 -0
  10. package/dist/infrastructure/create/format-project.d.ts.map +1 -0
  11. package/dist/infrastructure/create/format-project.js +21 -0
  12. package/dist/infrastructure/create/format-project.js.map +1 -0
  13. package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
  14. package/dist/infrastructure/create/lifecycle-registry.js +5 -0
  15. package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
  16. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  17. package/dist/modules/features/cms/sanity-pagebuilder/config.js +1 -0
  18. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  19. package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
  20. package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
  21. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  22. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.d.ts.map +1 -1
  23. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js +1 -0
  24. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js.map +1 -1
  25. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
  26. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
  27. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/agent-skills/ashby-pagebuilder-block/SKILL.md +29 -11
  30. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +31 -12
  31. package/src/agent-skills/launch-ready/SKILL.md +114 -0
  32. package/src/agent-skills/launch-ready/references/a11y.md +74 -0
  33. package/src/agent-skills/launch-ready/references/content.md +65 -0
  34. package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
  35. package/src/agent-skills/launch-ready/references/launch.md +44 -0
  36. package/src/agent-skills/launch-ready/references/metadata.md +83 -0
  37. package/src/agent-skills/launch-ready/references/performance.md +36 -0
  38. package/src/agent-skills/launch-ready/references/routing.md +47 -0
  39. package/src/agent-skills/launch-ready/references/security.md +39 -0
  40. package/src/agent-skills/page-builder-block/SKILL.md +26 -13
  41. package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
  42. package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
  43. package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
  44. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +2 -2
  45. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +4 -2
  46. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  47. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  48. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  49. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  50. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  51. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  52. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
  53. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
  54. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -2
  55. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +36 -49
  56. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +1 -50
  57. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +76 -64
  58. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
  59. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +6 -8
  60. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  61. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +1 -4
  62. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  63. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  64. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  65. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  66. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  67. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +33 -38
  68. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  69. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +0 -16
  70. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +52 -35
  71. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
  72. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
  73. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
  74. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/studio-embed.ts +22 -0
  75. package/src/modules/features/cms/sanity-pagebuilder-cache/files/app/api/revalidate/route.ts +2 -2
  76. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +4 -2
  77. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  78. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  79. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  80. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  81. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  82. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  83. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
  84. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
  85. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/icons.ts +2 -2
  86. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +36 -49
  87. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/presentation.ts +1 -50
  88. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +76 -64
  89. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
  90. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +6 -8
  91. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  92. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +1 -4
  93. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  94. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  95. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  96. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  97. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  98. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +33 -38
  99. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  100. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +0 -16
  101. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +52 -35
  102. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
  103. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
  104. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
  105. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/studio-embed.ts +22 -0
  106. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +4 -10
  107. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +10 -5
  108. package/src/modules/features/jobs/ashby/files/_sanity-adapter/job-board-block.ts +0 -7
  109. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/README.md +11 -5
  110. package/src/templates/next-default/.env.example +3 -1
  111. package/src/templates/next-default/app/layout.tsx +2 -1
  112. package/src/templates/next-default/app/robots.ts +9 -3
  113. package/src/templates/next-default/lib/utils/metadata.ts +2 -1
  114. package/src/templates/next-default/lib/utils/url.ts +20 -3
  115. package/src/templates/next-default/next.config.ts +16 -0
  116. package/src/templates/next-experiments/.env.example +3 -1
  117. package/src/templates/next-experiments/app/layout.tsx +2 -1
  118. package/src/templates/next-experiments/app/robots.ts +9 -3
  119. package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
  120. package/src/templates/next-experiments/lib/utils/url.ts +20 -3
  121. package/src/templates/next-experiments/next.config.ts +16 -0
  122. package/src/templates/next-pagebuilder/.env.example +3 -1
  123. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
  124. package/src/templates/next-pagebuilder/AGENTS.md +22 -7
  125. package/src/templates/next-pagebuilder/README.md +15 -3
  126. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
  127. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +5 -4
  128. package/src/templates/next-pagebuilder/app/components/page.tsx +81 -0
  129. package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
  130. package/src/templates/next-pagebuilder/app/robots.ts +9 -3
  131. package/src/templates/next-pagebuilder/biome.json +2 -1
  132. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
  133. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +8 -3
  134. package/src/templates/next-pagebuilder/components/page-builder/library.ts +183 -0
  135. package/src/templates/next-pagebuilder/components/page-builder/registry.tsx +4 -1
  136. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -1
  137. package/src/templates/next-pagebuilder/components/page-builder/stage.tsx +10 -0
  138. package/src/templates/next-pagebuilder/components/page-document/index.tsx +28 -35
  139. package/src/templates/next-pagebuilder/lefthook.yml +1 -1
  140. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +170 -299
  141. package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
  142. package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
  143. package/src/templates/next-pagebuilder/next.config.ts +16 -0
  144. package/src/templates/next-pagebuilder/package.json +1 -1
  145. package/src/templates/next-pagebuilder-cache/.env.example +3 -1
  146. package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
  147. package/src/templates/next-pagebuilder-cache/AGENTS.md +22 -7
  148. package/src/templates/next-pagebuilder-cache/README.md +15 -3
  149. package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
  150. package/src/templates/next-pagebuilder-cache/app/actions/unlock-gated-content.ts +5 -4
  151. package/src/templates/next-pagebuilder-cache/app/components/page.tsx +91 -0
  152. package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
  153. package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
  154. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  155. package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
  156. package/src/templates/next-pagebuilder-cache/components/page-builder/components/gated-content/index.tsx +8 -3
  157. package/src/templates/next-pagebuilder-cache/components/page-builder/library.ts +183 -0
  158. package/src/templates/next-pagebuilder-cache/components/page-builder/registry.tsx +4 -1
  159. package/src/templates/next-pagebuilder-cache/components/page-builder/renderer.tsx +3 -1
  160. package/src/templates/next-pagebuilder-cache/components/page-builder/stage.tsx +10 -0
  161. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +28 -35
  162. package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
  163. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +170 -299
  164. package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
  165. package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
  166. package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
  167. package/src/templates/next-pagebuilder-cache/package.json +1 -1
  168. package/src/templates/next-webgl/.env.example +3 -1
  169. package/src/templates/next-webgl/app/layout.tsx +2 -1
  170. package/src/templates/next-webgl/app/robots.ts +9 -3
  171. package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
  172. package/src/templates/next-webgl/lib/utils/url.ts +20 -3
  173. package/src/templates/next-webgl/next.config.ts +16 -0
  174. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  175. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
  176. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  177. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
@@ -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
+ );
@@ -12,6 +12,7 @@ import {
12
12
  BuildingsIcon,
13
13
  CalendarBlankIcon,
14
14
  CardsIcon,
15
+ CubeIcon,
15
16
  FileTextIcon,
16
17
  FolderOpenIcon,
17
18
  FootprintsIcon,
@@ -19,7 +20,6 @@ import {
19
20
  ListBulletsIcon,
20
21
  ListIcon,
21
22
  LockKeyIcon,
22
- PackageIcon,
23
23
  QuestionIcon,
24
24
  RocketIcon,
25
25
  ShapesIcon,
@@ -45,7 +45,6 @@ const asDuotoneIcon = (IconComponent: Icon): SanityStudioIcon => {
45
45
  return DuotoneIcon;
46
46
  };
47
47
 
48
- export const pageBuilderCardIcon = asDuotoneIcon(PackageIcon);
49
48
  export const pageBuilderCardListIcon = asDuotoneIcon(ListIcon);
50
49
 
51
50
  export const authorIcon = asDuotoneIcon(UserCircleIcon);
@@ -54,6 +53,7 @@ export const pageListIcon = asDuotoneIcon(ListBulletsIcon);
54
53
 
55
54
  export const blogCategoryIcon = asDuotoneIcon(TagIcon);
56
55
 
56
+ export const componentIcon = asDuotoneIcon(CubeIcon);
57
57
  export const componentsFolderIcon = asDuotoneIcon(ShapesIcon);
58
58
 
59
59
  export const companyDataIcon = asDuotoneIcon(BuildingsIcon);