@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
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { ChevronDownIcon, ChevronRightIcon } from "@sanity/icons";
|
|
2
|
+
import { Box, Card, Flex, Popover, Stack, Text, useClickOutsideEvent } from "@sanity/ui";
|
|
3
|
+
import { InfoIcon, LightbulbIcon, TriangleAlertIcon } from "lucide-react";
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import type { FieldDefinition, SchemaType } from "sanity";
|
|
7
|
+
import { useSchema } from "sanity";
|
|
8
|
+
|
|
9
|
+
import { useHandbookContext } from "../contexts/handbook-context";
|
|
10
|
+
import { defaultIconProps } from "../lib/icons";
|
|
11
|
+
import { convertCase, isDefined } from "../lib/utils";
|
|
12
|
+
|
|
13
|
+
interface FieldProps {
|
|
14
|
+
/** The schema field definition to render. */
|
|
15
|
+
field: FieldDefinition;
|
|
16
|
+
/** Current nesting depth for recursive subfield rendering. */
|
|
17
|
+
depth?: number;
|
|
18
|
+
/** Type names already expanded in the ancestor chain, used to detect cycles. */
|
|
19
|
+
ancestors?: Set<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CompiledField {
|
|
23
|
+
/** Field identifier in the schema. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Resolved type object containing the type name and base type metadata. */
|
|
26
|
+
type: { name: string; title?: string; description?: string; fields?: unknown[]; of?: unknown[] };
|
|
27
|
+
/** Custom title set directly on inline object definitions. */
|
|
28
|
+
title?: string;
|
|
29
|
+
/** Custom description set directly on inline object definitions. */
|
|
30
|
+
description?: string;
|
|
31
|
+
/** Fields defined directly on inline object definitions. */
|
|
32
|
+
fields?: unknown[];
|
|
33
|
+
/** Array members defined directly on inline array definitions. */
|
|
34
|
+
of?: unknown[];
|
|
35
|
+
/** Optional handbook metadata annotations for this field. */
|
|
36
|
+
handbook?: FieldDefinition["handbook"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FieldHintProps {
|
|
40
|
+
/** Accessible label and popover heading. */
|
|
41
|
+
label: string;
|
|
42
|
+
/** Icon element displayed on the hint button. */
|
|
43
|
+
icon: ReactNode;
|
|
44
|
+
/** CSS color applied to the hint icon. */
|
|
45
|
+
color: string;
|
|
46
|
+
/** Whether the popover is currently visible. */
|
|
47
|
+
open: boolean;
|
|
48
|
+
/** Text content displayed inside the popover. */
|
|
49
|
+
children: string;
|
|
50
|
+
/** Callback to toggle popover visibility. */
|
|
51
|
+
onToggle: () => void;
|
|
52
|
+
/** Callback to close the popover. */
|
|
53
|
+
onClose: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Schema type names treated as leaf types whose subfields are not displayed. */
|
|
57
|
+
const opaqueTypes = new Set([
|
|
58
|
+
"block",
|
|
59
|
+
"boolean",
|
|
60
|
+
"crossDatasetReference",
|
|
61
|
+
"date",
|
|
62
|
+
"datetime",
|
|
63
|
+
"file",
|
|
64
|
+
"geopoint",
|
|
65
|
+
"image",
|
|
66
|
+
"number",
|
|
67
|
+
"reference",
|
|
68
|
+
"slug",
|
|
69
|
+
"string",
|
|
70
|
+
"text",
|
|
71
|
+
"url",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Fields injected by Sanity's built-in opaque types during schema compilation. */
|
|
75
|
+
const inheritedFieldNames: Partial<Record<string, Set<string>>> = {
|
|
76
|
+
block: new Set(["children", "level", "listItem", "markDefs", "style"]),
|
|
77
|
+
file: new Set(["asset", "media"]),
|
|
78
|
+
geopoint: new Set(["alt", "lat", "lng"]),
|
|
79
|
+
image: new Set(["asset", "crop", "hotspot", "media"]),
|
|
80
|
+
slug: new Set(["current", "source"]),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Type guard that checks whether a value is a compiled schema field with a nested type object.
|
|
85
|
+
*
|
|
86
|
+
* @param value - The value to check.
|
|
87
|
+
* @returns `true` if the value is a compiled field.
|
|
88
|
+
*/
|
|
89
|
+
function isCompiledField(value: unknown): value is CompiledField {
|
|
90
|
+
if (typeof value !== "object" || value === null) return false;
|
|
91
|
+
if (!("name" in value) || !("type" in value)) return false;
|
|
92
|
+
|
|
93
|
+
return typeof value.type === "object" && value.type !== null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Type guard that checks whether a value has a `fields` array property.
|
|
98
|
+
*
|
|
99
|
+
* @param value - The value to check.
|
|
100
|
+
* @returns `true` if the value contains a `fields` array.
|
|
101
|
+
*/
|
|
102
|
+
function hasFieldsArray(value: unknown): value is { fields: unknown[] } {
|
|
103
|
+
if (typeof value !== "object" || value === null || !("fields" in value)) return false;
|
|
104
|
+
|
|
105
|
+
return Array.isArray(value.fields);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Type guard that checks whether a value has an `of` array property.
|
|
110
|
+
*
|
|
111
|
+
* @param value - The value to check.
|
|
112
|
+
* @returns `true` if the value contains an `of` array.
|
|
113
|
+
*/
|
|
114
|
+
function hasOfArray(value: unknown): value is { of: unknown[] } {
|
|
115
|
+
if (typeof value !== "object" || value === null || !("of" in value)) return false;
|
|
116
|
+
|
|
117
|
+
return Array.isArray(value.of);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extracts the string `type` property from an unknown value, if present.
|
|
122
|
+
*
|
|
123
|
+
* @param value - The value to inspect.
|
|
124
|
+
* @returns The type name string, or `undefined`.
|
|
125
|
+
*/
|
|
126
|
+
function getTypeName(value: unknown) {
|
|
127
|
+
if (typeof value !== "object" || value === null || !("type" in value)) return undefined;
|
|
128
|
+
|
|
129
|
+
return typeof value.type === "string" ? value.type : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Determines whether a schema type is opaque (a leaf type whose subfields should not be displayed).
|
|
134
|
+
*
|
|
135
|
+
* @param typeName - The schema type name to check.
|
|
136
|
+
* @returns `true` if the type is opaque.
|
|
137
|
+
*/
|
|
138
|
+
function isOpaqueType(typeName: string) {
|
|
139
|
+
return opaqueTypes.has(typeName);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extracts the parent type name from a compiled schema type, if present.
|
|
144
|
+
*
|
|
145
|
+
* @param resolvedType - The compiled schema type to inspect.
|
|
146
|
+
* @returns The parent type name, or `undefined`.
|
|
147
|
+
*/
|
|
148
|
+
function getParentTypeName(resolvedType: unknown) {
|
|
149
|
+
if (typeof resolvedType !== "object" || resolvedType === null || !("type" in resolvedType)) return undefined;
|
|
150
|
+
if (typeof resolvedType.type !== "object" || resolvedType.type === null || !("name" in resolvedType.type)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return String(resolvedType.type.name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Normalises a compiled schema field into a FieldDefinition-compatible shape.
|
|
159
|
+
* Tracks visited type names to prevent infinite recursion on circular references.
|
|
160
|
+
*
|
|
161
|
+
* @param field - A compiled schema field or plain field definition.
|
|
162
|
+
* @param visited - Type names already seen in this normalisation chain.
|
|
163
|
+
* @returns A FieldDefinition-compatible object.
|
|
164
|
+
*/
|
|
165
|
+
function normaliseField(field: unknown, visited = new Set<string>()) {
|
|
166
|
+
if (isCompiledField(field)) {
|
|
167
|
+
const typeName = field.type.name;
|
|
168
|
+
|
|
169
|
+
const result: Record<string, unknown> = {
|
|
170
|
+
name: field.name,
|
|
171
|
+
type: typeName,
|
|
172
|
+
title: field.title ?? field.type.title,
|
|
173
|
+
description: field.description ?? field.type.description,
|
|
174
|
+
handbook: field.handbook,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (!visited.has(typeName)) {
|
|
178
|
+
const shouldTrack = !opaqueTypes.has(typeName) && typeName !== "object" && typeName !== "array";
|
|
179
|
+
const next = shouldTrack ? new Set([...visited, typeName]) : visited;
|
|
180
|
+
const fieldSource = hasFieldsArray(field) ? field : field.type;
|
|
181
|
+
const ofSource = hasOfArray(field) ? field : field.type;
|
|
182
|
+
|
|
183
|
+
if (hasFieldsArray(fieldSource)) {
|
|
184
|
+
result.fields = fieldSource.fields.map((subfield) => normaliseField(subfield, next));
|
|
185
|
+
}
|
|
186
|
+
if (hasOfArray(ofSource)) result.of = ofSource.of.map((member) => normaliseField(member, next));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- Constructed to match FieldDefinition shape.
|
|
190
|
+
return result as unknown as FieldDefinition;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- Already a plain FieldDefinition.
|
|
194
|
+
return field as FieldDefinition;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolves only the fields a custom type adds on top of an opaque base type,
|
|
199
|
+
* excluding fields injected by the base type during schema compilation.
|
|
200
|
+
*
|
|
201
|
+
* @param resolvedType - The compiled or normalised schema type.
|
|
202
|
+
* @param parentName - The opaque base type name.
|
|
203
|
+
* @returns The custom fields, or `undefined` if none exist.
|
|
204
|
+
*/
|
|
205
|
+
function resolveCustomFields(resolvedType: unknown, parentName: string) {
|
|
206
|
+
if (!hasFieldsArray(resolvedType)) return undefined;
|
|
207
|
+
|
|
208
|
+
const inherited = inheritedFieldNames[parentName];
|
|
209
|
+
|
|
210
|
+
const customFields = resolvedType.fields
|
|
211
|
+
.map((field: unknown) => normaliseField(field))
|
|
212
|
+
.filter((field) => inherited === undefined || !inherited.has(field.name));
|
|
213
|
+
|
|
214
|
+
return isDefined(customFields) ? customFields : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Normalises a list of raw field values into FieldDefinition-compatible shapes.
|
|
219
|
+
*
|
|
220
|
+
* @param items - Raw field values to normalise.
|
|
221
|
+
* @returns The normalised fields, or `undefined` if none exist.
|
|
222
|
+
*/
|
|
223
|
+
function resolveNormalisedFields(items: unknown[]) {
|
|
224
|
+
const normalised = items.map((item) => normaliseField(item));
|
|
225
|
+
|
|
226
|
+
return isDefined(normalised) ? normalised : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Normalises array members and filters out opaque types that have no meaningful subfields.
|
|
231
|
+
*
|
|
232
|
+
* @param members - Raw array member values to normalise.
|
|
233
|
+
* @returns The normalised non-opaque members, or `undefined` if none remain.
|
|
234
|
+
*/
|
|
235
|
+
function resolveNormalisedMembers(members: unknown[]) {
|
|
236
|
+
const normalised = members.map((member) => normaliseField(member)).filter((member) => !isOpaqueType(member.type));
|
|
237
|
+
|
|
238
|
+
return isDefined(normalised) ? normalised : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolves the parent type name from a value, trying direct extraction first
|
|
243
|
+
* and falling back to a schema lookup for normalised values with string types.
|
|
244
|
+
*
|
|
245
|
+
* @param value - The value to inspect.
|
|
246
|
+
* @param schema - The Sanity schema registry.
|
|
247
|
+
* @returns The parent type name, or `undefined`.
|
|
248
|
+
*/
|
|
249
|
+
function resolveParentName(value: unknown, schema: { get: (name: string) => SchemaType | undefined }) {
|
|
250
|
+
const direct = getParentTypeName(value);
|
|
251
|
+
if (isDefined(direct)) return direct;
|
|
252
|
+
|
|
253
|
+
const typeName = getTypeName(value);
|
|
254
|
+
if (!isDefined(typeName)) return undefined;
|
|
255
|
+
|
|
256
|
+
const resolvedType = schema.get(typeName);
|
|
257
|
+
return isDefined(resolvedType) ? getParentTypeName(resolvedType) : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Attempts to extract displayable fields from a value by checking for direct fields,
|
|
262
|
+
* an `of` array, or a schema type lookup on single array members.
|
|
263
|
+
*
|
|
264
|
+
* @param value - The value to extract fields from.
|
|
265
|
+
* @param schema - The Sanity schema registry.
|
|
266
|
+
* @returns The resolved fields, or `undefined` if the value has none.
|
|
267
|
+
*/
|
|
268
|
+
function resolveFields(value: unknown, schema: { get: (name: string) => SchemaType | undefined }) {
|
|
269
|
+
if (hasFieldsArray(value)) {
|
|
270
|
+
const parentName = resolveParentName(value, schema);
|
|
271
|
+
if (isDefined(parentName) && isOpaqueType(parentName)) return resolveCustomFields(value, parentName);
|
|
272
|
+
|
|
273
|
+
return resolveNormalisedFields(value.fields);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (hasOfArray(value)) {
|
|
277
|
+
const members = value.of;
|
|
278
|
+
|
|
279
|
+
if (members.length === 1) {
|
|
280
|
+
const [member] = members;
|
|
281
|
+
const normalised = normaliseField(member);
|
|
282
|
+
|
|
283
|
+
if (isOpaqueType(normalised.type)) return undefined;
|
|
284
|
+
|
|
285
|
+
if (hasFieldsArray(normalised)) {
|
|
286
|
+
const parentName = resolveParentName(normalised, schema);
|
|
287
|
+
if (isDefined(parentName) && isOpaqueType(parentName)) return resolveCustomFields(normalised, parentName);
|
|
288
|
+
|
|
289
|
+
return resolveNormalisedFields(normalised.fields);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const memberType = schema.get(normalised.type);
|
|
293
|
+
if (isDefined(memberType)) return resolveFields(memberType, schema);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return resolveNormalisedMembers(members);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolves the displayable subfields of a field definition by traversing the schema.
|
|
304
|
+
*
|
|
305
|
+
* @param field - The parent field definition.
|
|
306
|
+
* @param schema - The Sanity schema registry.
|
|
307
|
+
* @returns The non-opaque subfields, or `undefined` if the field has none.
|
|
308
|
+
*/
|
|
309
|
+
function getSubfields(field: FieldDefinition, schema: { get: (name: string) => SchemaType | undefined }) {
|
|
310
|
+
if (isOpaqueType(field.type)) return undefined;
|
|
311
|
+
|
|
312
|
+
const directFields = resolveFields(field, schema);
|
|
313
|
+
if (directFields !== undefined) return directFields;
|
|
314
|
+
|
|
315
|
+
const resolvedType = schema.get(field.type);
|
|
316
|
+
if (!isDefined(resolvedType)) return undefined;
|
|
317
|
+
|
|
318
|
+
const parentName = getParentTypeName(resolvedType);
|
|
319
|
+
if (isDefined(parentName) && isOpaqueType(parentName)) {
|
|
320
|
+
return resolveCustomFields(resolvedType, parentName);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return resolveFields(resolvedType, schema);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function FieldHint({ label, icon, color, open, children, onToggle, onClose }: FieldHintProps) {
|
|
327
|
+
const buttonReference = useRef<HTMLButtonElement | null>(null);
|
|
328
|
+
const popoverReference = useRef<HTMLDivElement | null>(null);
|
|
329
|
+
|
|
330
|
+
useClickOutsideEvent(open ? onClose : undefined, () => [buttonReference.current, popoverReference.current]);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<Popover
|
|
334
|
+
open={open}
|
|
335
|
+
placement="top"
|
|
336
|
+
portal
|
|
337
|
+
radius={2}
|
|
338
|
+
shadow={2}
|
|
339
|
+
content={
|
|
340
|
+
<div ref={popoverReference}>
|
|
341
|
+
<Box padding={3} style={{ maxWidth: "280px" }}>
|
|
342
|
+
<Stack space={3}>
|
|
343
|
+
<Text size={1} weight="semibold">
|
|
344
|
+
{label}
|
|
345
|
+
</Text>
|
|
346
|
+
<Text size={1} muted>
|
|
347
|
+
{children}
|
|
348
|
+
</Text>
|
|
349
|
+
</Stack>
|
|
350
|
+
</Box>
|
|
351
|
+
</div>
|
|
352
|
+
}
|
|
353
|
+
>
|
|
354
|
+
<button
|
|
355
|
+
ref={buttonReference}
|
|
356
|
+
aria-label={label}
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={onToggle}
|
|
359
|
+
onMouseEnter={onToggle}
|
|
360
|
+
onMouseLeave={onClose}
|
|
361
|
+
style={{
|
|
362
|
+
display: "flex",
|
|
363
|
+
alignItems: "center",
|
|
364
|
+
padding: "2px",
|
|
365
|
+
fontSize: "16px",
|
|
366
|
+
color,
|
|
367
|
+
background: "none",
|
|
368
|
+
border: "none",
|
|
369
|
+
cursor: "pointer",
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{icon}
|
|
373
|
+
</button>
|
|
374
|
+
</Popover>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function FieldHints({ tip, info, caution }: { tip?: string; info?: string; caution?: string }) {
|
|
379
|
+
const [openHint, setOpenHint] = useState<string | null>(null);
|
|
380
|
+
|
|
381
|
+
const closeHint = useCallback(() => {
|
|
382
|
+
setOpenHint(null);
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<Flex align="center" gap={2}>
|
|
387
|
+
{isDefined(tip) && (
|
|
388
|
+
<FieldHint
|
|
389
|
+
label="Tip"
|
|
390
|
+
icon={<LightbulbIcon {...defaultIconProps} />}
|
|
391
|
+
color="var(--card-icon-color)"
|
|
392
|
+
open={openHint === "tip"}
|
|
393
|
+
onToggle={() => {
|
|
394
|
+
setOpenHint(openHint === "tip" ? null : "tip");
|
|
395
|
+
}}
|
|
396
|
+
onClose={closeHint}
|
|
397
|
+
>
|
|
398
|
+
{tip}
|
|
399
|
+
</FieldHint>
|
|
400
|
+
)}
|
|
401
|
+
{isDefined(info) && (
|
|
402
|
+
<FieldHint
|
|
403
|
+
label="Information"
|
|
404
|
+
icon={<InfoIcon {...defaultIconProps} />}
|
|
405
|
+
color="var(--card-icon-color)"
|
|
406
|
+
open={openHint === "info"}
|
|
407
|
+
onToggle={() => {
|
|
408
|
+
setOpenHint(openHint === "info" ? null : "info");
|
|
409
|
+
}}
|
|
410
|
+
onClose={closeHint}
|
|
411
|
+
>
|
|
412
|
+
{info}
|
|
413
|
+
</FieldHint>
|
|
414
|
+
)}
|
|
415
|
+
{isDefined(caution) && (
|
|
416
|
+
<FieldHint
|
|
417
|
+
label="Caution"
|
|
418
|
+
icon={<TriangleAlertIcon {...defaultIconProps} />}
|
|
419
|
+
color="var(--card-icon-color)"
|
|
420
|
+
open={openHint === "caution"}
|
|
421
|
+
onToggle={() => {
|
|
422
|
+
setOpenHint(openHint === "caution" ? null : "caution");
|
|
423
|
+
}}
|
|
424
|
+
onClose={closeHint}
|
|
425
|
+
>
|
|
426
|
+
{caution}
|
|
427
|
+
</FieldHint>
|
|
428
|
+
)}
|
|
429
|
+
</Flex>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function Field({ field, depth = 0, ancestors = new Set() }: FieldProps) {
|
|
434
|
+
const schema = useSchema();
|
|
435
|
+
const { undocumentedFieldMessage } = useHandbookContext();
|
|
436
|
+
const [expanded, setExpanded] = useState(false);
|
|
437
|
+
|
|
438
|
+
const effectiveTitle = field.handbook?.title ?? field.title ?? (field.name && convertCase(field.name, "title"));
|
|
439
|
+
const effectiveDescription = field.handbook?.description ?? field.description ?? undocumentedFieldMessage;
|
|
440
|
+
|
|
441
|
+
const { example, tip, info, caution } = field.handbook ?? {};
|
|
442
|
+
const hasHints = isDefined([tip, info, caution]);
|
|
443
|
+
|
|
444
|
+
const isCycle = ancestors.has(field.type);
|
|
445
|
+
const subfields = isCycle ? undefined : getSubfields(field, schema);
|
|
446
|
+
const hasSubfields = isDefined(subfields);
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<Stack space={2}>
|
|
450
|
+
<Stack space={3}>
|
|
451
|
+
<Flex align="center" paddingY={1} gap={2}>
|
|
452
|
+
<Box flex={1}>
|
|
453
|
+
<Flex align="center" gap={2}>
|
|
454
|
+
<Text size={1} weight="medium">
|
|
455
|
+
{effectiveTitle}
|
|
456
|
+
</Text>
|
|
457
|
+
<Text size={0} muted style={{ marginTop: "2px" }}>
|
|
458
|
+
{field.type}
|
|
459
|
+
</Text>
|
|
460
|
+
</Flex>
|
|
461
|
+
</Box>
|
|
462
|
+
{hasHints && <FieldHints tip={tip} info={info} caution={caution} />}
|
|
463
|
+
</Flex>
|
|
464
|
+
{isDefined(effectiveDescription) && (
|
|
465
|
+
<Text size={1} muted>
|
|
466
|
+
{effectiveDescription}
|
|
467
|
+
</Text>
|
|
468
|
+
)}
|
|
469
|
+
{isDefined(example) && (
|
|
470
|
+
<Box marginTop={1}>
|
|
471
|
+
<Text size={1} muted style={{ fontStyle: "italic" }}>
|
|
472
|
+
e.g. {example}
|
|
473
|
+
</Text>
|
|
474
|
+
</Box>
|
|
475
|
+
)}
|
|
476
|
+
</Stack>
|
|
477
|
+
{isCycle && (
|
|
478
|
+
<Box marginTop={2}>
|
|
479
|
+
<Text size={1} muted style={{ fontStyle: "italic" }}>
|
|
480
|
+
See {convertCase(field.type, "title")} above.
|
|
481
|
+
</Text>
|
|
482
|
+
</Box>
|
|
483
|
+
)}
|
|
484
|
+
{hasSubfields && (
|
|
485
|
+
<>
|
|
486
|
+
<Card
|
|
487
|
+
as="button"
|
|
488
|
+
padding={3}
|
|
489
|
+
radius={2}
|
|
490
|
+
tone="transparent"
|
|
491
|
+
onClick={() => {
|
|
492
|
+
setExpanded(!expanded);
|
|
493
|
+
}}
|
|
494
|
+
style={{ width: "100%", marginTop: "1rem", textAlign: "left", border: "none", cursor: "pointer" }}
|
|
495
|
+
>
|
|
496
|
+
<Flex align="center" gap={2}>
|
|
497
|
+
<Text size={0}>{expanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</Text>
|
|
498
|
+
<Text size={1} muted weight="medium">
|
|
499
|
+
{subfields.length} subfield{subfields.length === 1 ? "" : "s"}
|
|
500
|
+
</Text>
|
|
501
|
+
</Flex>
|
|
502
|
+
</Card>
|
|
503
|
+
{expanded && (
|
|
504
|
+
<div
|
|
505
|
+
style={{
|
|
506
|
+
marginTop: "16px",
|
|
507
|
+
marginBottom: "8px",
|
|
508
|
+
paddingLeft: "16px",
|
|
509
|
+
borderLeft: "1px solid var(--card-border-color)",
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
<Stack space={5}>
|
|
513
|
+
{subfields.map((subfield) => (
|
|
514
|
+
<Field
|
|
515
|
+
key={subfield.name}
|
|
516
|
+
field={subfield}
|
|
517
|
+
depth={depth + 1}
|
|
518
|
+
ancestors={new Set([...ancestors, field.type])}
|
|
519
|
+
/>
|
|
520
|
+
))}
|
|
521
|
+
</Stack>
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
</>
|
|
525
|
+
)}
|
|
526
|
+
</Stack>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { PortableText } from "@portabletext/react";
|
|
2
|
+
import type { PortableTextBlock, PortableTextComponents } from "@portabletext/react";
|
|
3
|
+
import { Box, Text } from "@sanity/ui";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
import { CalloutBlock } from "../blocks/callout/block";
|
|
8
|
+
import { CodeBlock } from "../blocks/code/block";
|
|
9
|
+
import { HorizontalRuleBlock } from "../blocks/horizontal-rule/block";
|
|
10
|
+
import { ImageBlock } from "../blocks/image/block";
|
|
11
|
+
import { VideoBlock } from "../blocks/video/block";
|
|
12
|
+
import { useHandbookContext } from "../contexts/handbook-context";
|
|
13
|
+
|
|
14
|
+
/** Portable Text component overrides for rendering guide content. */
|
|
15
|
+
const portableTextComponents: PortableTextComponents = {
|
|
16
|
+
block: {
|
|
17
|
+
h2: ({ children }) => (
|
|
18
|
+
<div style={{ marginBlockStart: "3rem", marginBlockEnd: "0.25rem" }}>
|
|
19
|
+
<Text as="h2" size={1} weight="medium">
|
|
20
|
+
{children}
|
|
21
|
+
</Text>
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
normal: ({ children }) => (
|
|
25
|
+
<div style={{ marginBlock: "1rem" }}>
|
|
26
|
+
<Text size={1} muted>
|
|
27
|
+
{children}
|
|
28
|
+
</Text>
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
blockquote: ({ children }) => (
|
|
32
|
+
<div style={{ marginBlock: "2rem" }}>
|
|
33
|
+
<Box paddingLeft={3} paddingY={1} style={{ borderLeft: "2px solid var(--card-border-color)" }}>
|
|
34
|
+
<Text size={1} muted style={{ fontStyle: "italic" }}>
|
|
35
|
+
{children}
|
|
36
|
+
</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
list: {
|
|
42
|
+
bullet: ({ children, value }) => (
|
|
43
|
+
<div style={{ marginBlock: value.level > 1 ? "0.75rem" : "2rem" }}>
|
|
44
|
+
<Text size={1} muted>
|
|
45
|
+
<ul style={{ display: "flex", flexDirection: "column", gap: "0.375rem", margin: 0, paddingLeft: "1.25em" }}>
|
|
46
|
+
{children}
|
|
47
|
+
</ul>
|
|
48
|
+
</Text>
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
number: ({ children, value }) => (
|
|
52
|
+
<div style={{ marginBlock: value.level > 1 ? "0.75rem" : "2rem" }}>
|
|
53
|
+
<Text size={1} muted>
|
|
54
|
+
<ol style={{ display: "flex", flexDirection: "column", gap: "0.375rem", margin: 0, paddingLeft: "1.25em" }}>
|
|
55
|
+
{children}
|
|
56
|
+
</ol>
|
|
57
|
+
</Text>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
listItem: {
|
|
62
|
+
bullet: ({ children }) => <li>{children}</li>,
|
|
63
|
+
number: ({ children }) => <li>{children}</li>,
|
|
64
|
+
},
|
|
65
|
+
marks: {
|
|
66
|
+
strong: ({ children }) => <strong>{children}</strong>,
|
|
67
|
+
em: ({ children }) => <em>{children}</em>,
|
|
68
|
+
code: ({ children }) => (
|
|
69
|
+
<code
|
|
70
|
+
style={{
|
|
71
|
+
padding: "0.125rem 0.25rem",
|
|
72
|
+
fontSize: "0.875em",
|
|
73
|
+
backgroundColor: "var(--card-code-bg-color)",
|
|
74
|
+
borderRadius: "0.125rem",
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</code>
|
|
79
|
+
),
|
|
80
|
+
link: ({ children, value }: { children: ReactNode; value?: { href?: string } }) => (
|
|
81
|
+
<a href={value?.href} target="_blank" rel="noopener noreferrer" style={{ color: "var(--card-link-fg-color)" }}>
|
|
82
|
+
{children}
|
|
83
|
+
</a>
|
|
84
|
+
),
|
|
85
|
+
"strike-through": ({ children }) => <del>{children}</del>,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function GuideContent({ content }: { content: PortableTextBlock[] }) {
|
|
90
|
+
const { blocks: customBlocks } = useHandbookContext();
|
|
91
|
+
|
|
92
|
+
const components = useMemo<PortableTextComponents>(() => {
|
|
93
|
+
const customTypes: Record<string, unknown> = {};
|
|
94
|
+
|
|
95
|
+
for (const block of customBlocks) {
|
|
96
|
+
customTypes[block.schema.name] = block.component;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...portableTextComponents,
|
|
101
|
+
types: {
|
|
102
|
+
"handbook.image": ImageBlock,
|
|
103
|
+
"handbook.video": VideoBlock,
|
|
104
|
+
"handbook.code": CodeBlock,
|
|
105
|
+
"handbook.callout": CalloutBlock,
|
|
106
|
+
"handbook.horizontalRule": HorizontalRuleBlock,
|
|
107
|
+
...customTypes,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}, [customBlocks]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
<PortableText value={content} components={components} />
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { BookOpenTextIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { useHandbookContext } from "../contexts/handbook-context";
|
|
4
|
+
import { createSanityIcon } from "../lib/icons";
|
|
5
|
+
import { isDefined } from "../lib/utils";
|
|
6
|
+
import { ContentPanel } from "./content-panel";
|
|
7
|
+
import { GuideContent } from "./guide-content";
|
|
8
|
+
import { SidebarGroup } from "./sidebar-group";
|
|
9
|
+
import { SidebarTab } from "./sidebar-tab";
|
|
10
|
+
|
|
11
|
+
export function GuidesTabs() {
|
|
12
|
+
const { configuration, loading } = useHandbookContext();
|
|
13
|
+
|
|
14
|
+
if (loading || !isDefined(configuration)) return null;
|
|
15
|
+
|
|
16
|
+
return configuration.groups.map((group) => (
|
|
17
|
+
<SidebarGroup key={group._key} heading={group.title}>
|
|
18
|
+
{group.guides.map((guide) => (
|
|
19
|
+
<SidebarTab key={guide._key} id={guide._key} label={guide.title} icon={createSanityIcon(BookOpenTextIcon)} />
|
|
20
|
+
))}
|
|
21
|
+
</SidebarGroup>
|
|
22
|
+
));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function GuidesPanels() {
|
|
26
|
+
const { configuration, loading } = useHandbookContext();
|
|
27
|
+
|
|
28
|
+
if (loading || !isDefined(configuration)) return null;
|
|
29
|
+
|
|
30
|
+
return configuration.groups.flatMap((group) =>
|
|
31
|
+
group.guides.map((guide) => (
|
|
32
|
+
<ContentPanel key={guide._key} id={guide._key} title={guide.title} description={guide.description}>
|
|
33
|
+
<GuideContent content={guide.content} />
|
|
34
|
+
</ContentPanel>
|
|
35
|
+
)),
|
|
36
|
+
);
|
|
37
|
+
}
|