bsmnt 0.7.0 → 0.9.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/dist/application/add-hooks/index.d.ts.map +1 -1
- package/dist/application/add-hooks/index.js +1 -0
- package/dist/application/add-hooks/index.js.map +1 -1
- package/dist/application/add-integration/index.d.ts.map +1 -1
- package/dist/application/add-integration/index.js +1 -0
- package/dist/application/add-integration/index.js.map +1 -1
- package/dist/core/create/execute-plan.d.ts +2 -0
- package/dist/core/create/execute-plan.d.ts.map +1 -1
- package/dist/core/create/execute-plan.js +3 -0
- package/dist/core/create/execute-plan.js.map +1 -1
- package/dist/domain/version.d.ts +14 -0
- package/dist/domain/version.d.ts.map +1 -0
- package/dist/domain/version.js +81 -0
- package/dist/domain/version.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/create/executor.d.ts.map +1 -1
- package/dist/infrastructure/create/executor.js +2 -0
- package/dist/infrastructure/create/executor.js.map +1 -1
- package/dist/infrastructure/create/render-generated-docs.d.ts +18 -0
- package/dist/infrastructure/create/render-generated-docs.d.ts.map +1 -0
- package/dist/infrastructure/create/render-generated-docs.js +72 -0
- package/dist/infrastructure/create/render-generated-docs.js.map +1 -0
- package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
- package/dist/infrastructure/create/setup-dotenvx.js +10 -4
- package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
- package/dist/infrastructure/create/setup-remote.d.ts +6 -1
- package/dist/infrastructure/create/setup-remote.d.ts.map +1 -1
- package/dist/infrastructure/create/setup-remote.js +7 -2
- package/dist/infrastructure/create/setup-remote.js.map +1 -1
- package/dist/infrastructure/update/cache.d.ts +14 -0
- package/dist/infrastructure/update/cache.d.ts.map +1 -0
- package/dist/infrastructure/update/cache.js +41 -0
- package/dist/infrastructure/update/cache.js.map +1 -0
- package/dist/infrastructure/update/check-version.d.ts +2 -0
- package/dist/infrastructure/update/check-version.d.ts.map +1 -0
- package/dist/infrastructure/update/check-version.js +33 -0
- package/dist/infrastructure/update/check-version.js.map +1 -0
- package/dist/infrastructure/update/notifier.d.ts +8 -0
- package/dist/infrastructure/update/notifier.d.ts.map +1 -0
- package/dist/infrastructure/update/notifier.js +141 -0
- package/dist/infrastructure/update/notifier.js.map +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/config.js +2 -0
- package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
- package/package.json +2 -1
- package/src/agent-skills/manage-env/SKILL.md +5 -3
- package/src/modules/features/cms/sanity/files/app/blog/[slug]/page.tsx +10 -9
- package/src/modules/features/cms/sanity/files/app/blog/page.tsx +5 -5
- package/src/modules/features/cms/sanity/files/lib/scripts/copy-sanity-mcp.ts +6 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +81 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +7 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx +4 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +72 -21
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +178 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +9 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +5 -4
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +54 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +4 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +1 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +1 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +27 -5
- package/src/modules/features/env/dotenvx/files/decrypt-env.mjs +12 -12
- package/src/modules/features/env/dotenvx/files/push-keys.mjs +3 -3
- package/src/modules/features/env/dotenvx/files/save-key.mjs +3 -3
- package/src/modules/features/env/dotenvx/files/setup-remote.ts +3 -1
- package/src/modules/features/env/dotenvx/files/write-env.mjs +6 -6
- package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/hubspot-form.css +2 -0
- package/src/templates/next-default/.env.example +8 -0
- package/src/templates/next-default/.github/workflows/ci.yml +7 -0
- package/src/templates/next-default/README.md +14 -0
- package/src/templates/next-default/biome.json +1 -1
- package/src/templates/next-default/components/basement.svg +1 -1
- package/src/templates/next-default/knip.json +8 -6
- package/src/templates/next-default/lefthook.yml +0 -5
- package/src/templates/next-experiments/.env.example +8 -0
- package/src/templates/next-experiments/.github/workflows/ci.yml +7 -0
- package/src/templates/next-experiments/README.md +14 -0
- package/src/templates/next-experiments/biome.json +1 -1
- package/src/templates/next-experiments/components/basement.svg +1 -1
- package/src/templates/next-experiments/knip.json +8 -6
- package/src/templates/next-experiments/lefthook.yml +0 -5
- package/src/templates/next-pagebuilder/.env.example +8 -0
- package/src/templates/next-pagebuilder/.github/PULL_REQUEST_TEMPLATE.md +13 -0
- package/src/templates/next-pagebuilder/.github/workflows/ci.yml +15 -0
- package/src/templates/next-pagebuilder/README.md +14 -0
- package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +2 -2
- package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +39 -0
- package/src/templates/next-pagebuilder/biome.json +1 -1
- package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +169 -0
- package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -0
- package/src/templates/next-pagebuilder/knip.json +8 -6
- package/src/templates/next-pagebuilder/lefthook.yml +0 -5
- package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +81 -0
- package/src/templates/next-webgl/.env.example +8 -0
- package/src/templates/next-webgl/.github/workflows/ci.yml +7 -0
- package/src/templates/next-webgl/README.md +14 -0
- package/src/templates/next-webgl/biome.json +1 -1
- package/src/templates/next-webgl/components/basement.svg +1 -1
- package/src/templates/next-webgl/knip.json +8 -6
- package/src/templates/next-webgl/lefthook.yml +0 -5
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { EyeClosedIcon, EyeOpenIcon } from "@sanity/icons";
|
|
2
|
+
import { Badge, Box, Button, Flex, Text, Tooltip, useToast } from "@sanity/ui";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { type ObjectItemProps, useClient, useFormValue } from "sanity";
|
|
5
|
+
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
6
|
+
|
|
7
|
+
const HIDDEN_KEYS_FIELD = "hiddenPageBuilderKeys";
|
|
8
|
+
|
|
9
|
+
type ReferenceItem = { _key: string; _ref: string; _type: string };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Eye-icon toggle for a page builder placement's per-page visibility. Adds the
|
|
13
|
+
* placement's `_key` to the owning document's `hiddenPageBuilderKeys`; GROQ
|
|
14
|
+
* filters those keys out at query time. Standalone control because Sanity's
|
|
15
|
+
* built-in item menu isn't extensible via public props.
|
|
16
|
+
*/
|
|
17
|
+
export function BlockActionsItem(props: ObjectItemProps<ReferenceItem>) {
|
|
18
|
+
const { value, renderDefault } = props;
|
|
19
|
+
const client = useClient({ apiVersion });
|
|
20
|
+
const toast = useToast();
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
|
|
23
|
+
// Open document id (draft while editing).
|
|
24
|
+
const documentId = useFormValue(["_id"]) as string | undefined;
|
|
25
|
+
const hiddenKeys =
|
|
26
|
+
(useFormValue([HIDDEN_KEYS_FIELD]) as string[] | undefined) ?? [];
|
|
27
|
+
const itemKey = value._key;
|
|
28
|
+
const hidden = hiddenKeys.includes(itemKey);
|
|
29
|
+
const label = hidden ? "Show on this page" : "Hide on this page";
|
|
30
|
+
|
|
31
|
+
async function handleToggleHidden() {
|
|
32
|
+
if (busy || !documentId) return;
|
|
33
|
+
setBusy(true);
|
|
34
|
+
try {
|
|
35
|
+
// Atomic array ops (not read-modify-write) so concurrent toggles
|
|
36
|
+
// of different blocks don't clobber each other.
|
|
37
|
+
const patch = client.patch(documentId);
|
|
38
|
+
if (hidden) {
|
|
39
|
+
patch.unset([`${HIDDEN_KEYS_FIELD}[@ == "${itemKey}"]`]);
|
|
40
|
+
} else {
|
|
41
|
+
patch
|
|
42
|
+
.setIfMissing({ [HIDDEN_KEYS_FIELD]: [] })
|
|
43
|
+
.insert("after", `${HIDDEN_KEYS_FIELD}[-1]`, [itemKey]);
|
|
44
|
+
}
|
|
45
|
+
await patch.commit();
|
|
46
|
+
toast.push({
|
|
47
|
+
status: "success",
|
|
48
|
+
title: hidden ? "Block shown" : "Block hidden",
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
toast.push({ status: "error", title: "Failed to update visibility" });
|
|
52
|
+
} finally {
|
|
53
|
+
setBusy(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Flex align="center" gap={1} paddingLeft={1}>
|
|
59
|
+
<Tooltip content={<Text size={1}>{label}</Text>} placement="top" portal>
|
|
60
|
+
<Button
|
|
61
|
+
mode="bleed"
|
|
62
|
+
// Open eye = visible, closed eye = hidden.
|
|
63
|
+
icon={hidden ? EyeClosedIcon : EyeOpenIcon}
|
|
64
|
+
tone={hidden ? "caution" : "default"}
|
|
65
|
+
aria-label={label}
|
|
66
|
+
// Unsaved doc has no id to patch — disable, don't no-op.
|
|
67
|
+
disabled={busy || !documentId}
|
|
68
|
+
onClick={handleToggleHidden}
|
|
69
|
+
/>
|
|
70
|
+
</Tooltip>
|
|
71
|
+
{hidden && (
|
|
72
|
+
<Badge fontSize={0} mode="outline" tone="caution">
|
|
73
|
+
Hidden
|
|
74
|
+
</Badge>
|
|
75
|
+
)}
|
|
76
|
+
<Box flex={1} style={hidden ? { opacity: 0.5 } : undefined}>
|
|
77
|
+
{renderDefault(props)}
|
|
78
|
+
</Box>
|
|
79
|
+
</Flex>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
LayoutIcon,
|
|
16
16
|
ListBulletsIcon,
|
|
17
17
|
ListIcon,
|
|
18
|
+
LockKeyIcon,
|
|
18
19
|
PackageIcon,
|
|
19
20
|
PushPinIcon,
|
|
20
21
|
QuestionIcon,
|
|
@@ -71,6 +72,7 @@ export const customJsonLdIcon = asDuotoneIcon(BracketsCurlyIcon);
|
|
|
71
72
|
export const heroIcon = asDuotoneIcon(RocketIcon);
|
|
72
73
|
export const descriptionIcon = asDuotoneIcon(TextAlignLeftIcon);
|
|
73
74
|
export const contentIcon = asDuotoneIcon(ArticleNyTimesIcon);
|
|
75
|
+
export const gatedContentIcon = asDuotoneIcon(LockKeyIcon);
|
|
74
76
|
|
|
75
77
|
export const pageIcon = asDuotoneIcon(FileTextIcon);
|
|
76
78
|
export const pinIcon = asDuotoneIcon(PushPinIcon);
|
|
@@ -4,6 +4,7 @@ import { singletonComponents } from "./singletons";
|
|
|
4
4
|
export type PageBuilderDocumentType =
|
|
5
5
|
| "hero"
|
|
6
6
|
| "description"
|
|
7
|
+
| "gatedContent"
|
|
7
8
|
| "blogContent"
|
|
8
9
|
| (typeof singletonComponents)[number]["schemaType"];
|
|
9
10
|
|
|
@@ -47,6 +48,12 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
|
|
|
47
48
|
title: "Description",
|
|
48
49
|
groups: ["generic"],
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
documentType: "gatedContent",
|
|
53
|
+
name: "gatedContentReference",
|
|
54
|
+
title: "Gated Content",
|
|
55
|
+
groups: ["generic"],
|
|
56
|
+
},
|
|
50
57
|
{
|
|
51
58
|
documentType: "blogContent",
|
|
52
59
|
name: "blogContentReference",
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-tree-pane.tsx
CHANGED
|
@@ -198,12 +198,10 @@ export const PageTreePane = ({ options }: PageTreePaneProps) => {
|
|
|
198
198
|
{showPinned && (
|
|
199
199
|
<Stack>
|
|
200
200
|
{PINNED_PAGE_ENTRIES.map((entry) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: "ancestor"
|
|
206
|
-
: "none";
|
|
201
|
+
let activeState: ActiveState = "none";
|
|
202
|
+
if (activeChildId === entry.id) {
|
|
203
|
+
activeState = isLeafPane ? "leaf" : "ancestor";
|
|
204
|
+
}
|
|
207
205
|
return (
|
|
208
206
|
<PinnedRow
|
|
209
207
|
key={entry.id}
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts
CHANGED
|
@@ -54,38 +54,89 @@ const richTextLinkProjection = `
|
|
|
54
54
|
}
|
|
55
55
|
`;
|
|
56
56
|
|
|
57
|
+
// `^` is the owning page document, so blocks whose `_key` is in the page's
|
|
58
|
+
// `hiddenPageBuilderKeys` list are filtered out here — a single choke point, so
|
|
59
|
+
// hidden blocks never reach the renderer, the preview, or any derived output
|
|
60
|
+
// built from this projection. `coalesce(..., [])` keeps pages that predate the
|
|
61
|
+
// field (no hidden blocks) rendering normally.
|
|
57
62
|
const pageBuilderProjection = `
|
|
58
|
-
pageBuilder[]{
|
|
63
|
+
pageBuilder[!(_key in coalesce(^.hiddenPageBuilderKeys, []))]{
|
|
59
64
|
_key,
|
|
60
65
|
...@->{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
name,
|
|
72
|
-
avatar,
|
|
73
|
-
role
|
|
74
|
-
},
|
|
75
|
-
body[]{
|
|
66
|
+
// Gated content withholds its payload from the page query — only the
|
|
67
|
+
// teaser and gate config ship to the client. GROQ has no field exclusion,
|
|
68
|
+
// so this branch lists safe fields explicitly rather than spreading every
|
|
69
|
+
// field. The payload is fetched separately via GATED_CONTENT_QUERY.
|
|
70
|
+
_type == "gatedContent" => {
|
|
71
|
+
_id,
|
|
72
|
+
_type,
|
|
73
|
+
internalTitle,
|
|
74
|
+
teaserHeading,
|
|
75
|
+
teaserBody[]{
|
|
76
76
|
${richTextLinkProjection}
|
|
77
77
|
},
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
gateType,
|
|
79
|
+
gateHeadline,
|
|
80
|
+
gateDescription,
|
|
81
|
+
submitLabel,
|
|
82
|
+
successMessage,
|
|
83
|
+
kind
|
|
84
|
+
},
|
|
85
|
+
_type != "gatedContent" => {
|
|
86
|
+
...,
|
|
87
|
+
_type == "blogContent" => {
|
|
88
|
+
...,
|
|
89
|
+
categories[]->{
|
|
90
|
+
_id,
|
|
91
|
+
title,
|
|
92
|
+
slug
|
|
93
|
+
},
|
|
94
|
+
author->{
|
|
95
|
+
_id,
|
|
96
|
+
name,
|
|
97
|
+
avatar,
|
|
98
|
+
role
|
|
99
|
+
},
|
|
100
|
+
body[]{
|
|
101
|
+
${richTextLinkProjection}
|
|
102
|
+
},
|
|
103
|
+
relatedPosts[]->{
|
|
104
|
+
_id,
|
|
105
|
+
title,
|
|
106
|
+
"resolvedSlug": ${pageResolvedSlugExpression},
|
|
107
|
+
metadata
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
110
|
}
|
|
85
111
|
}
|
|
86
112
|
}
|
|
87
113
|
`;
|
|
88
114
|
|
|
115
|
+
// Gated content payload — fetched on demand by the unlock server action, keyed
|
|
116
|
+
// by the block's document id. Kept out of the page query so it never ships to
|
|
117
|
+
// the client until the visitor completes the gate.
|
|
118
|
+
export const GATED_CONTENT_QUERY = defineQuery(`
|
|
119
|
+
*[_type == "gatedContent" && _id == $id][0]{
|
|
120
|
+
kind,
|
|
121
|
+
content[]{
|
|
122
|
+
${richTextLinkProjection}
|
|
123
|
+
},
|
|
124
|
+
"file": file.asset->{
|
|
125
|
+
url,
|
|
126
|
+
originalFilename,
|
|
127
|
+
mimeType,
|
|
128
|
+
size
|
|
129
|
+
},
|
|
130
|
+
"video": {
|
|
131
|
+
"url": videoUrl,
|
|
132
|
+
"file": videoFile.asset->{
|
|
133
|
+
url,
|
|
134
|
+
mimeType
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`);
|
|
139
|
+
|
|
89
140
|
const pageProjection = `
|
|
90
141
|
_id,
|
|
91
142
|
_type,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { blogContent } from "./reusable/blog-content";
|
|
2
2
|
export { description } from "./reusable/description";
|
|
3
|
+
export { gatedContent } from "./reusable/gated-content";
|
|
3
4
|
export { hero } from "./reusable/hero";
|
|
4
5
|
export { blogCollection } from "./singleton/content-collection";
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { defineField, defineType } from "sanity";
|
|
2
|
+
import { gatedContentIcon } from "@/lib/integrations/sanity/icons";
|
|
3
|
+
|
|
4
|
+
type GatedContentParent = { kind?: string };
|
|
5
|
+
|
|
6
|
+
const getKindLabel = (kind: unknown) => {
|
|
7
|
+
if (kind === "file") return "File";
|
|
8
|
+
if (kind === "video") return "Video";
|
|
9
|
+
return "Rich Text";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const requiredForKind =
|
|
13
|
+
(kind: string, message: string) =>
|
|
14
|
+
(value: unknown, context: { parent?: unknown }) => {
|
|
15
|
+
const parent = context.parent as GatedContentParent | undefined;
|
|
16
|
+
if (parent?.kind !== kind) return true;
|
|
17
|
+
const isEmpty =
|
|
18
|
+
value == null || (Array.isArray(value) && value.length === 0);
|
|
19
|
+
return isEmpty ? message : true;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const gatedContent = defineType({
|
|
23
|
+
name: "gatedContent",
|
|
24
|
+
title: "Gated Content",
|
|
25
|
+
type: "document",
|
|
26
|
+
icon: gatedContentIcon,
|
|
27
|
+
description:
|
|
28
|
+
"Content hidden behind a lead-capture gate. Everyone sees the teaser; the payload is withheld from the page until the visitor submits the form.",
|
|
29
|
+
groups: [
|
|
30
|
+
{ name: "teaser", title: "Teaser", default: true },
|
|
31
|
+
{ name: "gate", title: "Gate" },
|
|
32
|
+
{ name: "content", title: "Gated Content" },
|
|
33
|
+
],
|
|
34
|
+
fields: [
|
|
35
|
+
defineField({
|
|
36
|
+
name: "internalTitle",
|
|
37
|
+
title: "Internal Title",
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Only used to identify this block in the Studio.",
|
|
40
|
+
validation: (Rule) => Rule.required(),
|
|
41
|
+
}),
|
|
42
|
+
defineField({
|
|
43
|
+
name: "teaserHeading",
|
|
44
|
+
title: "Teaser Heading",
|
|
45
|
+
type: "string",
|
|
46
|
+
group: "teaser",
|
|
47
|
+
}),
|
|
48
|
+
defineField({
|
|
49
|
+
name: "teaserBody",
|
|
50
|
+
title: "Teaser Body",
|
|
51
|
+
type: "richText",
|
|
52
|
+
group: "teaser",
|
|
53
|
+
description: "Shown to everyone, above the gate.",
|
|
54
|
+
}),
|
|
55
|
+
defineField({
|
|
56
|
+
name: "gateType",
|
|
57
|
+
title: "Unlock With",
|
|
58
|
+
type: "string",
|
|
59
|
+
group: "gate",
|
|
60
|
+
initialValue: "form",
|
|
61
|
+
options: {
|
|
62
|
+
list: [{ title: "Form submission", value: "form" }],
|
|
63
|
+
layout: "radio",
|
|
64
|
+
},
|
|
65
|
+
validation: (Rule) => Rule.required(),
|
|
66
|
+
}),
|
|
67
|
+
defineField({
|
|
68
|
+
name: "gateHeadline",
|
|
69
|
+
title: "Gate Headline",
|
|
70
|
+
type: "string",
|
|
71
|
+
group: "gate",
|
|
72
|
+
initialValue: "Unlock the full content",
|
|
73
|
+
}),
|
|
74
|
+
defineField({
|
|
75
|
+
name: "gateDescription",
|
|
76
|
+
title: "Gate Description",
|
|
77
|
+
type: "text",
|
|
78
|
+
rows: 3,
|
|
79
|
+
group: "gate",
|
|
80
|
+
}),
|
|
81
|
+
defineField({
|
|
82
|
+
name: "submitLabel",
|
|
83
|
+
title: "Submit Button Label",
|
|
84
|
+
type: "string",
|
|
85
|
+
group: "gate",
|
|
86
|
+
initialValue: "Unlock",
|
|
87
|
+
}),
|
|
88
|
+
defineField({
|
|
89
|
+
name: "successMessage",
|
|
90
|
+
title: "Success Message",
|
|
91
|
+
type: "string",
|
|
92
|
+
group: "gate",
|
|
93
|
+
description: "Shown briefly after the gate is unlocked.",
|
|
94
|
+
}),
|
|
95
|
+
defineField({
|
|
96
|
+
name: "kind",
|
|
97
|
+
title: "Content Type",
|
|
98
|
+
type: "string",
|
|
99
|
+
group: "content",
|
|
100
|
+
initialValue: "richText",
|
|
101
|
+
options: {
|
|
102
|
+
list: [
|
|
103
|
+
{ title: "Rich Text", value: "richText" },
|
|
104
|
+
{ title: "File (PDF, etc.)", value: "file" },
|
|
105
|
+
{ title: "Video", value: "video" },
|
|
106
|
+
],
|
|
107
|
+
layout: "radio",
|
|
108
|
+
},
|
|
109
|
+
validation: (Rule) => Rule.required(),
|
|
110
|
+
}),
|
|
111
|
+
defineField({
|
|
112
|
+
name: "content",
|
|
113
|
+
title: "Rich Text",
|
|
114
|
+
type: "richText",
|
|
115
|
+
group: "content",
|
|
116
|
+
description: "Revealed once the visitor completes the unlock action.",
|
|
117
|
+
hidden: ({ parent }) => (parent as GatedContentParent)?.kind !== "richText",
|
|
118
|
+
validation: (Rule) =>
|
|
119
|
+
Rule.custom(requiredForKind("richText", "Rich text is required.")),
|
|
120
|
+
}),
|
|
121
|
+
defineField({
|
|
122
|
+
name: "file",
|
|
123
|
+
title: "File",
|
|
124
|
+
type: "file",
|
|
125
|
+
group: "content",
|
|
126
|
+
description: "PDF or other downloadable asset, revealed on unlock.",
|
|
127
|
+
options: { accept: ".pdf,.doc,.docx,.zip,.epub,.csv,.xlsx" },
|
|
128
|
+
hidden: ({ parent }) => (parent as GatedContentParent)?.kind !== "file",
|
|
129
|
+
validation: (Rule) =>
|
|
130
|
+
Rule.custom((value: unknown, context) => {
|
|
131
|
+
const parent = context.parent as GatedContentParent | undefined;
|
|
132
|
+
if (parent?.kind !== "file") return true;
|
|
133
|
+
const asset = (value as { asset?: unknown } | undefined)?.asset;
|
|
134
|
+
return asset ? true : "A file is required.";
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
defineField({
|
|
138
|
+
name: "videoUrl",
|
|
139
|
+
title: "Video URL",
|
|
140
|
+
type: "url",
|
|
141
|
+
group: "content",
|
|
142
|
+
description: "External embed URL (YouTube, Vimeo, Mux…).",
|
|
143
|
+
hidden: ({ parent }) => (parent as GatedContentParent)?.kind !== "video",
|
|
144
|
+
}),
|
|
145
|
+
defineField({
|
|
146
|
+
name: "videoFile",
|
|
147
|
+
title: "Video File",
|
|
148
|
+
type: "file",
|
|
149
|
+
group: "content",
|
|
150
|
+
description: "Uploaded video, used when no Video URL is set.",
|
|
151
|
+
options: { accept: "video/*" },
|
|
152
|
+
hidden: ({ parent }) => (parent as GatedContentParent)?.kind !== "video",
|
|
153
|
+
validation: (Rule) =>
|
|
154
|
+
Rule.custom((value: unknown, context) => {
|
|
155
|
+
const parent = context.parent as
|
|
156
|
+
| (GatedContentParent & { videoUrl?: string })
|
|
157
|
+
| undefined;
|
|
158
|
+
if (parent?.kind !== "video") return true;
|
|
159
|
+
const asset = (value as { asset?: unknown } | undefined)?.asset;
|
|
160
|
+
if (parent?.videoUrl || asset) return true;
|
|
161
|
+
return "Add a Video URL or upload a video file.";
|
|
162
|
+
}),
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
preview: {
|
|
166
|
+
select: {
|
|
167
|
+
title: "internalTitle",
|
|
168
|
+
heading: "teaserHeading",
|
|
169
|
+
kind: "kind",
|
|
170
|
+
},
|
|
171
|
+
prepare({ title, heading, kind }) {
|
|
172
|
+
return {
|
|
173
|
+
title: title || heading || "Gated Content",
|
|
174
|
+
subtitle: `Gated Content · ${getKindLabel(kind)}`,
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { SchemaTypeDefinition } from "sanity";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
blogCollection,
|
|
13
|
+
blogContent,
|
|
14
|
+
description,
|
|
15
|
+
gatedContent,
|
|
16
|
+
hero,
|
|
17
|
+
} from "./components";
|
|
12
18
|
import { author } from "./content/author";
|
|
13
19
|
import { blogCategory } from "./content/blog-category";
|
|
14
20
|
import { companyData } from "./layout/company-data";
|
|
@@ -51,6 +57,7 @@ export {
|
|
|
51
57
|
externalLink,
|
|
52
58
|
faqJsonLd,
|
|
53
59
|
footer,
|
|
60
|
+
gatedContent,
|
|
54
61
|
hero,
|
|
55
62
|
itemListJsonLd,
|
|
56
63
|
jobPostingJsonLd,
|
|
@@ -103,6 +110,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
|
|
|
103
110
|
blogContent,
|
|
104
111
|
hero,
|
|
105
112
|
description,
|
|
113
|
+
gatedContent,
|
|
106
114
|
|
|
107
115
|
// Layout types (singletons for site-wide layout)
|
|
108
116
|
companyData,
|
|
@@ -106,11 +106,12 @@ export const redirect = defineType({
|
|
|
106
106
|
permanent: "permanent",
|
|
107
107
|
},
|
|
108
108
|
prepare({ source, destination, permanent }) {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
let target = "?";
|
|
110
|
+
if (destination) {
|
|
111
|
+
target = /^https?:\/\//.test(destination)
|
|
111
112
|
? destination
|
|
112
|
-
: `/${destination}
|
|
113
|
-
|
|
113
|
+
: `/${destination}`;
|
|
114
|
+
}
|
|
114
115
|
return {
|
|
115
116
|
title: source ? `/${source}` : "Untitled redirect",
|
|
116
117
|
subtitle: `→ ${target} (${permanent ? "308" : "307"})`,
|
|
@@ -9,12 +9,18 @@ export const metadata = defineType({
|
|
|
9
9
|
collapsible: true,
|
|
10
10
|
collapsed: true,
|
|
11
11
|
},
|
|
12
|
+
groups: [
|
|
13
|
+
{ name: "search", title: "Search", default: true },
|
|
14
|
+
{ name: "social", title: "Social sharing" },
|
|
15
|
+
{ name: "advanced", title: "Advanced" },
|
|
16
|
+
],
|
|
12
17
|
fields: [
|
|
13
18
|
defineField({
|
|
14
19
|
name: "metaTitle",
|
|
15
20
|
title: "Meta Title",
|
|
16
21
|
type: "string",
|
|
17
|
-
|
|
22
|
+
group: "search",
|
|
23
|
+
description: "Title used by search engines and browser tabs",
|
|
18
24
|
validation: (Rule) =>
|
|
19
25
|
Rule.max(60).warning(
|
|
20
26
|
"Title should be under 60 characters for optimal SEO",
|
|
@@ -25,25 +31,71 @@ export const metadata = defineType({
|
|
|
25
31
|
title: "Meta Description",
|
|
26
32
|
type: "text",
|
|
27
33
|
rows: 3,
|
|
34
|
+
group: "search",
|
|
28
35
|
description: "Description used by search engines",
|
|
29
36
|
validation: (Rule) =>
|
|
30
37
|
Rule.max(160).warning(
|
|
31
38
|
"Description should be under 160 characters for optimal SEO",
|
|
32
39
|
),
|
|
33
40
|
}),
|
|
41
|
+
defineField({
|
|
42
|
+
name: "keywords",
|
|
43
|
+
title: "Keywords",
|
|
44
|
+
type: "array",
|
|
45
|
+
group: "search",
|
|
46
|
+
of: [defineArrayMember({ type: "string" })],
|
|
47
|
+
options: { layout: "tags" },
|
|
48
|
+
description:
|
|
49
|
+
"Optional keywords/topics for this page. Press Enter after each.",
|
|
50
|
+
}),
|
|
34
51
|
defineField({
|
|
35
52
|
name: "image",
|
|
36
53
|
title: "OG Image",
|
|
37
54
|
type: "image",
|
|
55
|
+
group: "social",
|
|
38
56
|
description: "Image for social sharing previews (1200x630px recommended)",
|
|
39
57
|
options: {
|
|
40
58
|
hotspot: true,
|
|
41
59
|
},
|
|
42
60
|
}),
|
|
61
|
+
defineField({
|
|
62
|
+
name: "ogTitle",
|
|
63
|
+
title: "Social Title",
|
|
64
|
+
type: "string",
|
|
65
|
+
group: "social",
|
|
66
|
+
description:
|
|
67
|
+
"Title shown when shared on social (Open Graph & Twitter). Falls back to Meta Title.",
|
|
68
|
+
validation: (Rule) =>
|
|
69
|
+
Rule.max(70).warning("Social titles are often truncated past ~70 chars"),
|
|
70
|
+
}),
|
|
71
|
+
defineField({
|
|
72
|
+
name: "ogDescription",
|
|
73
|
+
title: "Social Description",
|
|
74
|
+
type: "text",
|
|
75
|
+
rows: 3,
|
|
76
|
+
group: "social",
|
|
77
|
+
description:
|
|
78
|
+
"Description shown when shared on social. Falls back to Meta Description.",
|
|
79
|
+
validation: (Rule) =>
|
|
80
|
+
Rule.max(200).warning(
|
|
81
|
+
"Social descriptions are often truncated past ~200 chars",
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
defineField({
|
|
85
|
+
name: "canonicalUrl",
|
|
86
|
+
title: "Canonical URL",
|
|
87
|
+
type: "url",
|
|
88
|
+
group: "advanced",
|
|
89
|
+
description:
|
|
90
|
+
"Override the canonical URL for this page. Leave blank to use the page's own URL. Use for duplicate/syndicated content.",
|
|
91
|
+
validation: (Rule) =>
|
|
92
|
+
Rule.uri({ scheme: ["http", "https"], allowRelative: true }),
|
|
93
|
+
}),
|
|
43
94
|
defineField({
|
|
44
95
|
name: "index",
|
|
45
96
|
title: "Index",
|
|
46
97
|
type: "boolean",
|
|
98
|
+
group: "advanced",
|
|
47
99
|
description: "Allow search engines to index this page",
|
|
48
100
|
initialValue: true,
|
|
49
101
|
}),
|
|
@@ -51,6 +103,7 @@ export const metadata = defineType({
|
|
|
51
103
|
name: "jsonLd",
|
|
52
104
|
title: "Structured Data (JSON-LD)",
|
|
53
105
|
type: "array",
|
|
106
|
+
group: "advanced",
|
|
54
107
|
description:
|
|
55
108
|
"Page-level schema.org structured data emitted as JSON-LD. Pick a predefined type, or add a Custom JSON-LD block for anything not listed.",
|
|
56
109
|
of: pageJsonLdMembers.map((member) =>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineArrayMember, defineType } from "sanity";
|
|
2
|
+
import { BlockActionsItem } from "@/lib/integrations/sanity/components/block-actions-item";
|
|
2
3
|
import { PageBuilderInput } from "@/lib/integrations/sanity/components/page-builder-input";
|
|
3
4
|
import {
|
|
4
5
|
pageBuilderInsertMenuGroups,
|
|
@@ -22,6 +23,9 @@ export const pageBuilder = defineType({
|
|
|
22
23
|
title: member.title,
|
|
23
24
|
type: "reference",
|
|
24
25
|
to: [{ type: member.documentType }],
|
|
26
|
+
// Per-placement Hide/Show action; hidden state lives in the owning
|
|
27
|
+
// document's `hiddenPageBuilderKeys`, not on the ref itself.
|
|
28
|
+
components: { item: BlockActionsItem },
|
|
25
29
|
...(member.documentId
|
|
26
30
|
? {
|
|
27
31
|
options: {
|
|
@@ -204,6 +204,17 @@ export const page = defineType({
|
|
|
204
204
|
return `These page builder components do not belong to the "${pageType ?? "generic"}" category: ${invalidMemberTitles.join(", ")}`;
|
|
205
205
|
}),
|
|
206
206
|
}),
|
|
207
|
+
defineField({
|
|
208
|
+
name: "hiddenPageBuilderKeys",
|
|
209
|
+
title: "Hidden Blocks",
|
|
210
|
+
type: "array",
|
|
211
|
+
of: [{ type: "string" }],
|
|
212
|
+
// The `_key`s of page builder placements hidden on this page. Managed
|
|
213
|
+
// via each block's Hide/Show action (see BlockActionsItem); never edited
|
|
214
|
+
// directly. Declaring it keeps the Studio from warning about an unknown
|
|
215
|
+
// field. Filtered out at query time in `pageBuilderProjection`.
|
|
216
|
+
hidden: true,
|
|
217
|
+
}),
|
|
207
218
|
defineField({
|
|
208
219
|
name: "metadata",
|
|
209
220
|
title: "SEO & Metadata",
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
contentFolderIcon,
|
|
15
15
|
contentIcon,
|
|
16
16
|
descriptionIcon,
|
|
17
|
+
gatedContentIcon,
|
|
17
18
|
heroIcon,
|
|
18
19
|
layoutFolderIcon,
|
|
19
20
|
pageFolderIcon,
|
|
@@ -294,6 +295,9 @@ export const structure: StructureResolver = (S, context) =>
|
|
|
294
295
|
S.documentTypeListItem("description")
|
|
295
296
|
.title("Descriptions")
|
|
296
297
|
.icon(descriptionIcon),
|
|
298
|
+
S.documentTypeListItem("gatedContent")
|
|
299
|
+
.title("Gated Content")
|
|
300
|
+
.icon(gatedContentIcon),
|
|
297
301
|
S.documentTypeListItem("blogContent")
|
|
298
302
|
.title("Blog Content")
|
|
299
303
|
.icon(contentIcon),
|
|
@@ -29,6 +29,26 @@ export const renderPageBuilderMarkdown = (
|
|
|
29
29
|
return block.headline ? [`## ${block.headline}`, ""] : [];
|
|
30
30
|
case "description":
|
|
31
31
|
return block.description ? [block.description, ""] : [];
|
|
32
|
+
case "gatedContent": {
|
|
33
|
+
// The gated payload is withheld from the page query, so it is not
|
|
34
|
+
// available here either — only the teaser is emitted, matching what
|
|
35
|
+
// the page ships before the gate is unlocked.
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
if (block.teaserHeading) lines.push(`## ${block.teaserHeading}`, "");
|
|
38
|
+
if (block.teaserBody) {
|
|
39
|
+
lines.push(
|
|
40
|
+
portableTextToMarkdown(
|
|
41
|
+
block.teaserBody as Parameters<
|
|
42
|
+
typeof portableTextToMarkdown
|
|
43
|
+
>[0],
|
|
44
|
+
markdownOptions,
|
|
45
|
+
),
|
|
46
|
+
"",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
lines.push("> Gated content — unlock on the page to view.", "");
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
32
52
|
case "blogContent": {
|
|
33
53
|
const lines: (string | null)[] = [];
|
|
34
54
|
const categories =
|
|
@@ -10,7 +10,7 @@ import { resolve } from "node:path";
|
|
|
10
10
|
const sanityDir = resolve(import.meta.dir, "../integrations/sanity");
|
|
11
11
|
|
|
12
12
|
if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
|
|
13
|
-
console.
|
|
13
|
+
console.info(
|
|
14
14
|
" Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set",
|
|
15
15
|
);
|
|
16
16
|
process.exit(0);
|
|
@@ -27,6 +27,7 @@ const serializeJsonLd = <T extends Thing>(data: WithContext<T>) =>
|
|
|
27
27
|
export const JsonLd = <T extends Thing>({ data }: JsonLdProps<T>) => (
|
|
28
28
|
<script
|
|
29
29
|
type="application/ld+json"
|
|
30
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized JSON-LD, not user HTML; `<` is escaped
|
|
30
31
|
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
|
31
32
|
/>
|
|
32
33
|
);
|