bsmnt 0.4.3 → 0.5.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 (187) hide show
  1. package/dist/application/add-integration/index.d.ts +24 -6
  2. package/dist/application/add-integration/index.d.ts.map +1 -1
  3. package/dist/application/add-integration/index.js +130 -82
  4. package/dist/application/add-integration/index.js.map +1 -1
  5. package/dist/application/create-project/map-selection-to-registry.js +1 -1
  6. package/dist/application/create-project/map-selection-to-registry.js.map +1 -1
  7. package/dist/application/shared/project-detection.d.ts +2 -2
  8. package/dist/application/shared/project-detection.d.ts.map +1 -1
  9. package/dist/application/shared/project-detection.js +2 -2
  10. package/dist/application/shared/project-detection.js.map +1 -1
  11. package/dist/core/create/internal-ids.d.ts +4 -1
  12. package/dist/core/create/internal-ids.d.ts.map +1 -1
  13. package/dist/core/create/internal-ids.js +6 -1
  14. package/dist/core/create/internal-ids.js.map +1 -1
  15. package/dist/domain/cms.d.ts +0 -7
  16. package/dist/domain/cms.d.ts.map +1 -1
  17. package/dist/domain/cms.js.map +1 -1
  18. package/dist/domain/forms.d.ts +17 -0
  19. package/dist/domain/forms.d.ts.map +1 -0
  20. package/dist/domain/forms.js +13 -0
  21. package/dist/domain/forms.js.map +1 -0
  22. package/dist/domain/integration.d.ts +30 -0
  23. package/dist/domain/integration.d.ts.map +1 -0
  24. package/dist/domain/integration.js +12 -0
  25. package/dist/domain/integration.js.map +1 -0
  26. package/dist/domain/skills.d.ts +32 -0
  27. package/dist/domain/skills.d.ts.map +1 -1
  28. package/dist/domain/skills.js +39 -0
  29. package/dist/domain/skills.js.map +1 -1
  30. package/dist/index.js +95 -37
  31. package/dist/index.js.map +1 -1
  32. package/dist/infrastructure/agents/register-sanity-mcp.js +9 -6
  33. package/dist/infrastructure/agents/register-sanity-mcp.js.map +1 -1
  34. package/dist/infrastructure/create/apply-package-json.d.ts.map +1 -1
  35. package/dist/infrastructure/create/apply-package-json.js +19 -0
  36. package/dist/infrastructure/create/apply-package-json.js.map +1 -1
  37. package/dist/infrastructure/create/install-dependencies.d.ts.map +1 -1
  38. package/dist/infrastructure/create/install-dependencies.js +4 -0
  39. package/dist/infrastructure/create/install-dependencies.js.map +1 -1
  40. package/dist/infrastructure/create/merge-registry.d.ts.map +1 -1
  41. package/dist/infrastructure/create/merge-registry.js +8 -0
  42. package/dist/infrastructure/create/merge-registry.js.map +1 -1
  43. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  44. package/dist/infrastructure/create/setup-agent.js +29 -6
  45. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  46. package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
  47. package/dist/infrastructure/create/setup-sanity.js +3 -0
  48. package/dist/infrastructure/create/setup-sanity.js.map +1 -1
  49. package/dist/infrastructure/integrations/inject-integration.d.ts +3 -3
  50. package/dist/infrastructure/integrations/inject-integration.d.ts.map +1 -1
  51. package/dist/infrastructure/integrations/inject-integration.js +81 -37
  52. package/dist/infrastructure/integrations/inject-integration.js.map +1 -1
  53. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  54. package/dist/modules/features/cms/sanity/config.js +11 -5
  55. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  56. package/dist/modules/features/cms/sanity/index.js +8 -2
  57. package/dist/modules/features/cms/sanity/index.js.map +1 -1
  58. package/dist/modules/features/cms/sanity/mergers/ci-workflow.d.ts +13 -0
  59. package/dist/modules/features/cms/sanity/mergers/ci-workflow.d.ts.map +1 -0
  60. package/dist/modules/features/cms/sanity/mergers/ci-workflow.js +38 -0
  61. package/dist/modules/features/cms/sanity/mergers/ci-workflow.js.map +1 -0
  62. package/dist/modules/features/cms/sanity/mergers/knip.d.ts +12 -0
  63. package/dist/modules/features/cms/sanity/mergers/knip.d.ts.map +1 -0
  64. package/dist/modules/features/cms/sanity/mergers/knip.js +38 -0
  65. package/dist/modules/features/cms/sanity/mergers/knip.js.map +1 -0
  66. package/dist/modules/features/cms/sanity/mergers/layout.d.ts.map +1 -1
  67. package/dist/modules/features/cms/sanity/mergers/layout.js +7 -3
  68. package/dist/modules/features/cms/sanity/mergers/layout.js.map +1 -1
  69. package/dist/modules/features/cms/sanity/mergers/sitemap.d.ts +3 -3
  70. package/dist/modules/features/cms/sanity/mergers/sitemap.d.ts.map +1 -1
  71. package/dist/modules/features/cms/sanity/mergers/sitemap.js +48 -62
  72. package/dist/modules/features/cms/sanity/mergers/sitemap.js.map +1 -1
  73. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  74. package/dist/modules/features/cms/sanity-pagebuilder/config.js +8 -5
  75. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  76. package/dist/modules/features/cms/sanity-pagebuilder/index.js +2 -2
  77. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  78. package/dist/modules/features/forms/hubspot/config.d.ts +31 -0
  79. package/dist/modules/features/forms/hubspot/config.d.ts.map +1 -0
  80. package/dist/modules/features/forms/hubspot/config.js +25 -0
  81. package/dist/modules/features/forms/hubspot/config.js.map +1 -0
  82. package/dist/paths.d.ts +8 -0
  83. package/dist/paths.d.ts.map +1 -1
  84. package/dist/paths.js +8 -0
  85. package/dist/paths.js.map +1 -1
  86. package/package.json +17 -9
  87. package/src/assets/check-node-version.mjs +22 -0
  88. package/src/modules/features/cms/sanity/files/app/blog/page.tsx +54 -0
  89. package/src/modules/features/cms/sanity/files/app/feed.xml/route.ts +36 -0
  90. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/components/disable-draft-mode.tsx +3 -3
  91. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/article.ts +7 -7
  92. package/src/modules/features/cms/sanity/files/lib/scripts/setup-ci-secrets.ts +117 -0
  93. package/src/modules/features/cms/sanity/files/lib/utils/rss.ts +96 -0
  94. package/src/modules/features/cms/sanity-pagebuilder/files/.knip.json +8 -0
  95. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/[[...slug]]/route.ts +100 -0
  96. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/draft-mode/disable/route.ts +7 -0
  97. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/draft-mode/enable/route.ts +20 -0
  98. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +126 -0
  99. package/src/modules/features/cms/sanity-pagebuilder/files/app/feed.xml/route.ts +40 -0
  100. package/src/modules/features/cms/sanity-pagebuilder/files/app/sitemap.md/route.ts +124 -0
  101. package/src/modules/features/cms/sanity-pagebuilder/files/app/studio/[[...tool]]/page.tsx +8 -0
  102. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/draft-mode-toggle.tsx +27 -0
  103. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +87 -0
  104. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/visual-editing.tsx +28 -0
  105. package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +41 -0
  106. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/check-integration.ts +5 -0
  107. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/client.ts +27 -0
  108. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/disable-draft-mode.tsx +23 -0
  109. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +39 -0
  110. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-category-input.tsx +50 -0
  111. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-field.tsx +18 -0
  112. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/slug-input.tsx +14 -0
  113. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/confirm-publish-action.ts +40 -0
  114. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/env.ts +34 -0
  115. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/layout.ts +48 -0
  116. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/redirects.ts +17 -0
  117. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +62 -0
  118. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/live/index.tsx +88 -0
  119. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/markdown-proxy.config.ts +50 -0
  120. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +130 -0
  121. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-category.ts +28 -0
  122. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx +420 -0
  123. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/pinned-pages.ts +54 -0
  124. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +119 -0
  125. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +385 -0
  126. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.cli.ts +29 -0
  127. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +113 -0
  128. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +4 -0
  129. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +89 -0
  130. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/description.ts +29 -0
  131. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/hero.ts +28 -0
  132. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/singleton/content-collection.ts +45 -0
  133. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/content/author.ts +70 -0
  134. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/content/blog-category.ts +55 -0
  135. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +96 -0
  136. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/company-data.ts +62 -0
  137. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/footer.ts +79 -0
  138. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/navbar.ts +75 -0
  139. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +120 -0
  140. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/link.ts +125 -0
  141. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/logo-field.ts +9 -0
  142. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +68 -0
  143. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/nav-objects.ts +192 -0
  144. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +39 -0
  145. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-folder.ts +124 -0
  146. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +242 -0
  147. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +63 -0
  148. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +39 -0
  149. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +330 -0
  150. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/image.ts +8 -0
  151. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/link.ts +140 -0
  152. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +81 -0
  153. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +45 -0
  154. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/check-redirect.ts +30 -0
  155. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +212 -0
  156. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +167 -0
  157. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/rss.ts +96 -0
  158. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/sitemap.ts +40 -0
  159. package/src/modules/features/cms/sanity-pagebuilder/files/proxy.ts +81 -0
  160. package/src/templates/next-default/.github/workflows/ci.yml +62 -0
  161. package/src/templates/next-default/README.md +22 -0
  162. package/src/templates/next-default/knip.json +14 -0
  163. package/src/templates/next-default/lefthook.yml +5 -0
  164. package/src/templates/next-default/package.json +8 -4
  165. package/src/templates/next-experiments/.github/workflows/ci.yml +62 -0
  166. package/src/templates/next-experiments/README.md +22 -0
  167. package/src/templates/next-experiments/knip.json +14 -0
  168. package/src/templates/next-experiments/lefthook.yml +30 -0
  169. package/src/templates/next-experiments/package.json +10 -2
  170. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +70 -0
  171. package/src/templates/next-pagebuilder/README.md +34 -0
  172. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +6 -1
  173. package/src/templates/next-pagebuilder/app/(content)/layout.tsx +3 -12
  174. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +11 -10
  175. package/src/templates/next-pagebuilder/components/layout/header/index.tsx +5 -3
  176. package/src/templates/next-pagebuilder/components/layout/json-ld/index.tsx +9 -10
  177. package/src/templates/next-pagebuilder/components/layout/wrapper/index.tsx +3 -11
  178. package/src/templates/next-pagebuilder/components/page-builder/components/content-collection/index.tsx +1 -1
  179. package/src/templates/next-pagebuilder/knip.json +27 -0
  180. package/src/templates/next-pagebuilder/lefthook.yml +5 -0
  181. package/src/templates/next-pagebuilder/lib/scripts/setup-ci-secrets.ts +117 -0
  182. package/src/templates/next-pagebuilder/package.json +9 -3
  183. package/src/templates/next-webgl/.github/workflows/ci.yml +62 -0
  184. package/src/templates/next-webgl/README.md +22 -0
  185. package/src/templates/next-webgl/knip.json +13 -0
  186. package/src/templates/next-webgl/lefthook.yml +5 -0
  187. package/src/templates/next-webgl/package.json +8 -4
