@driftime/sanity-plugin-handbook 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/index.cjs +1866 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +143 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +1870 -0
- package/dist/index.js.map +1 -0
- package/package.json +92 -0
- package/src/blocks/callout/block.tsx +56 -0
- package/src/blocks/callout/content.tsx +39 -0
- package/src/blocks/callout/preview.tsx +15 -0
- package/src/blocks/code/block.tsx +18 -0
- package/src/blocks/code/content.tsx +18 -0
- package/src/blocks/code/preview.tsx +17 -0
- package/src/blocks/horizontal-rule/block.tsx +5 -0
- package/src/blocks/horizontal-rule/content.tsx +8 -0
- package/src/blocks/horizontal-rule/preview.tsx +5 -0
- package/src/blocks/image/block.tsx +23 -0
- package/src/blocks/image/content.tsx +31 -0
- package/src/blocks/image/preview.tsx +23 -0
- package/src/blocks/video/block.tsx +21 -0
- package/src/blocks/video/content.tsx +37 -0
- package/src/blocks/video/preview.tsx +21 -0
- package/src/components/content-panel.tsx +58 -0
- package/src/components/decorators.tsx +53 -0
- package/src/components/document-types.tsx +49 -0
- package/src/components/field.tsx +528 -0
- package/src/components/guide-content.tsx +117 -0
- package/src/components/guides.tsx +37 -0
- package/src/components/pane-header.tsx +26 -0
- package/src/components/sidebar-group.tsx +30 -0
- package/src/components/sidebar-tab.tsx +78 -0
- package/src/contexts/handbook-context.tsx +84 -0
- package/src/getting-started/document-types.tsx +104 -0
- package/src/getting-started/how-to-use.tsx +93 -0
- package/src/getting-started/overview.tsx +64 -0
- package/src/hooks/use-container-width.ts +41 -0
- package/src/hooks/use-handbook-configuration.ts +74 -0
- package/src/hooks/use-is-handbook-editor.ts +18 -0
- package/src/index.ts +145 -0
- package/src/layouts/content.tsx +13 -0
- package/src/layouts/sidebar.tsx +15 -0
- package/src/layouts/tool.tsx +205 -0
- package/src/lib/handbook-structure.ts +46 -0
- package/src/lib/icons.ts +18 -0
- package/src/lib/utils.ts +69 -0
- package/src/schemas/annotations/link.ts +19 -0
- package/src/schemas/blocks/callout.ts +69 -0
- package/src/schemas/blocks/code.ts +46 -0
- package/src/schemas/blocks/horizontal-rule.ts +31 -0
- package/src/schemas/blocks/image.ts +40 -0
- package/src/schemas/blocks/video.ts +34 -0
- package/src/schemas/guide-content.ts +58 -0
- package/src/schemas/types/guide.ts +48 -0
- package/src/schemas/types/handbook.ts +85 -0
- package/src/types.ts +101 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { BookTextIcon } from "lucide-react";
|
|
2
|
+
import { definePlugin } from "sanity";
|
|
3
|
+
|
|
4
|
+
import { createHandbookTool } from "./layouts/tool";
|
|
5
|
+
import { setHandbookEditors } from "./lib/handbook-structure";
|
|
6
|
+
import { createSanityIcon } from "./lib/icons";
|
|
7
|
+
import { isDefined, isPermittedEditor } from "./lib/utils";
|
|
8
|
+
import { handbookCalloutType } from "./schemas/blocks/callout";
|
|
9
|
+
import { handbookCodeType } from "./schemas/blocks/code";
|
|
10
|
+
import { handbookHorizontalRuleType } from "./schemas/blocks/horizontal-rule";
|
|
11
|
+
import { handbookImageType } from "./schemas/blocks/image";
|
|
12
|
+
import { handbookVideoType } from "./schemas/blocks/video";
|
|
13
|
+
import { createHandbookGuideType } from "./schemas/types/guide";
|
|
14
|
+
import { handbookType } from "./schemas/types/handbook";
|
|
15
|
+
import type { HandbookConfig, HandbookStructureGroup } from "./types";
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
HandbookBlockDefinition,
|
|
19
|
+
HandbookConfig,
|
|
20
|
+
HandbookGuideGroup,
|
|
21
|
+
HandbookMetadata,
|
|
22
|
+
HandbookStructureGroup,
|
|
23
|
+
SanityHandbook,
|
|
24
|
+
SanityHandbookGuide,
|
|
25
|
+
} from "./types";
|
|
26
|
+
export { handbookStructure } from "./lib/handbook-structure";
|
|
27
|
+
export { useIsHandbookEditor } from "./hooks/use-is-handbook-editor";
|
|
28
|
+
|
|
29
|
+
/** Document types that should not appear in the "create new" menu. */
|
|
30
|
+
const handbookSingletonTypes = new Set(["handbook.handbook"]);
|
|
31
|
+
|
|
32
|
+
/** All handbook document types used to filter template items for non-editors. */
|
|
33
|
+
const handbookDocumentTypes = new Set(["handbook.handbook", "handbook.guide"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Checks whether a template ID matches or is a variant of any type in the given set.
|
|
37
|
+
*
|
|
38
|
+
* @param templateId - The template identifier to check.
|
|
39
|
+
* @param typeSet - The set of document type names to match against.
|
|
40
|
+
* @returns True if the template ID matches a type or starts with a type prefix.
|
|
41
|
+
*/
|
|
42
|
+
function isHandbookTemplate(templateId: string, typeSet: Set<string>) {
|
|
43
|
+
return typeSet.has(templateId) || [...typeSet].some((type) => templateId.startsWith(`${type}-`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validates a single document type group entry in the plugin configuration.
|
|
48
|
+
*
|
|
49
|
+
* @param index - Position of the group in the configuration array.
|
|
50
|
+
* @param group - The document type group to validate.
|
|
51
|
+
*/
|
|
52
|
+
function validateGroup(index: number, group: HandbookStructureGroup) {
|
|
53
|
+
const prefix = `sanity-handbook: groups[${index}]`;
|
|
54
|
+
|
|
55
|
+
if (!isDefined(group.title)) throw new Error(`${prefix} is missing a required "title" property.`);
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(group.documents) || group.documents.length === 0) {
|
|
58
|
+
throw new Error(`${prefix} must have a non-empty "documents" array.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const document of group.documents) {
|
|
62
|
+
if (!isDefined(document.name)) {
|
|
63
|
+
throw new Error(`${prefix}.documents contains a document without a "name" property.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!Array.isArray(document.fields)) {
|
|
67
|
+
throw new TypeError(`${prefix}.documents.${document.name} is missing a "fields" array.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validates the full handbook plugin configuration, checking all groups.
|
|
74
|
+
*
|
|
75
|
+
* @param config - The plugin configuration to validate.
|
|
76
|
+
*/
|
|
77
|
+
function validateConfig(config: HandbookConfig) {
|
|
78
|
+
if (config.groups) {
|
|
79
|
+
for (const [index, group] of config.groups.entries()) {
|
|
80
|
+
validateGroup(index, group);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a Handbook tool for Sanity Studio, providing auto-generated documentation
|
|
87
|
+
* for document types and Portable Text guides within the Studio interface.
|
|
88
|
+
*
|
|
89
|
+
* @public
|
|
90
|
+
* @param config - Optional plugin configuration.
|
|
91
|
+
* @returns Sanity plugin definition.
|
|
92
|
+
*/
|
|
93
|
+
export function handbookPlugin(config?: HandbookConfig) {
|
|
94
|
+
if (config) validateConfig(config);
|
|
95
|
+
|
|
96
|
+
const customBlocks = config?.blocks ?? [];
|
|
97
|
+
const editors = config?.editors;
|
|
98
|
+
setHandbookEditors(editors);
|
|
99
|
+
|
|
100
|
+
const guideType = createHandbookGuideType(customBlocks);
|
|
101
|
+
|
|
102
|
+
return definePlugin(() => ({
|
|
103
|
+
name: "@driftime/sanity-plugin-handbook",
|
|
104
|
+
schema: {
|
|
105
|
+
types: [
|
|
106
|
+
handbookType,
|
|
107
|
+
guideType,
|
|
108
|
+
handbookImageType,
|
|
109
|
+
handbookVideoType,
|
|
110
|
+
handbookCodeType,
|
|
111
|
+
handbookCalloutType,
|
|
112
|
+
handbookHorizontalRuleType,
|
|
113
|
+
...customBlocks.map((block) => block.schema),
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
document: {
|
|
117
|
+
newDocumentOptions: (templateItems, { creationContext, currentUser }) => {
|
|
118
|
+
if (creationContext.type === "global" || creationContext.type === "structure") {
|
|
119
|
+
if (!isPermittedEditor(editors, currentUser?.email)) {
|
|
120
|
+
return templateItems.filter((item) => !isHandbookTemplate(item.templateId, handbookDocumentTypes));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return templateItems.filter((item) => !isHandbookTemplate(item.templateId, handbookSingletonTypes));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return templateItems;
|
|
127
|
+
},
|
|
128
|
+
actions: (actionComponents, { schemaType }) => {
|
|
129
|
+
if (handbookSingletonTypes.has(schemaType)) {
|
|
130
|
+
return actionComponents.filter(({ action }) => action !== "duplicate");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return actionComponents;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
tools: [
|
|
137
|
+
{
|
|
138
|
+
name: "handbook",
|
|
139
|
+
title: config?.title ?? "Handbook",
|
|
140
|
+
icon: createSanityIcon(BookTextIcon),
|
|
141
|
+
component: createHandbookTool({ ...config, blocks: customBlocks }),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}))();
|
|
145
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DocumentTypesPanels } from "../components/document-types";
|
|
2
|
+
import { GuidesPanels } from "../components/guides";
|
|
3
|
+
import { GettingStartedPanels } from "../getting-started/overview";
|
|
4
|
+
|
|
5
|
+
export function Content() {
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
<GettingStartedPanels />
|
|
9
|
+
<DocumentTypesPanels />
|
|
10
|
+
<GuidesPanels />
|
|
11
|
+
</>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Box } from "@sanity/ui";
|
|
2
|
+
|
|
3
|
+
import { DocumentTypesTabs } from "../components/document-types";
|
|
4
|
+
import { GuidesTabs } from "../components/guides";
|
|
5
|
+
import { GettingStartedTabs } from "../getting-started/overview";
|
|
6
|
+
|
|
7
|
+
export function Sidebar() {
|
|
8
|
+
return (
|
|
9
|
+
<Box paddingX={3} paddingBottom={1}>
|
|
10
|
+
<GettingStartedTabs />
|
|
11
|
+
<DocumentTypesTabs />
|
|
12
|
+
<GuidesTabs />
|
|
13
|
+
</Box>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { ArrowLeftIcon } from "@sanity/icons";
|
|
2
|
+
import { Button, Card, Flex, Text } from "@sanity/ui";
|
|
3
|
+
|
|
4
|
+
import { PaneHeader } from "../components/pane-header";
|
|
5
|
+
import { HandbookProvider, useHandbookContext } from "../contexts/handbook-context";
|
|
6
|
+
import { useContainerWidth } from "../hooks/use-container-width";
|
|
7
|
+
import { isDefined } from "../lib/utils";
|
|
8
|
+
import type { HandbookConfig } from "../types";
|
|
9
|
+
import { Content } from "./content";
|
|
10
|
+
import { Sidebar } from "./sidebar";
|
|
11
|
+
|
|
12
|
+
/** Default fallback message shown when a field has no description. */
|
|
13
|
+
const defaultUndocumentedFieldMessage =
|
|
14
|
+
"This field has not been documented yet. Contact your development team for guidance.";
|
|
15
|
+
|
|
16
|
+
/** Minimum container width for the wide (sidebar always visible) layout. */
|
|
17
|
+
const wideBreakpoint = 920;
|
|
18
|
+
|
|
19
|
+
/** Minimum container width for the medium (collapsible sidebar) layout. */
|
|
20
|
+
const narrowBreakpoint = 480;
|
|
21
|
+
|
|
22
|
+
/** Sidebar width in pixels when fully expanded. */
|
|
23
|
+
const sidebarWidth = 320;
|
|
24
|
+
|
|
25
|
+
/** Sidebar width in pixels when collapsed to a vertical label. */
|
|
26
|
+
const collapsedSidebarWidth = 51;
|
|
27
|
+
|
|
28
|
+
function CollapsedSidebar() {
|
|
29
|
+
const { sidebarTitle, setSidebarExpanded } = useHandbookContext();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
width: `${collapsedSidebarWidth}px`,
|
|
35
|
+
minWidth: `${collapsedSidebarWidth}px`,
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
borderRight: "1px solid var(--card-border-color)",
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<Card tone="inherit" data-collapsed="">
|
|
41
|
+
<Flex
|
|
42
|
+
direction="column"
|
|
43
|
+
padding={3}
|
|
44
|
+
onClick={() => {
|
|
45
|
+
setSidebarExpanded(true);
|
|
46
|
+
}}
|
|
47
|
+
style={{
|
|
48
|
+
width: "100vh",
|
|
49
|
+
boxSizing: "border-box",
|
|
50
|
+
transform: "rotate(90deg)",
|
|
51
|
+
transformOrigin: `calc(${collapsedSidebarWidth}px / 2)`,
|
|
52
|
+
cursor: "pointer",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Flex align="flex-start">
|
|
56
|
+
<Card flex={1} paddingLeft={2} padding={2}>
|
|
57
|
+
<Flex align="center" gap={1}>
|
|
58
|
+
<Text size={1} textOverflow="ellipsis" weight="semibold" style={{ cursor: "default", outline: "none" }}>
|
|
59
|
+
{sidebarTitle}
|
|
60
|
+
</Text>
|
|
61
|
+
</Flex>
|
|
62
|
+
</Card>
|
|
63
|
+
</Flex>
|
|
64
|
+
</Flex>
|
|
65
|
+
</Card>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function NarrowContentHeader() {
|
|
71
|
+
const { sidebarTitle, setSidebarExpanded } = useHandbookContext();
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Flex align="center" gap={2} padding={3}>
|
|
75
|
+
<Button
|
|
76
|
+
icon={ArrowLeftIcon}
|
|
77
|
+
mode="bleed"
|
|
78
|
+
onClick={() => {
|
|
79
|
+
setSidebarExpanded(true);
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
<Text size={1} weight="semibold">
|
|
83
|
+
{sidebarTitle}
|
|
84
|
+
</Text>
|
|
85
|
+
</Flex>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ExpandedSidebar() {
|
|
90
|
+
const { sidebarTitle } = useHandbookContext();
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Card
|
|
94
|
+
borderRight
|
|
95
|
+
style={{ display: "flex", flexDirection: "column", width: `${sidebarWidth}px`, minWidth: `${sidebarWidth}px` }}
|
|
96
|
+
>
|
|
97
|
+
<PaneHeader title={sidebarTitle} />
|
|
98
|
+
<div style={{ flex: 1, paddingBottom: "4px", overflowY: "auto" }}>
|
|
99
|
+
<Sidebar />
|
|
100
|
+
</div>
|
|
101
|
+
</Card>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function NarrowLayout() {
|
|
106
|
+
const { sidebarTitle, sidebarExpanded } = useHandbookContext();
|
|
107
|
+
|
|
108
|
+
if (sidebarExpanded) {
|
|
109
|
+
return (
|
|
110
|
+
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
|
111
|
+
<PaneHeader title={sidebarTitle} />
|
|
112
|
+
<div style={{ flex: 1, paddingBottom: "4px", overflowY: "auto" }}>
|
|
113
|
+
<Sidebar />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
|
121
|
+
<NarrowContentHeader />
|
|
122
|
+
<Content />
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function MediumLayout() {
|
|
128
|
+
const { sidebarExpanded, setSidebarExpanded } = useHandbookContext();
|
|
129
|
+
|
|
130
|
+
if (sidebarExpanded) {
|
|
131
|
+
return (
|
|
132
|
+
<Flex style={{ flex: 1 }}>
|
|
133
|
+
<ExpandedSidebar />
|
|
134
|
+
<Card
|
|
135
|
+
flex={1}
|
|
136
|
+
padding={3}
|
|
137
|
+
onClick={() => {
|
|
138
|
+
setSidebarExpanded(false);
|
|
139
|
+
}}
|
|
140
|
+
style={{ opacity: 0.4, overflowY: "auto", cursor: "pointer" }}
|
|
141
|
+
/>
|
|
142
|
+
</Flex>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Flex style={{ flex: 1 }}>
|
|
148
|
+
<CollapsedSidebar />
|
|
149
|
+
<Card flex={1} style={{ display: "flex", flexDirection: "column" }}>
|
|
150
|
+
<Content />
|
|
151
|
+
</Card>
|
|
152
|
+
</Flex>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function WideLayout() {
|
|
157
|
+
return (
|
|
158
|
+
<Flex style={{ flex: 1 }}>
|
|
159
|
+
<ExpandedSidebar />
|
|
160
|
+
<Card flex={1} style={{ display: "flex", flexDirection: "column" }}>
|
|
161
|
+
<Content />
|
|
162
|
+
</Card>
|
|
163
|
+
</Flex>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function HandbookLayout() {
|
|
168
|
+
const { reference: containerReference, width: containerWidth } = useContainerWidth<HTMLDivElement>();
|
|
169
|
+
|
|
170
|
+
const isNarrow = isDefined(containerWidth) && containerWidth < narrowBreakpoint;
|
|
171
|
+
const isMedium = isDefined(containerWidth) && containerWidth >= narrowBreakpoint && containerWidth < wideBreakpoint;
|
|
172
|
+
|
|
173
|
+
function renderLayout() {
|
|
174
|
+
if (isNarrow) return <NarrowLayout />;
|
|
175
|
+
if (isMedium) return <MediumLayout />;
|
|
176
|
+
return <WideLayout />;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div ref={containerReference} style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
181
|
+
{renderLayout()}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Creates the Handbook tool component bound to the given plugin configuration.
|
|
188
|
+
*
|
|
189
|
+
* @param config - Handbook plugin configuration.
|
|
190
|
+
* @returns Tool component for Sanity Studio.
|
|
191
|
+
*/
|
|
192
|
+
export function createHandbookTool(config: HandbookConfig) {
|
|
193
|
+
return function HandbookTool() {
|
|
194
|
+
return (
|
|
195
|
+
<HandbookProvider
|
|
196
|
+
sidebarTitle={config.sidebarTitle ?? "Handbook"}
|
|
197
|
+
groups={config.groups ?? []}
|
|
198
|
+
blocks={config.blocks ?? []}
|
|
199
|
+
undocumentedFieldMessage={config.undocumentedFieldMessage ?? defaultUndocumentedFieldMessage}
|
|
200
|
+
>
|
|
201
|
+
<HandbookLayout />
|
|
202
|
+
</HandbookProvider>
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { BookOpenTextIcon, BookTextIcon } from "lucide-react";
|
|
2
|
+
import type { CurrentUser } from "sanity";
|
|
3
|
+
import type { StructureBuilder } from "sanity/structure";
|
|
4
|
+
|
|
5
|
+
import { createSanityIcon } from "./icons";
|
|
6
|
+
import { isPermittedEditor } from "./utils";
|
|
7
|
+
|
|
8
|
+
/** Editor email addresses stored from plugin configuration for use by the structure helper. */
|
|
9
|
+
let registeredEditors: string[] | undefined = undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stores the editors list from the plugin configuration for use by the structure helper.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
* @param editors - The editors list from HandbookConfig.
|
|
16
|
+
*/
|
|
17
|
+
export function setHandbookEditors(editors?: string[]) {
|
|
18
|
+
registeredEditors = editors;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates Structure tool items for handbook documents.
|
|
23
|
+
* Returns an empty array if the current user is not in the editors list
|
|
24
|
+
* configured via handbookPlugin.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
* @param structureBuilder - The Sanity Structure builder instance.
|
|
28
|
+
* @param context - The Structure context containing the current user.
|
|
29
|
+
* @returns An array of list items for the handbook singleton and guides list.
|
|
30
|
+
*/
|
|
31
|
+
export function handbookStructure(structureBuilder: StructureBuilder, context: { currentUser: CurrentUser | null }) {
|
|
32
|
+
if (!isPermittedEditor(registeredEditors, context.currentUser?.email)) return [];
|
|
33
|
+
|
|
34
|
+
const { listItem, document, documentTypeList } = structureBuilder;
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
listItem()
|
|
38
|
+
.title("Handbook")
|
|
39
|
+
.icon(createSanityIcon(BookTextIcon))
|
|
40
|
+
.child(document().id("handbook.handbook").schemaType("handbook.handbook").title("Handbook")),
|
|
41
|
+
listItem()
|
|
42
|
+
.title("Handbook Guides")
|
|
43
|
+
.icon(createSanityIcon(BookOpenTextIcon))
|
|
44
|
+
.child(documentTypeList("handbook.guide").title("Handbook Guides")),
|
|
45
|
+
];
|
|
46
|
+
}
|
package/src/lib/icons.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LucideIcon, LucideProps } from "lucide-react";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default properties applied to all Lucide icons throughout the plugin.
|
|
6
|
+
*/
|
|
7
|
+
export const defaultIconProps: LucideProps = { width: "1em", height: "1em", strokeWidth: 1.5 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a Sanity Studio icon component from a Lucide icon with default props applied.
|
|
11
|
+
*
|
|
12
|
+
* @param icon - The Lucide icon component to wrap.
|
|
13
|
+
* @param props - Optional props to merge with defaults.
|
|
14
|
+
* @returns A React component function for use in Sanity Studio.
|
|
15
|
+
*/
|
|
16
|
+
export function createSanityIcon(icon: LucideIcon, props?: LucideProps) {
|
|
17
|
+
return () => createElement(icon, { ...defaultIconProps, ...props });
|
|
18
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determines whether a value is defined and non-empty. Treats `false`, empty strings,
|
|
3
|
+
* empty arrays, and empty objects as not defined.
|
|
4
|
+
*
|
|
5
|
+
* @param value - The value to check.
|
|
6
|
+
* @returns True if the value is defined and not empty.
|
|
7
|
+
*/
|
|
8
|
+
export function isDefined<T>(value: T | null | undefined | false): value is T {
|
|
9
|
+
if (value === undefined || value === null || value === false) return false;
|
|
10
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
11
|
+
if (Array.isArray(value)) return value.some((element) => isDefined(element));
|
|
12
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
13
|
+
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Checks whether an email address is in the permitted editors list.
|
|
19
|
+
* Returns true if no editors list is provided or the list is empty (unrestricted access).
|
|
20
|
+
*
|
|
21
|
+
* @param editors - Optional list of permitted email addresses.
|
|
22
|
+
* @param email - The email address to check.
|
|
23
|
+
* @returns Whether the email is permitted to edit.
|
|
24
|
+
*/
|
|
25
|
+
export function isPermittedEditor(editors: string[] | undefined, email: string | undefined) {
|
|
26
|
+
if (!isDefined(editors)) return true;
|
|
27
|
+
if (!isDefined(email)) return false;
|
|
28
|
+
|
|
29
|
+
return editors.includes(email);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Converts a string between different casing formats.
|
|
34
|
+
*
|
|
35
|
+
* @param value - The string to convert.
|
|
36
|
+
* @param format - The target casing format.
|
|
37
|
+
* @returns The converted string.
|
|
38
|
+
*/
|
|
39
|
+
export function convertCase(value: string, format: "kebab" | "snake" | "camel" | "pascal" | "title" | "sentence") {
|
|
40
|
+
const words = value
|
|
41
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
42
|
+
.replaceAll(/[-_\s]+/g, " ")
|
|
43
|
+
.trim()
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
|
|
46
|
+
switch (format) {
|
|
47
|
+
case "kebab": {
|
|
48
|
+
return words.replaceAll(/\s+/g, "-");
|
|
49
|
+
}
|
|
50
|
+
case "snake": {
|
|
51
|
+
return words.replaceAll(/\s+/g, "_");
|
|
52
|
+
}
|
|
53
|
+
case "camel": {
|
|
54
|
+
return words.replaceAll(/\s+(.)/g, (_match, character: string) => character.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
case "pascal": {
|
|
57
|
+
return words.replaceAll(/(?:^|\s+)(.)/g, (_match, character: string) => character.toUpperCase());
|
|
58
|
+
}
|
|
59
|
+
case "title": {
|
|
60
|
+
return words.replaceAll(/\b\w/g, (character) => character.toUpperCase());
|
|
61
|
+
}
|
|
62
|
+
case "sentence": {
|
|
63
|
+
return words.replaceAll(/^(.)/g, (character) => character.toUpperCase());
|
|
64
|
+
}
|
|
65
|
+
default: {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineArrayMember, defineField } from "sanity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline link annotation for Portable Text fields.
|
|
5
|
+
* Supports http, https, mailto, and tel URI schemes.
|
|
6
|
+
*/
|
|
7
|
+
export const linkAnnotation = defineArrayMember({
|
|
8
|
+
name: "link",
|
|
9
|
+
type: "object",
|
|
10
|
+
fields: [
|
|
11
|
+
defineField({
|
|
12
|
+
name: "href",
|
|
13
|
+
title: "URL",
|
|
14
|
+
type: "url",
|
|
15
|
+
description: "Web address for this link. Supports http, https, mailto, and tel schemes.",
|
|
16
|
+
validation: (rule) => rule.uri({ scheme: ["http", "https", "mailto", "tel"] }),
|
|
17
|
+
}),
|
|
18
|
+
],
|
|
19
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MessageCircleQuestionMarkIcon } from "lucide-react";
|
|
2
|
+
import { defineArrayMember, defineField, defineType } from "sanity";
|
|
3
|
+
|
|
4
|
+
import { CalloutPreview } from "../../blocks/callout/preview";
|
|
5
|
+
import { InlineCodeDecorator, ParagraphDecorator } from "../../components/decorators";
|
|
6
|
+
import { createSanityIcon } from "../../lib/icons";
|
|
7
|
+
import { linkAnnotation } from "../annotations/link";
|
|
8
|
+
|
|
9
|
+
export const handbookCalloutType = defineType({
|
|
10
|
+
name: "handbook.callout",
|
|
11
|
+
title: "Callout",
|
|
12
|
+
type: "object",
|
|
13
|
+
icon: createSanityIcon(MessageCircleQuestionMarkIcon),
|
|
14
|
+
description: "A highlighted message block used to draw attention to tips, supplementary information, or warnings.",
|
|
15
|
+
preview: {
|
|
16
|
+
select: { variant: "variant", body: "body" },
|
|
17
|
+
prepare({ variant, body }: { variant?: string; body?: { children?: { text?: string }[] }[] }) {
|
|
18
|
+
const text = Array.isArray(body)
|
|
19
|
+
? body.flatMap((block) => (block.children ?? []).map((child) => child.text ?? "")).join("")
|
|
20
|
+
: "";
|
|
21
|
+
|
|
22
|
+
return { variant, text };
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
components: {
|
|
26
|
+
preview: CalloutPreview,
|
|
27
|
+
},
|
|
28
|
+
fields: [
|
|
29
|
+
defineField({
|
|
30
|
+
name: "variant",
|
|
31
|
+
title: "Variant",
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Visual style and intent of the callout.",
|
|
34
|
+
initialValue: "tip",
|
|
35
|
+
validation: (rule) => rule.required(),
|
|
36
|
+
options: {
|
|
37
|
+
list: [
|
|
38
|
+
{ title: "Tip", value: "tip" },
|
|
39
|
+
{ title: "Information", value: "info" },
|
|
40
|
+
{ title: "Warning", value: "warning" },
|
|
41
|
+
],
|
|
42
|
+
layout: "radio",
|
|
43
|
+
direction: "horizontal",
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
defineField({
|
|
47
|
+
name: "body",
|
|
48
|
+
title: "Body",
|
|
49
|
+
type: "array",
|
|
50
|
+
description: "Content displayed inside the callout. Supports inline formatting and links.",
|
|
51
|
+
of: [
|
|
52
|
+
defineArrayMember({
|
|
53
|
+
type: "block",
|
|
54
|
+
styles: [{ title: "Normal", value: "normal", component: ParagraphDecorator }],
|
|
55
|
+
marks: {
|
|
56
|
+
decorators: [
|
|
57
|
+
{ title: "Bold", value: "strong" },
|
|
58
|
+
{ title: "Italic", value: "em" },
|
|
59
|
+
{ title: "Strikethrough", value: "strike-through" },
|
|
60
|
+
{ title: "Code", value: "code", component: InlineCodeDecorator },
|
|
61
|
+
],
|
|
62
|
+
annotations: [linkAnnotation],
|
|
63
|
+
},
|
|
64
|
+
lists: [],
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CodeIcon } from "lucide-react";
|
|
2
|
+
import { defineField, defineType } from "sanity";
|
|
3
|
+
|
|
4
|
+
import { CodePreview } from "../../blocks/code/preview";
|
|
5
|
+
import { createSanityIcon } from "../../lib/icons";
|
|
6
|
+
|
|
7
|
+
export const handbookCodeType = defineType({
|
|
8
|
+
name: "handbook.code",
|
|
9
|
+
title: "Code",
|
|
10
|
+
type: "object",
|
|
11
|
+
icon: createSanityIcon(CodeIcon),
|
|
12
|
+
description: "A syntax-highlighted code block with language selection.",
|
|
13
|
+
preview: {
|
|
14
|
+
select: { code: "code", language: "language" },
|
|
15
|
+
},
|
|
16
|
+
components: {
|
|
17
|
+
preview: CodePreview,
|
|
18
|
+
},
|
|
19
|
+
fields: [
|
|
20
|
+
defineField({
|
|
21
|
+
name: "code",
|
|
22
|
+
title: "Code",
|
|
23
|
+
type: "text",
|
|
24
|
+
description: "The source code to display.",
|
|
25
|
+
}),
|
|
26
|
+
defineField({
|
|
27
|
+
name: "language",
|
|
28
|
+
title: "Language",
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Programming language used for syntax highlighting.",
|
|
31
|
+
options: {
|
|
32
|
+
list: [
|
|
33
|
+
{ title: "CSS", value: "css" },
|
|
34
|
+
{ title: "GROQ", value: "groq" },
|
|
35
|
+
{ title: "HTML", value: "html" },
|
|
36
|
+
{ title: "JavaScript", value: "javascript" },
|
|
37
|
+
{ title: "JSON", value: "json" },
|
|
38
|
+
{ title: "Plain Text", value: "text" },
|
|
39
|
+
{ title: "React", value: "tsx" },
|
|
40
|
+
{ title: "Shell", value: "shell" },
|
|
41
|
+
{ title: "TypeScript", value: "typescript" },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MinusIcon } from "lucide-react";
|
|
2
|
+
import { defineField, defineType } from "sanity";
|
|
3
|
+
|
|
4
|
+
import { HorizontalRulePreview } from "../../blocks/horizontal-rule/preview";
|
|
5
|
+
import { createSanityIcon } from "../../lib/icons";
|
|
6
|
+
|
|
7
|
+
export const handbookHorizontalRuleType = defineType({
|
|
8
|
+
name: "handbook.horizontalRule",
|
|
9
|
+
title: "Horizontal Rule",
|
|
10
|
+
type: "object",
|
|
11
|
+
icon: createSanityIcon(MinusIcon),
|
|
12
|
+
description: "A visual divider between sections of content.",
|
|
13
|
+
preview: {
|
|
14
|
+
prepare() {
|
|
15
|
+
return { title: "Horizontal Rule" };
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
components: {
|
|
19
|
+
preview: HorizontalRulePreview,
|
|
20
|
+
},
|
|
21
|
+
fields: [
|
|
22
|
+
defineField({
|
|
23
|
+
name: "style",
|
|
24
|
+
title: "Style",
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Presentation style for the divider.",
|
|
27
|
+
hidden: true,
|
|
28
|
+
initialValue: "default",
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
});
|