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,651 @@
1
+ import type { SanityClient } from "sanity";
2
+ import { parseCsvRecords } from "./csv";
3
+ import {
4
+ REDIRECT_PATH_PATTERN,
5
+ REDIRECT_SOURCE_MAX_LENGTH,
6
+ } from "./schemas/layout/redirect";
7
+
8
+ export const MAX_FILE_BYTES = 5 * 1024 * 1024;
9
+ const MAX_ROWS = 5000;
10
+ const IMPORT_BATCH_SIZE = 100;
11
+
12
+ const PLACEHOLDER_ORIGIN = "https://redirect.invalid";
13
+ const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd);
14
+ const SCHEME = /^[a-z][a-z\d+.-]*:/i;
15
+ const ABSOLUTE_URL = /^https?:\/\//i;
16
+
17
+ export const REDIRECT_CSV_TEMPLATE = `source,destination,type
18
+ old-page,new-page,permanent
19
+ /blog/old-post,/blog/new-post,308
20
+ /retired-section,/,permanent
21
+ spring-promo,https://example.com/campaign,temporary
22
+ `;
23
+
24
+ const HEADERS = {
25
+ source: [
26
+ "source",
27
+ "from",
28
+ "old",
29
+ "old url",
30
+ "old path",
31
+ "source url",
32
+ "source path",
33
+ "redirect from",
34
+ "url",
35
+ ],
36
+ destination: [
37
+ "destination",
38
+ "to",
39
+ "target",
40
+ "new",
41
+ "new url",
42
+ "new path",
43
+ "destination url",
44
+ "target url",
45
+ "redirect to",
46
+ ],
47
+ type: ["type", "status", "code", "status code", "redirect type"],
48
+ };
49
+
50
+ const PERMANENT: Record<string, boolean> = {
51
+ "": true,
52
+ permanent: true,
53
+ "301": true,
54
+ "308": true,
55
+ temporary: false,
56
+ "302": false,
57
+ "303": false,
58
+ "307": false,
59
+ };
60
+
61
+ interface CsvRedirectRow {
62
+ line: number;
63
+ source: string;
64
+ destination: string;
65
+ type: string;
66
+ }
67
+
68
+ type ReadRedirectCsvResult =
69
+ | { rows: CsvRedirectRow[]; notes: string[] }
70
+ | { error: string };
71
+
72
+ const headerKey = (value: string) =>
73
+ value
74
+ .trim()
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, " ")
77
+ .trim();
78
+
79
+ const looksLikeUrl = (value: string) => /^(\/|https?:\/\/)/i.test(value.trim());
80
+
81
+ export const readRedirectCsv = (text: string): ReadRedirectCsvResult => {
82
+ if (text.startsWith("PK") || text.includes("\u0000")) {
83
+ return {
84
+ error:
85
+ "This looks like a spreadsheet file rather than CSV. Export it as CSV (UTF-8) and try again.",
86
+ };
87
+ }
88
+
89
+ let records: string[][];
90
+ try {
91
+ records = parseCsvRecords(text);
92
+ } catch (error) {
93
+ return { error: error instanceof Error ? error.message : String(error) };
94
+ }
95
+ const firstIndex = records.findIndex((record) =>
96
+ record.some((value) => value.trim()),
97
+ );
98
+ const first = records[firstIndex];
99
+ if (!first) return { error: "The file is empty." };
100
+
101
+ const keys = first.map(headerKey);
102
+ const column = (names: string[]) =>
103
+ keys.findIndex((key) => names.includes(key));
104
+ let columns = {
105
+ source: column(HEADERS.source),
106
+ destination: column(HEADERS.destination),
107
+ type: column(HEADERS.type),
108
+ };
109
+ let bodyStart = firstIndex + 1;
110
+ const notes: string[] = [];
111
+
112
+ if (columns.source < 0 || columns.destination < 0) {
113
+ if (!(looksLikeUrl(first[0] ?? "") && first.length >= 2)) {
114
+ return {
115
+ error: `Couldn't find the source and destination columns. The first row reads: ${first.join(", ")}.`,
116
+ };
117
+ }
118
+ columns = { source: 0, destination: 1, type: 2 };
119
+ bodyStart = firstIndex;
120
+ notes.push(
121
+ "There's no header row, so the columns are read as source, destination, type.",
122
+ );
123
+ }
124
+
125
+ const cell = (record: string[], index: number) =>
126
+ index < 0 ? "" : (record[index]?.trim() ?? "");
127
+ const rows = records.flatMap((record, index) =>
128
+ index >= bodyStart && record.some((value) => value.trim())
129
+ ? [
130
+ {
131
+ line: index + 1,
132
+ source: cell(record, columns.source),
133
+ destination: cell(record, columns.destination),
134
+ type: cell(record, columns.type),
135
+ },
136
+ ]
137
+ : [],
138
+ );
139
+
140
+ if (rows.length === 0) return { error: "The file has no redirects in it." };
141
+ if (rows.length > MAX_ROWS) {
142
+ return {
143
+ error: `The file has ${rows.length} redirects; split it into files of ${MAX_ROWS} or fewer.`,
144
+ };
145
+ }
146
+ return { rows, notes };
147
+ };
148
+
149
+ const isAbsoluteUrl = (value: string) => ABSOLUTE_URL.test(value);
150
+
151
+ // Redirect sources and internal destinations are stored without slashes at either end.
152
+ export const normalizePath = (path: string) =>
153
+ path.replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
154
+
155
+ export const describePath = (value: string) =>
156
+ isAbsoluteUrl(value) ? value : `/${normalizePath(value)}`;
157
+
158
+ const destinationKey = (destination: string) =>
159
+ isAbsoluteUrl(destination) ? destination : normalizePath(destination);
160
+
161
+ // Answered by their own routes; the catch-all that checks redirects never sees them.
162
+ const isReservedPath = (path: string) => {
163
+ const [first] = path.split("/");
164
+ return first === "studio" || first === "api" || path === "components";
165
+ };
166
+
167
+ type Parsed = { value: string; notes: string[] } | { error: string };
168
+
169
+ const parsePath = (
170
+ raw: string,
171
+ role: "source" | "destination",
172
+ siteHosts: readonly string[],
173
+ ): Parsed | { external: string } => {
174
+ if (raw.includes(REPLACEMENT_CHARACTER)) {
175
+ return {
176
+ error:
177
+ "has characters that didn't survive the file's encoding. Save the file as CSV (UTF-8)",
178
+ };
179
+ }
180
+ const absolute = raw.startsWith("//") || isAbsoluteUrl(raw);
181
+ if (!absolute && SCHEME.test(raw)) {
182
+ return { error: `"${raw}" isn't a path or a web address` };
183
+ }
184
+
185
+ let url: URL;
186
+ try {
187
+ url = new URL(
188
+ raw.startsWith("//") ? `https:${raw}` : raw,
189
+ `${PLACEHOLDER_ORIGIN}/`,
190
+ );
191
+ } catch {
192
+ return { error: `"${raw}" isn't a valid address` };
193
+ }
194
+ if (absolute && !siteHosts.includes(url.host)) {
195
+ return role === "source"
196
+ ? { error: `is on ${url.host}, not this site` }
197
+ : { external: url.href };
198
+ }
199
+ if (url.search) {
200
+ return {
201
+ error:
202
+ role === "source"
203
+ ? `has a query string (${url.search}), which redirects can't match`
204
+ : `has a query string (${url.search}), which only a full URL to another site can keep`,
205
+ };
206
+ }
207
+
208
+ const notes: string[] = [];
209
+ if (url.hash) {
210
+ notes.push(
211
+ role === "source"
212
+ ? `Dropped ${url.hash} from the source: browsers never send the anchor.`
213
+ : `Dropped ${url.hash} from the destination: a path can't carry an anchor.`,
214
+ );
215
+ }
216
+ const path = normalizePath(url.pathname);
217
+ if (path && !REDIRECT_PATH_PATTERN.test(path)) {
218
+ return {
219
+ error: `/${path} can only use lowercase letters, numbers, hyphens and slashes`,
220
+ };
221
+ }
222
+ return { value: path, notes };
223
+ };
224
+
225
+ const parseSource = (raw: string, siteHosts: readonly string[]): Parsed => {
226
+ const parsed = parsePath(raw, "source", siteHosts);
227
+ if ("external" in parsed) return { error: "isn't on this site" };
228
+ if ("error" in parsed) return parsed;
229
+ if (!parsed.value) return { error: "is the homepage, which can't redirect" };
230
+ if (isReservedPath(parsed.value)) {
231
+ return {
232
+ error: `/${parsed.value} is answered before redirects run, so it can't redirect`,
233
+ };
234
+ }
235
+ if (parsed.value.length > REDIRECT_SOURCE_MAX_LENGTH) {
236
+ return { error: `is longer than ${REDIRECT_SOURCE_MAX_LENGTH} characters` };
237
+ }
238
+ return parsed;
239
+ };
240
+
241
+ const parseDestination = (
242
+ raw: string,
243
+ siteHosts: readonly string[],
244
+ ): Parsed => {
245
+ const parsed = parsePath(raw, "destination", siteHosts);
246
+ if ("external" in parsed) return { value: parsed.external, notes: [] };
247
+ if ("error" in parsed) return parsed;
248
+ return { value: parsed.value || "/", notes: parsed.notes };
249
+ };
250
+
251
+ export interface ExistingRedirect {
252
+ _id: string;
253
+ _rev: string;
254
+ source: string | null;
255
+ destination: string | null;
256
+ permanent: boolean | null;
257
+ }
258
+
259
+ interface PlannedRedirect {
260
+ _id: string;
261
+ source: string;
262
+ destination: string;
263
+ permanent: boolean;
264
+ }
265
+
266
+ export type RowStatus =
267
+ | "create"
268
+ | "update"
269
+ | "unchanged"
270
+ | "duplicate"
271
+ | "skipped"
272
+ | "error";
273
+
274
+ export interface PlannedRow extends CsvRedirectRow {
275
+ status: RowStatus;
276
+ problem?: string;
277
+ notes: string[];
278
+ redirect?: PlannedRedirect;
279
+ previous?: { destination: string; permanent: boolean };
280
+ revision?: string;
281
+ }
282
+
283
+ interface PlanContext {
284
+ existing: readonly ExistingRedirect[];
285
+ /** Normalized paths of published pages; the homepage is "". */
286
+ routes: ReadonlySet<string>;
287
+ siteHosts: readonly string[];
288
+ }
289
+
290
+ const baseId = (id: string) => id.replace(/^(drafts|versions\.[^.]+)\./, "");
291
+
292
+ // cyrb53, since the Studio has no node:crypto; a re-run derives the same id.
293
+ const hash = (value: string) => {
294
+ let h1 = 0xdeadbeef;
295
+ let h2 = 0x41c6ce57;
296
+ for (let index = 0; index < value.length; index++) {
297
+ const code = value.charCodeAt(index);
298
+ h1 = Math.imul(h1 ^ code, 2654435761);
299
+ h2 = Math.imul(h2 ^ code, 1597334677);
300
+ }
301
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
302
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
303
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
304
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
305
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
306
+ };
307
+
308
+ const fail = (row: PlannedRow, problem: string) => {
309
+ row.status = "error";
310
+ row.problem = problem;
311
+ delete row.redirect;
312
+ };
313
+
314
+ const isWrite = (
315
+ row: PlannedRow,
316
+ ): row is PlannedRow & { redirect: PlannedRedirect } =>
317
+ row.redirect !== undefined &&
318
+ (row.status === "create" || row.status === "update");
319
+
320
+ /**
321
+ * Where a visitor to `source` ends up, or null for a loop. The catch-all only
322
+ * checks redirects when no page matches, so a chain stops at the first page.
323
+ */
324
+ const finalDestination = (
325
+ rules: ReadonlyMap<string, string>,
326
+ routes: ReadonlySet<string>,
327
+ source: string,
328
+ ) => {
329
+ const seen = new Set([source]);
330
+ let destination = rules.get(source) ?? "";
331
+ for (;;) {
332
+ if (isAbsoluteUrl(destination)) return destination;
333
+ const path = normalizePath(destination);
334
+ if (routes.has(path)) return path;
335
+ const next = rules.get(path);
336
+ if (next === undefined) return path;
337
+ if (seen.has(path)) return null;
338
+ seen.add(path);
339
+ destination = next;
340
+ }
341
+ };
342
+
343
+ export const planRedirectImport = (
344
+ csvRows: readonly CsvRedirectRow[],
345
+ { existing, routes, siteHosts }: PlanContext,
346
+ ): PlannedRow[] => {
347
+ const rows: PlannedRow[] = csvRows.map((row) => {
348
+ const planned: PlannedRow = { ...row, status: "create", notes: [] };
349
+ if (!row.source) {
350
+ fail(planned, "No source.");
351
+ return planned;
352
+ }
353
+ const source = parseSource(row.source, siteHosts);
354
+ if ("error" in source) {
355
+ fail(planned, `The source ${source.error}.`);
356
+ return planned;
357
+ }
358
+ planned.notes.push(...source.notes);
359
+
360
+ if (!row.destination) {
361
+ planned.status = "skipped";
362
+ planned.problem = "No destination yet.";
363
+ return planned;
364
+ }
365
+ const permanent = PERMANENT[row.type.toLowerCase()];
366
+ if (permanent === undefined) {
367
+ fail(
368
+ planned,
369
+ `Unknown type "${row.type}". Use permanent (308) or temporary (307).`,
370
+ );
371
+ return planned;
372
+ }
373
+ const destination = parseDestination(row.destination, siteHosts);
374
+ if ("error" in destination) {
375
+ fail(planned, `The destination ${destination.error}.`);
376
+ return planned;
377
+ }
378
+ planned.notes.push(...destination.notes);
379
+
380
+ if (destinationKey(destination.value) === source.value) {
381
+ fail(planned, "The destination is the source itself.");
382
+ return planned;
383
+ }
384
+
385
+ planned.redirect = {
386
+ _id: "",
387
+ source: source.value,
388
+ destination: destination.value,
389
+ permanent,
390
+ };
391
+ return planned;
392
+ });
393
+
394
+ const bySource = new Map<string, PlannedRow[]>();
395
+ for (const row of rows) {
396
+ if (!row.redirect) continue;
397
+ const group = bySource.get(row.redirect.source) ?? [];
398
+ group.push(row);
399
+ bySource.set(row.redirect.source, group);
400
+ }
401
+ for (const [source, group] of bySource) {
402
+ const [first, ...rest] = group;
403
+ if (!first?.redirect || rest.length === 0) continue;
404
+ const { destination, permanent } = first.redirect;
405
+ const agrees = rest.every(
406
+ (row) =>
407
+ row.redirect &&
408
+ destinationKey(row.redirect.destination) ===
409
+ destinationKey(destination) &&
410
+ row.redirect.permanent === permanent,
411
+ );
412
+ if (agrees) {
413
+ for (const row of rest) {
414
+ row.status = "duplicate";
415
+ row.problem = `Same as row ${first.line}.`;
416
+ delete row.redirect;
417
+ }
418
+ continue;
419
+ }
420
+ const lines = group.map((row) => row.line).join(", ");
421
+ for (const row of group) {
422
+ fail(row, `Rows ${lines} send /${source} to different places.`);
423
+ }
424
+ }
425
+
426
+ const published = existing.filter(({ _id }) => baseId(_id) === _id);
427
+ const publishedIds = new Set(published.map(({ _id }) => _id));
428
+ const pendingIds = new Set(
429
+ existing
430
+ .filter(({ _id }) => baseId(_id) !== _id)
431
+ .map(({ _id }) => baseId(_id)),
432
+ );
433
+ const takenIds = new Set(existing.map(({ _id }) => baseId(_id)));
434
+ const publishedBySource = new Map<string, ExistingRedirect[]>();
435
+ const unpublishedSources = new Set<string>();
436
+ for (const redirect of existing) {
437
+ const key = normalizePath(redirect.source ?? "");
438
+ if (publishedIds.has(redirect._id)) {
439
+ publishedBySource.set(key, [
440
+ ...(publishedBySource.get(key) ?? []),
441
+ redirect,
442
+ ]);
443
+ } else {
444
+ // Includes drafts of published redirects, whose source may have changed.
445
+ unpublishedSources.add(key);
446
+ }
447
+ }
448
+
449
+ for (const row of rows) {
450
+ const redirect = row.redirect;
451
+ if (!redirect) continue;
452
+ const [current, ...others] = publishedBySource.get(redirect.source) ?? [];
453
+
454
+ if (!current) {
455
+ if (unpublishedSources.has(redirect.source)) {
456
+ fail(
457
+ row,
458
+ `An unpublished redirect in the Studio already uses /${redirect.source}. Publish or discard it, then import again.`,
459
+ );
460
+ continue;
461
+ }
462
+ let id = `redirect-${hash(redirect.source)}`;
463
+ for (let suffix = 2; takenIds.has(id); suffix++) {
464
+ id = `redirect-${hash(redirect.source)}-${suffix}`;
465
+ }
466
+ takenIds.add(id);
467
+ redirect._id = id;
468
+ continue;
469
+ }
470
+
471
+ if (pendingIds.has(current._id)) {
472
+ fail(
473
+ row,
474
+ `/${redirect.source} has unpublished changes in the Studio. Publish or discard them, then import again.`,
475
+ );
476
+ continue;
477
+ }
478
+ // The site follows whichever one it reads first, so an update can't be trusted to win.
479
+ if (others.length > 0) {
480
+ fail(
481
+ row,
482
+ `${others.length + 1} published redirects already use /${redirect.source}. Delete all but one, then import again.`,
483
+ );
484
+ continue;
485
+ }
486
+ redirect._id = current._id;
487
+ row.revision = current._rev;
488
+ const previous = {
489
+ destination: current.destination ?? "",
490
+ permanent: current.permanent !== false,
491
+ };
492
+ if (
493
+ destinationKey(previous.destination) ===
494
+ destinationKey(redirect.destination) &&
495
+ previous.permanent === redirect.permanent
496
+ ) {
497
+ row.status = "unchanged";
498
+ continue;
499
+ }
500
+ row.status = "update";
501
+ row.previous = previous;
502
+ }
503
+
504
+ const compile = () => {
505
+ const writes = rows.filter(isWrite);
506
+ const replaced = new Set(writes.map(({ redirect }) => redirect._id));
507
+ const rules = new Map<string, string>();
508
+ for (const { _id, source, destination } of published) {
509
+ if (!replaced.has(_id) && source && destination) {
510
+ rules.set(normalizePath(source), destination);
511
+ }
512
+ }
513
+ for (const { redirect } of writes) {
514
+ rules.set(redirect.source, redirect.destination);
515
+ }
516
+ return { writes, rules };
517
+ };
518
+
519
+ for (let changed = true; changed; ) {
520
+ changed = false;
521
+ const { writes, rules } = compile();
522
+ for (const row of writes) {
523
+ if (finalDestination(rules, routes, row.redirect.source) === null) {
524
+ fail(row, "This would send visitors round in a redirect loop.");
525
+ changed = true;
526
+ }
527
+ }
528
+ }
529
+
530
+ const { writes, rules } = compile();
531
+ for (const row of writes) {
532
+ const { source, destination } = row.redirect;
533
+ if (routes.has(source)) {
534
+ row.notes.push(
535
+ `A published page lives at /${source}, so the redirect only takes effect once that page is unpublished.`,
536
+ );
537
+ }
538
+ const final = finalDestination(rules, routes, source);
539
+ if (final === null || isAbsoluteUrl(final)) continue;
540
+ if (final !== destinationKey(destination)) {
541
+ row.notes.push(
542
+ `${describePath(destination)} redirects again, so visitors end up at ${describePath(final)}.`,
543
+ );
544
+ }
545
+ if (!routes.has(final) && !isReservedPath(final)) {
546
+ row.notes.push(`Nothing is published at ${describePath(final)} yet.`);
547
+ }
548
+ }
549
+
550
+ return rows;
551
+ };
552
+
553
+ export const rowsToWrite = (rows: readonly PlannedRow[]) =>
554
+ rows.filter(isWrite);
555
+
556
+ export class RedirectImportError extends Error {
557
+ constructor(
558
+ message: string,
559
+ readonly written: ReadonlySet<PlannedRow>,
560
+ ) {
561
+ super(message);
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Updates go first, in one transaction: only an update can break an existing
567
+ * chain, so every state the site passes through is a subset of the loop-free
568
+ * plan, even if a later batch fails. Creates follow in batches.
569
+ */
570
+ export const commitRedirectImport = async (
571
+ client: SanityClient,
572
+ rows: readonly PlannedRow[],
573
+ onProgress: (written: number) => void,
574
+ ) => {
575
+ const writes = rowsToWrite(rows);
576
+ const updates = writes.filter((row) => row.status === "update");
577
+ const creates = writes.filter((row) => row.status === "create");
578
+ const batches = updates.length > 0 ? [updates] : [];
579
+ for (let index = 0; index < creates.length; index += IMPORT_BATCH_SIZE) {
580
+ batches.push(creates.slice(index, index + IMPORT_BATCH_SIZE));
581
+ }
582
+
583
+ const written = new Set<PlannedRow>();
584
+ for (const batch of batches) {
585
+ const transaction = client.transaction();
586
+ for (const row of batch) {
587
+ const { _id, source, destination, permanent } = row.redirect;
588
+ if (row.status === "create") {
589
+ transaction.create({
590
+ _id,
591
+ _type: "redirect",
592
+ source: { _type: "slug", current: source },
593
+ destination,
594
+ permanent,
595
+ });
596
+ } else {
597
+ transaction.patch(_id, (patch) =>
598
+ (row.revision ? patch.ifRevisionId(row.revision) : patch).set({
599
+ destination,
600
+ permanent,
601
+ }),
602
+ );
603
+ }
604
+ }
605
+
606
+ try {
607
+ await transaction.commit();
608
+ } catch (error) {
609
+ throw new RedirectImportError(
610
+ error instanceof Error ? error.message : String(error),
611
+ written,
612
+ );
613
+ }
614
+ for (const row of batch) written.add(row);
615
+ onProgress(written.size);
616
+ }
617
+
618
+ return written;
619
+ };
620
+
621
+ const RESULT_LABELS: Record<RowStatus, string> = {
622
+ create: "New",
623
+ update: "Changed",
624
+ unchanged: "Unchanged",
625
+ duplicate: "Duplicate",
626
+ skipped: "Skipped",
627
+ error: "Error",
628
+ };
629
+
630
+ export const statusLabel = (status: RowStatus) => RESULT_LABELS[status];
631
+
632
+ const resultOf = (row: PlannedRow, written: ReadonlySet<PlannedRow>) => {
633
+ if (row.status !== "create" && row.status !== "update") {
634
+ return RESULT_LABELS[row.status];
635
+ }
636
+ if (!written.has(row)) return "Not written";
637
+ return row.status === "create" ? "Created" : "Updated";
638
+ };
639
+
640
+ export const importReportRows = (
641
+ rows: readonly PlannedRow[],
642
+ written: ReadonlySet<PlannedRow>,
643
+ ) =>
644
+ rows.map((row) => ({
645
+ row: row.line,
646
+ source: row.source,
647
+ destination: row.destination,
648
+ type: row.type,
649
+ result: resultOf(row, written),
650
+ details: [row.problem, ...row.notes].filter(Boolean).join(" "),
651
+ }));
@@ -2,12 +2,12 @@ import { table } from "@sanity/table";
2
2
  import { defineConfig } from "sanity";
3
3
  import { structureTool } from "sanity/structure";
4
4
  import { media } from "sanity-plugin-media";
5
+ import { StudioLayout } from "./components/redirect-import-dialog";
5
6
  import { createConfirmPublishAction } from "./confirm-publish-action";
6
7
  import { dataset, projectId } from "./env";
7
8
  import { PINNED_PAGE_ENTRIES, PINNED_PAGE_IDS } from "./pinned-pages";
8
9
  import { presentation } from "./presentation";
9
10
  import { schema } from "./schemas";
10
- import { singletonComponentTypes } from "./singletons";
11
11
  import { structure } from "./structure";
12
12
 
13
13
  const isProd = process.env.NODE_ENV === "production";
@@ -19,10 +19,9 @@ const singletonDocumentActions = new Set([
19
19
  ]);
20
20
 
21
21
  const isSingletonDocument = (schemaType: string, documentId?: string) =>
22
- singletonComponentTypes.has(schemaType) ||
23
- (schemaType === "page" &&
24
- documentId !== undefined &&
25
- PINNED_PAGE_IDS.has(documentId));
22
+ schemaType === "page" &&
23
+ documentId !== undefined &&
24
+ PINNED_PAGE_IDS.has(documentId);
26
25
 
27
26
  export default defineConfig({
28
27
  basePath: "/studio",
@@ -107,10 +106,9 @@ export default defineConfig({
107
106
  },
108
107
  newDocumentOptions: (previousOptions) =>
109
108
  previousOptions.filter(
110
- (option) =>
111
- !singletonComponentTypes.has(option.templateId) &&
112
- !PINNED_PAGE_IDS.has(option.templateId),
109
+ (option) => !PINNED_PAGE_IDS.has(option.templateId),
113
110
  ),
114
111
  },
112
+ studio: { components: { layout: StudioLayout } },
115
113
  plugins: [structureTool({ structure }), presentation, media(), table()],
116
114
  });
@@ -4,4 +4,3 @@ export { gatedContent } from "./reusable/gated-content";
4
4
  export { hero } from "./reusable/hero";
5
5
  export { richTextBlock } from "./reusable/rich-text-block";
6
6
  export { blogCollection } from "./singleton/content-collection";
7
- export { studioPreview } from "./studio-preview";