@@ -0,0 +1,420 @@
1
+ import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr";
2
+ import {
3
+ Badge,
4
+ Box,
5
+ Card,
6
+ Flex,
7
+ Spinner,
8
+ Stack,
9
+ Text,
10
+ TextInput,
11
+ } from "@sanity/ui";
12
+ import { useEffect, useRef, useState } from "react";
13
+ import { asyncScheduler, from, merge, of } from "rxjs";
14
+ import { switchMap, throttleTime } from "rxjs/operators";
15
+ import { type SanityClient, useClient } from "sanity";
16
+ import { usePaneRouter } from "sanity/structure";
17
+ import { apiVersion } from "./env";
18
+ import { pageFolderIcon, pinIcon } from "./icons";
19
+ import {
20
+ getPageCategoryIcon,
21
+ type PageBuilderGroupName,
22
+ } from "./page-builder-config";
23
+ import { pageCategoryExpression } from "./page-category";
24
+ import {
25
+ PINNED_PAGE_ENTRIES,
26
+ PINNED_PAGE_IDS,
27
+ type PinnedPageEntry,
28
+ } from "./pinned-pages";
29
+
30
+ const NO_MATCHES = "No matches found";
31
+ const NO_PAGES_OR_FOLDERS = "No pages or folders here yet";
32
+
33
+ interface PageTreeItem {
34
+ _id: string;
35
+ _originalId?: string;
36
+ _type: "page" | "pageFolder";
37
+ title?: string | null;
38
+ slug?: string | null;
39
+ pageType?: PageBuilderGroupName | null;
40
+ }
41
+
42
+ // `_originalId` is prefixed `drafts.` when an unpublished edit exists.
43
+ const isDraftItem = (item: PageTreeItem) =>
44
+ !!item._originalId?.startsWith("drafts.");
45
+
46
+ type ActiveState = "leaf" | "ancestor" | "none";
47
+
48
+ const PAGE_TREE_ITEM_ID_PREFIX = {
49
+ page: "page-tree-",
50
+ pageFolder: "page-folder-tree-",
51
+ } as const;
52
+
53
+ const PAGE_TREE_PANE_STYLE_ID = "page-tree-pane-style";
54
+
55
+ // Constrain the tree pane width so the slug/title rows stay readable. Runs once
56
+ // (id-guarded) from inside the component so the injection lives in React's tree.
57
+ const usePaneWidthStyle = () => {
58
+ useEffect(() => {
59
+ if (
60
+ typeof document === "undefined" ||
61
+ document.getElementById(PAGE_TREE_PANE_STYLE_ID)
62
+ ) {
63
+ return;
64
+ }
65
+ const style = document.createElement("style");
66
+ style.id = PAGE_TREE_PANE_STYLE_ID;
67
+ style.textContent = `[data-ui="Pane"][id^="page-tree-"]{max-width:350px;}`;
68
+ document.head.appendChild(style);
69
+ }, []);
70
+ };
71
+
72
+ const PAGE_TREE_QUERY = `
73
+ *[
74
+ (
75
+ _type == "pageFolder" &&
76
+ (
77
+ ($folderId == null && !defined(parentFolder._ref)) ||
78
+ parentFolder._ref == $folderId
79
+ )
80
+ ) ||
81
+ (
82
+ _type == "page" &&
83
+ (
84
+ ($folderId == null && !defined(pageFolder._ref)) ||
85
+ pageFolder._ref == $folderId
86
+ )
87
+ )
88
+ ]{
89
+ _id,
90
+ _originalId,
91
+ _type,
92
+ title,
93
+ "slug": slug.current,
94
+ "pageType": ${pageCategoryExpression}
95
+ }
96
+ `;
97
+
98
+ const normalizeId = (id: string) => id.replace(/^drafts\./, "");
99
+
100
+ const getItemId = (item: Pick<PageTreeItem, "_id" | "_type">) =>
101
+ `${PAGE_TREE_ITEM_ID_PREFIX[item._type]}${normalizeId(item._id)}`;
102
+
103
+ const sortItems = (items: PageTreeItem[]) =>
104
+ [...items].sort((a, b) => {
105
+ if (a._type !== b._type) return a._type === "page" ? -1 : 1;
106
+ // Pages without a slug (e.g. the homepage) float to the top.
107
+ if (!a.slug && a._type === "page") return -1;
108
+ if (!b.slug && b._type === "page") return 1;
109
+ return (a.title ?? "").localeCompare(b.title ?? "");
110
+ });
111
+
112
+ interface PageTreePaneProps {
113
+ options?: Record<string, unknown>;
114
+ }
115
+
116
+ export const PageTreePane = ({ options }: PageTreePaneProps) => {
117
+ const folderId = (options?.folderId as string | null | undefined) ?? null;
118
+
119
+ usePaneWidthStyle();
120
+
121
+ const [items, setItems] = useState<PageTreeItem[]>([]);
122
+ const [loading, setLoading] = useState(true);
123
+ const [query, setQuery] = useState("");
124
+
125
+ const baseClient = useClient({ apiVersion });
126
+ // Pinned to drafts so the tree shows in-progress edits and the Draft badge.
127
+ const clientRef = useRef<SanityClient | null>(null);
128
+ if (!clientRef.current) {
129
+ clientRef.current = baseClient.withConfig({ perspective: "drafts" });
130
+ }
131
+ const client = clientRef.current;
132
+
133
+ const { ChildLink, routerPanesState, groupIndex } = usePaneRouter();
134
+ const activeChildId = routerPanesState[groupIndex + 1]?.[0]?.id ?? null;
135
+ const isLeafPane = routerPanesState.length === groupIndex + 2;
136
+
137
+ useEffect(() => {
138
+ setLoading(true);
139
+ // Initial fetch, then refetch on each folder-scoped mutation to stay live.
140
+ const subscription = merge(
141
+ of(null),
142
+ client
143
+ .listen<PageTreeItem>(
144
+ folderId == null
145
+ ? `*[_type in ["page", "pageFolder"] && !defined(parentFolder._ref) && !defined(pageFolder._ref)]`
146
+ : `*[_type in ["page", "pageFolder"] && (parentFolder._ref == $folderId || pageFolder._ref == $folderId)]`,
147
+ { folderId },
148
+ { visibility: "query", events: ["mutation"], includeResult: false },
149
+ )
150
+ .pipe(
151
+ // leading+trailing: react to the first edit at once, coalesce the rest.
152
+ throttleTime(300, asyncScheduler, {
153
+ leading: true,
154
+ trailing: true,
155
+ }),
156
+ ),
157
+ )
158
+ .pipe(
159
+ switchMap(() =>
160
+ from(client.fetch<PageTreeItem[]>(PAGE_TREE_QUERY, { folderId })),
161
+ ),
162
+ )
163
+ .subscribe({
164
+ next: (data) => {
165
+ setItems(data);
166
+ setLoading(false);
167
+ },
168
+ error: () => setLoading(false),
169
+ });
170
+
171
+ return () => subscription.unsubscribe();
172
+ }, [folderId, client]);
173
+
174
+ // Pinned pages get their own section, so drop them from the dynamic list.
175
+ const visible =
176
+ folderId == null
177
+ ? items.filter((item) => !PINNED_PAGE_IDS.has(item._id))
178
+ : items;
179
+ const sorted = sortItems(visible);
180
+ const term = query.toLowerCase().trim();
181
+ const filtered = term
182
+ ? sorted.filter(
183
+ (item) =>
184
+ item.title?.toLowerCase().includes(term) ||
185
+ item.slug?.toLowerCase().includes(term),
186
+ )
187
+ : sorted;
188
+ const showPinned = folderId == null && !term;
189
+
190
+ return (
191
+ <Flex direction="column" style={{ height: "100%" }}>
192
+ <PageTreeSearch
193
+ query={query}
194
+ onQueryChange={setQuery}
195
+ autoFocus={folderId == null}
196
+ />
197
+ <Box style={{ borderBottom: "1px solid var(--card-border-color)" }} />
198
+ {showPinned && (
199
+ <Stack>
200
+ {PINNED_PAGE_ENTRIES.map((entry) => {
201
+ const activeState: ActiveState =
202
+ activeChildId === entry.id
203
+ ? isLeafPane
204
+ ? "leaf"
205
+ : "ancestor"
206
+ : "none";
207
+ return (
208
+ <PinnedRow
209
+ key={entry.id}
210
+ entry={entry}
211
+ ChildLink={ChildLink}
212
+ activeState={activeState}
213
+ />
214
+ );
215
+ })}
216
+ </Stack>
217
+ )}
218
+ <TreeBody
219
+ loading={loading}
220
+ items={filtered}
221
+ emptyMessage={term ? NO_MATCHES : NO_PAGES_OR_FOLDERS}
222
+ ChildLink={ChildLink}
223
+ activeChildId={activeChildId}
224
+ isLeafPane={isLeafPane}
225
+ />
226
+ </Flex>
227
+ );
228
+ };
229
+
230
+ interface PageTreeSearchProps {
231
+ query: string;
232
+ onQueryChange: (value: string) => void;
233
+ autoFocus?: boolean;
234
+ }
235
+
236
+ const PageTreeSearch = ({
237
+ query,
238
+ onQueryChange,
239
+ autoFocus,
240
+ }: PageTreeSearchProps) => (
241
+ <Box padding={3}>
242
+ <TextInput
243
+ icon={MagnifyingGlassIcon}
244
+ placeholder="Filter pages and folders…"
245
+ value={query}
246
+ onChange={(e) => onQueryChange(e.currentTarget.value)}
247
+ clearButton={!!query}
248
+ onClear={() => onQueryChange("")}
249
+ autoFocus={autoFocus}
250
+ />
251
+ </Box>
252
+ );
253
+
254
+ interface TreeBodyProps {
255
+ loading: boolean;
256
+ items: PageTreeItem[];
257
+ emptyMessage: string;
258
+ ChildLink: ReturnType<typeof usePaneRouter>["ChildLink"];
259
+ activeChildId: string | null;
260
+ isLeafPane: boolean;
261
+ }
262
+
263
+ const TreeBody = ({
264
+ loading,
265
+ items,
266
+ emptyMessage,
267
+ ChildLink,
268
+ activeChildId,
269
+ isLeafPane,
270
+ }: TreeBodyProps) => (
271
+ <Box style={{ flex: 1, overflowY: "auto" }}>
272
+ {loading && <Loading />}
273
+ {!loading && items.length === 0 && <Empty message={emptyMessage} />}
274
+ {!loading && items.length > 0 && (
275
+ <Stack>
276
+ {items.map((item) => {
277
+ const isActive = getItemId(item) === activeChildId;
278
+ let activeState: ActiveState = "none";
279
+ if (isActive) activeState = isLeafPane ? "leaf" : "ancestor";
280
+ return (
281
+ <ItemRow
282
+ key={item._id}
283
+ item={item}
284
+ ChildLink={ChildLink}
285
+ activeState={activeState}
286
+ />
287
+ );
288
+ })}
289
+ </Stack>
290
+ )}
291
+ </Box>
292
+ );
293
+
294
+ const Loading = () => (
295
+ <Flex justify="center" padding={4}>
296
+ <Spinner />
297
+ </Flex>
298
+ );
299
+
300
+ const Empty = ({ message }: { message: string }) => (
301
+ <Flex padding={4} justify="center">
302
+ <Text muted size={1}>
303
+ {message}
304
+ </Text>
305
+ </Flex>
306
+ );
307
+
308
+ interface ItemRowProps {
309
+ item: PageTreeItem;
310
+ ChildLink: ReturnType<typeof usePaneRouter>["ChildLink"];
311
+ activeState: ActiveState;
312
+ }
313
+
314
+ const ItemIcon = ({ item }: { item: PageTreeItem }) => {
315
+ const Icon =
316
+ item._type === "pageFolder"
317
+ ? pageFolderIcon
318
+ : getPageCategoryIcon(item.pageType);
319
+ return <Icon />;
320
+ };
321
+
322
+ const ItemRow = ({ item, ChildLink, activeState }: ItemRowProps) => {
323
+ const fallback = item._type === "page" ? "Untitled Page" : "Untitled Folder";
324
+ const isLeaf = activeState === "leaf";
325
+ const isAncestor = activeState === "ancestor";
326
+
327
+ return (
328
+ <ChildLink childId={getItemId(item)}>
329
+ <Card
330
+ as="div"
331
+ padding={3}
332
+ radius={0}
333
+ pressed={isLeaf}
334
+ selected={isAncestor}
335
+ tone={isLeaf ? "primary" : "default"}
336
+ style={{
337
+ cursor: "pointer",
338
+ display: "block",
339
+ ...(isLeaf && {
340
+ backgroundColor: "var(--card-badge-primary-bg-color)",
341
+ color: "var(--card-badge-primary-fg-color)",
342
+ }),
343
+ ...(isAncestor && {
344
+ backgroundColor: "var(--card-muted-bg-color)",
345
+ }),
346
+ }}
347
+ >
348
+ <Flex align="center" gap={3}>
349
+ <Box style={{ flexShrink: 0 }}>
350
+ <ItemIcon item={item} />
351
+ </Box>
352
+ <Stack space={2} flex={1}>
353
+ <Flex align="center" gap={2}>
354
+ <Text size={1} weight="medium">
355
+ {item.title ?? fallback}
356
+ </Text>
357
+ {isDraftItem(item) && (
358
+ <Badge fontSize={0} mode="outline" tone="caution">
359
+ Draft
360
+ </Badge>
361
+ )}
362
+ </Flex>
363
+ {item.slug && (
364
+ <Text size={0} muted weight="regular">
365
+ /{item.slug}
366
+ </Text>
367
+ )}
368
+ </Stack>
369
+ </Flex>
370
+ </Card>
371
+ </ChildLink>
372
+ );
373
+ };
374
+
375
+ interface PinnedRowProps {
376
+ entry: PinnedPageEntry;
377
+ ChildLink: ReturnType<typeof usePaneRouter>["ChildLink"];
378
+ activeState: ActiveState;
379
+ }
380
+
381
+ const PinnedRow = ({ entry, ChildLink, activeState }: PinnedRowProps) => {
382
+ const isLeaf = activeState === "leaf";
383
+ const isAncestor = activeState === "ancestor";
384
+ const Icon = pinIcon;
385
+
386
+ return (
387
+ <ChildLink childId={entry.id}>
388
+ <Card
389
+ as="div"
390
+ padding={3}
391
+ radius={0}
392
+ pressed={isLeaf}
393
+ selected={isAncestor}
394
+ tone={isLeaf ? "primary" : "default"}
395
+ style={{
396
+ cursor: "pointer",
397
+ display: "block",
398
+ ...(isLeaf && {
399
+ backgroundColor: "var(--card-badge-primary-bg-color)",
400
+ color: "var(--card-badge-primary-fg-color)",
401
+ }),
402
+ ...(isAncestor && {
403
+ backgroundColor: "var(--card-muted-bg-color)",
404
+ }),
405
+ }}
406
+ >
407
+ <Flex align="center" gap={3}>
408
+ <Box style={{ flexShrink: 0 }}>
409
+ <Icon />
410
+ </Box>
411
+ <Stack space={2} flex={1}>
412
+ <Text size={1} weight="medium">
413
+ {entry.title}
414
+ </Text>
415
+ </Stack>
416
+ </Flex>
417
+ </Card>
418
+ </ChildLink>
419
+ );
420
+ };
@@ -0,0 +1,54 @@
1
+ import { pageIcon, type SanityStudioIcon } from "./icons";
2
+
3
+ export type PinnedPageEntry = {
4
+ /** Sanity document _id. Doubles as the Studio template id. */
5
+ id: string;
6
+ /** Sanity document draft _id (`drafts.${id}`). */
7
+ draftId: string;
8
+ /** Title shown in Studio listings and used as the initial `title` value. */
9
+ title: string;
10
+ /** Icon shown in Studio. */
11
+ icon: SanityStudioIcon;
12
+ /** Public URL path. Used for link resolution + presentation. */
13
+ path: string;
14
+ /** Slug.current seeded into the template. `null` means no slug (homepage). */
15
+ slug: string | null;
16
+ };
17
+
18
+ export const HOMEPAGE_PINNED_PAGE: PinnedPageEntry = {
19
+ id: "page-homepage",
20
+ draftId: "drafts.page-homepage",
21
+ title: "Homepage",
22
+ icon: pageIcon,
23
+ path: "/",
24
+ slug: null,
25
+ };
26
+
27
+ export const BLOG_INDEX_PINNED_PAGE: PinnedPageEntry = {
28
+ id: "page-blog-index",
29
+ draftId: "drafts.page-blog-index",
30
+ title: "Blog Index",
31
+ icon: pageIcon,
32
+ path: "/blog",
33
+ slug: "blog",
34
+ };
35
+
36
+ export const PINNED_PAGE_ENTRIES: PinnedPageEntry[] = [
37
+ HOMEPAGE_PINNED_PAGE,
38
+ BLOG_INDEX_PINNED_PAGE,
39
+ ];
40
+
41
+ export const PINNED_PAGE_IDS: ReadonlySet<string> = new Set(
42
+ PINNED_PAGE_ENTRIES.flatMap((entry) => [entry.id, entry.draftId]),
43
+ );
44
+
45
+ const pinnedPageById = new Map(
46
+ PINNED_PAGE_ENTRIES.flatMap((entry) => [
47
+ [entry.id, entry] as const,
48
+ [entry.draftId, entry] as const,
49
+ ]),
50
+ );
51
+
52
+ /** Look up a pinned page entry by either its published or draft id. */
53
+ export const getPinnedPageById = (id: string | null | undefined) =>
54
+ id ? (pinnedPageById.get(id) ?? null) : null;
@@ -0,0 +1,119 @@
1
+ import type { Observable } from "rxjs";
2
+ import { map } from "rxjs/operators";
3
+ import type { DocumentStore } from "sanity";
4
+ import {
5
+ type DocumentLocationsState,
6
+ defineDocuments,
7
+ presentationTool,
8
+ } from "sanity/presentation";
9
+ import { previewURL } from "./env";
10
+ import { getPinnedPageById, HOMEPAGE_PINNED_PAGE } from "./pinned-pages";
11
+
12
+ const MAX_FOLDER_DEPTH = 6;
13
+
14
+ const buildNestedSlugExpression = (
15
+ parentReferenceField: string,
16
+ leafSlugField: string,
17
+ ) => {
18
+ const cases = Array.from({ length: MAX_FOLDER_DEPTH }, (_, index) => {
19
+ const depth = MAX_FOLDER_DEPTH - index;
20
+ const parentSlugRefs = Array.from({ length: depth }, (_, parentIndex) => {
21
+ const remainingDepth = depth - parentIndex - 1;
22
+ return `${parentReferenceField}${"->parentFolder".repeat(remainingDepth)}->slug.current`;
23
+ });
24
+
25
+ const pathDefinedChecks = parentSlugRefs
26
+ .map((field) => `defined(${field})`)
27
+ .join(" && ");
28
+
29
+ const childPath = [...parentSlugRefs, leafSlugField].join(' + "/" + ');
30
+ const folderRootPath = parentSlugRefs.join(' + "/" + ');
31
+
32
+ return [
33
+ `${pathDefinedChecks} && ${leafSlugField} == "/" => ${folderRootPath}`,
34
+ `${pathDefinedChecks} && defined(${leafSlugField}) => ${childPath}`,
35
+ ];
36
+ }).flat();
37
+
38
+ return `select(${cases.join(",\n ")},${leafSlugField})`;
39
+ };
40
+
41
+ const pageResolvedSlugExpression = buildNestedSlugExpression(
42
+ "pageFolder",
43
+ "slug.current",
44
+ );
45
+
46
+ const CONTENT_TYPE_PREFIXES: Record<string, string> = {
47
+ blogContent: "blog",
48
+ };
49
+
50
+ const resolveSlugLocation = <T extends { title?: string }>(
51
+ documentStore: DocumentStore,
52
+ id: string,
53
+ projection: string,
54
+ toHref: (doc: T) => string | null,
55
+ fallbackTitle: string,
56
+ ): Observable<DocumentLocationsState> =>
57
+ documentStore
58
+ .listenQuery(`*[_id == $id][0]{ ${projection} }`, { id }, {})
59
+ .pipe(
60
+ map((doc: T | null) => {
61
+ const href = doc ? toHref(doc) : null;
62
+ if (!href) return { locations: [] };
63
+ return { locations: [{ title: doc?.title || fallbackTitle, href }] };
64
+ }),
65
+ );
66
+
67
+ export const presentation = presentationTool({
68
+ name: "preview",
69
+ title: "Preview",
70
+ resolve: {
71
+ mainDocuments: defineDocuments([
72
+ {
73
+ route: "/",
74
+ filter: `_type == "page" && _id == "${HOMEPAGE_PINNED_PAGE.id}"`,
75
+ },
76
+ ...Object.entries(CONTENT_TYPE_PREFIXES).map(([type, prefix]) => ({
77
+ route: `/${prefix}/:slug`,
78
+ filter: `_type == "${type}" && slug.current == $slug`,
79
+ })),
80
+ {
81
+ route: "/:slug+",
82
+ filter: `_type == "page" && ${pageResolvedSlugExpression} == $slug`,
83
+ },
84
+ ]),
85
+ locations: (params, { documentStore }) => {
86
+ if (params.type === "page") {
87
+ const pinned = getPinnedPageById(params.id);
88
+ if (pinned) {
89
+ return { locations: [{ title: pinned.title, href: pinned.path }] };
90
+ }
91
+ return resolveSlugLocation<{ title?: string; resolvedSlug?: string }>(
92
+ documentStore,
93
+ params.id,
94
+ `title, "resolvedSlug": ${pageResolvedSlugExpression}`,
95
+ (doc) => (doc.resolvedSlug ? `/${doc.resolvedSlug}` : null),
96
+ "Page",
97
+ );
98
+ }
99
+
100
+ const prefix = CONTENT_TYPE_PREFIXES[params.type];
101
+ if (!prefix) return null;
102
+
103
+ return resolveSlugLocation<{ title?: string; slug?: string }>(
104
+ documentStore,
105
+ params.id,
106
+ `title, "slug": slug.current`,
107
+ (doc) => (doc.slug ? `/${prefix}/${doc.slug}` : null),
108
+ "Untitled",
109
+ );
110
+ },
111
+ },
112
+ previewUrl: {
113
+ origin: previewURL,
114
+ draftMode: {
115
+ enable: "/api/draft-mode/enable",
116
+ disable: "/api/draft-mode/disable",
117
+ },
118
+ },
119
+ });