bsmnt 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create/internal-ids.d.ts +1 -0
- package/dist/core/create/internal-ids.d.ts.map +1 -1
- package/dist/core/create/internal-ids.js +1 -0
- package/dist/core/create/internal-ids.js.map +1 -1
- package/dist/domain/skills.d.ts +5 -0
- package/dist/domain/skills.d.ts.map +1 -1
- package/dist/domain/skills.js +9 -1
- package/dist/domain/skills.js.map +1 -1
- package/dist/infrastructure/create/format-project.d.ts +9 -0
- package/dist/infrastructure/create/format-project.d.ts.map +1 -0
- package/dist/infrastructure/create/format-project.js +21 -0
- package/dist/infrastructure/create/format-project.js.map +1 -0
- package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
- package/dist/infrastructure/create/lifecycle-registry.js +5 -0
- package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-skills/launch-ready/SKILL.md +114 -0
- package/src/agent-skills/launch-ready/references/a11y.md +74 -0
- package/src/agent-skills/launch-ready/references/content.md +65 -0
- package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
- package/src/agent-skills/launch-ready/references/launch.md +44 -0
- package/src/agent-skills/launch-ready/references/metadata.md +83 -0
- package/src/agent-skills/launch-ready/references/performance.md +36 -0
- package/src/agent-skills/launch-ready/references/routing.md +47 -0
- package/src/agent-skills/launch-ready/references/security.md +39 -0
- package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
- package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
- package/src/templates/next-default/.env.example +3 -1
- package/src/templates/next-default/app/layout.tsx +2 -1
- package/src/templates/next-default/app/robots.ts +9 -3
- package/src/templates/next-default/lib/utils/metadata.ts +2 -1
- package/src/templates/next-default/lib/utils/url.ts +20 -3
- package/src/templates/next-default/next.config.ts +16 -0
- package/src/templates/next-experiments/.env.example +3 -1
- package/src/templates/next-experiments/app/layout.tsx +2 -1
- package/src/templates/next-experiments/app/robots.ts +9 -3
- package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
- package/src/templates/next-experiments/lib/utils/url.ts +20 -3
- package/src/templates/next-experiments/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/.env.example +3 -1
- package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
- package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
- package/src/templates/next-pagebuilder/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder/biome.json +2 -1
- package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/package.json +1 -1
- package/src/templates/next-pagebuilder-cache/.env.example +3 -1
- package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder-cache/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
- package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
- package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder-cache/biome.json +2 -1
- package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
- package/src/templates/next-pagebuilder-cache/package.json +1 -1
- package/src/templates/next-webgl/.env.example +3 -1
- package/src/templates/next-webgl/app/layout.tsx +2 -1
- package/src/templates/next-webgl/app/robots.ts +9 -3
- package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
- package/src/templates/next-webgl/lib/utils/url.ts +20 -3
- package/src/templates/next-webgl/next.config.ts +16 -0
|
@@ -2,6 +2,9 @@ import { defineField, defineType } from "sanity";
|
|
|
2
2
|
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
3
3
|
import { redirectIcon } from "@/lib/integrations/sanity/icons";
|
|
4
4
|
|
|
5
|
+
export const REDIRECT_SOURCE_MAX_LENGTH = 200;
|
|
6
|
+
export const REDIRECT_PATH_PATTERN = /^[a-z0-9-/]+$/;
|
|
7
|
+
|
|
5
8
|
type RedirectValidationDocument = {
|
|
6
9
|
_id?: string;
|
|
7
10
|
source?: { current?: string };
|
|
@@ -23,14 +26,14 @@ export const redirect = defineType({
|
|
|
23
26
|
type: "slug",
|
|
24
27
|
description:
|
|
25
28
|
"Incoming route path to match, without a leading slash (e.g. old-page or blog/old-post).",
|
|
26
|
-
options: { maxLength:
|
|
29
|
+
options: { maxLength: REDIRECT_SOURCE_MAX_LENGTH },
|
|
27
30
|
validation: (Rule) =>
|
|
28
31
|
Rule.required().custom(async (slug, context) => {
|
|
29
32
|
if (!slug?.current) {
|
|
30
33
|
return "Source is required";
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
if (
|
|
36
|
+
if (!REDIRECT_PATH_PATTERN.test(slug.current)) {
|
|
34
37
|
return "Source must be lowercase with hyphens, letters, numbers, and forward slashes only";
|
|
35
38
|
}
|
|
36
39
|
|
|
@@ -63,15 +66,15 @@ export const redirect = defineType({
|
|
|
63
66
|
title: "Destination",
|
|
64
67
|
type: "string",
|
|
65
68
|
description:
|
|
66
|
-
"Where to send visitors. An internal route path without a leading slash (e.g. new-page or blog/new-post), or an absolute URL (e.g. https://example.com).",
|
|
69
|
+
"Where to send visitors. An internal route path without a leading slash (e.g. new-page or blog/new-post), / for the homepage, or an absolute URL (e.g. https://example.com).",
|
|
67
70
|
validation: (Rule) =>
|
|
68
71
|
Rule.required().custom(async (value, context) => {
|
|
69
72
|
if (typeof value !== "string") return true;
|
|
70
73
|
|
|
71
74
|
const isAbsolute = /^https?:\/\//.test(value);
|
|
72
|
-
if (isAbsolute) return true;
|
|
75
|
+
if (isAbsolute || value === "/") return true;
|
|
73
76
|
|
|
74
|
-
if (
|
|
77
|
+
if (!REDIRECT_PATH_PATTERN.test(value)) {
|
|
75
78
|
return "Destination must be a slash-free route path (lowercase, hyphens, numbers, forward slashes) or an absolute http(s) URL";
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -141,7 +144,7 @@ export const redirect = defineType({
|
|
|
141
144
|
if (destination) {
|
|
142
145
|
target = /^https?:\/\//.test(destination)
|
|
143
146
|
? destination
|
|
144
|
-
: `/${destination}`;
|
|
147
|
+
: `/${destination.replace(/^\/+/, "")}`;
|
|
145
148
|
}
|
|
146
149
|
return {
|
|
147
150
|
title: source ? `/${source}` : "Untitled redirect",
|
|
@@ -36,6 +36,14 @@ export const structuredData = defineType({
|
|
|
36
36
|
"Square or wide logo used in rich results. Min 112x112px, transparent PNG/SVG recommended.",
|
|
37
37
|
options: { hotspot: true },
|
|
38
38
|
}),
|
|
39
|
+
defineField({
|
|
40
|
+
name: "shareImage",
|
|
41
|
+
title: "Default Share Image",
|
|
42
|
+
type: "image",
|
|
43
|
+
description:
|
|
44
|
+
"Social preview (Open Graph) for any page without its own OG image. 1200x630px.",
|
|
45
|
+
options: { hotspot: true },
|
|
46
|
+
}),
|
|
39
47
|
defineField({
|
|
40
48
|
name: "description",
|
|
41
49
|
title: "Description",
|
|
@@ -1,6 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineArrayMember,
|
|
3
|
+
defineField,
|
|
4
|
+
defineType,
|
|
5
|
+
type ValidationContext,
|
|
6
|
+
} from "sanity";
|
|
7
|
+
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
2
8
|
import { pageJsonLdMembers } from "./structured-data-blocks";
|
|
3
9
|
|
|
10
|
+
const duplicateCountQuery = (
|
|
11
|
+
field: "metaTitle" | "metaDescription",
|
|
12
|
+
) => `count(*[
|
|
13
|
+
defined(metadata.${field}) &&
|
|
14
|
+
metadata.${field} == $value &&
|
|
15
|
+
!(_id in [$id, "drafts." + $id])
|
|
16
|
+
])`;
|
|
17
|
+
|
|
18
|
+
// Drafts perspective, so a duplicate that's only saved as a draft still warns.
|
|
19
|
+
const warnIfDuplicated =
|
|
20
|
+
(field: "metaTitle" | "metaDescription", label: string) =>
|
|
21
|
+
async (value: string | undefined, context: ValidationContext) => {
|
|
22
|
+
const id = context.document?._id?.replace(
|
|
23
|
+
/^(drafts|versions\.[^.]+)\./,
|
|
24
|
+
"",
|
|
25
|
+
);
|
|
26
|
+
if (!value?.trim() || !id) return true;
|
|
27
|
+
const duplicates = await context
|
|
28
|
+
.getClient({ apiVersion })
|
|
29
|
+
.withConfig({ perspective: "drafts" })
|
|
30
|
+
.fetch<number>(duplicateCountQuery(field), { value, id });
|
|
31
|
+
return duplicates > 0
|
|
32
|
+
? `Another document already uses this ${label}`
|
|
33
|
+
: true;
|
|
34
|
+
};
|
|
35
|
+
|
|
4
36
|
export const metadata = defineType({
|
|
5
37
|
name: "metadata",
|
|
6
38
|
title: "SEO & Metadata",
|
|
@@ -21,10 +53,12 @@ export const metadata = defineType({
|
|
|
21
53
|
type: "string",
|
|
22
54
|
group: "search",
|
|
23
55
|
description: "Title used by search engines and browser tabs",
|
|
24
|
-
validation: (Rule) =>
|
|
56
|
+
validation: (Rule) => [
|
|
25
57
|
Rule.max(60).warning(
|
|
26
58
|
"Title should be under 60 characters for optimal SEO",
|
|
27
59
|
),
|
|
60
|
+
Rule.custom(warnIfDuplicated("metaTitle", "meta title")).warning(),
|
|
61
|
+
],
|
|
28
62
|
}),
|
|
29
63
|
defineField({
|
|
30
64
|
name: "metaDescription",
|
|
@@ -33,10 +67,14 @@ export const metadata = defineType({
|
|
|
33
67
|
rows: 3,
|
|
34
68
|
group: "search",
|
|
35
69
|
description: "Description used by search engines",
|
|
36
|
-
validation: (Rule) =>
|
|
70
|
+
validation: (Rule) => [
|
|
37
71
|
Rule.max(160).warning(
|
|
38
72
|
"Description should be under 160 characters for optimal SEO",
|
|
39
73
|
),
|
|
74
|
+
Rule.custom(
|
|
75
|
+
warnIfDuplicated("metaDescription", "meta description"),
|
|
76
|
+
).warning(),
|
|
77
|
+
],
|
|
40
78
|
}),
|
|
41
79
|
defineField({
|
|
42
80
|
name: "keywords",
|
|
@@ -66,7 +104,9 @@ export const metadata = defineType({
|
|
|
66
104
|
description:
|
|
67
105
|
"Title shown when shared on social (Open Graph & Twitter). Falls back to Meta Title.",
|
|
68
106
|
validation: (Rule) =>
|
|
69
|
-
Rule.max(70).warning(
|
|
107
|
+
Rule.max(70).warning(
|
|
108
|
+
"Social titles are often truncated past ~70 chars",
|
|
109
|
+
),
|
|
70
110
|
}),
|
|
71
111
|
defineField({
|
|
72
112
|
name: "ogDescription",
|
|
@@ -87,9 +127,14 @@ export const metadata = defineType({
|
|
|
87
127
|
type: "url",
|
|
88
128
|
group: "advanced",
|
|
89
129
|
description:
|
|
90
|
-
"Override the canonical URL for this page. Leave blank to use the page's own URL. Use for duplicate/syndicated content.",
|
|
130
|
+
"Override the canonical URL for this page: a full URL, or a path starting with / (e.g. /blog/original-post). Leave blank to use the page's own URL. Use for duplicate/syndicated content.",
|
|
91
131
|
validation: (Rule) =>
|
|
92
|
-
Rule.uri({ scheme: ["http", "https"], allowRelative: true })
|
|
132
|
+
Rule.uri({ scheme: ["http", "https"], allowRelative: true }).custom(
|
|
133
|
+
(value) =>
|
|
134
|
+
!value || /^(https?:\/\/|\/)/i.test(value)
|
|
135
|
+
? true
|
|
136
|
+
: "Use a full URL (https://…) or a path starting with /",
|
|
137
|
+
),
|
|
93
138
|
}),
|
|
94
139
|
defineField({
|
|
95
140
|
name: "index",
|
|
@@ -99,6 +144,15 @@ export const metadata = defineType({
|
|
|
99
144
|
description: "Allow search engines to index this page",
|
|
100
145
|
initialValue: true,
|
|
101
146
|
}),
|
|
147
|
+
defineField({
|
|
148
|
+
name: "follow",
|
|
149
|
+
title: "Follow links",
|
|
150
|
+
type: "boolean",
|
|
151
|
+
group: "advanced",
|
|
152
|
+
description:
|
|
153
|
+
"Allow search engines to follow the links on this page. Independent of Index.",
|
|
154
|
+
initialValue: true,
|
|
155
|
+
}),
|
|
102
156
|
defineField({
|
|
103
157
|
name: "jsonLd",
|
|
104
158
|
title: "Structured Data (JSON-LD)",
|
|
@@ -4,6 +4,7 @@ import { SlugInput } from "@/lib/integrations/sanity/components/slug-input";
|
|
|
4
4
|
import { apiVersion } from "@/lib/integrations/sanity/env";
|
|
5
5
|
import { pageIcon } from "@/lib/integrations/sanity/icons";
|
|
6
6
|
import {
|
|
7
|
+
countPageHeadingBlocks,
|
|
7
8
|
getInvalidPageBuilderDocumentTypes,
|
|
8
9
|
getPageBuilderBlockMember,
|
|
9
10
|
getPageCategoryIcon,
|
|
@@ -25,6 +26,7 @@ type PageValidationDocument = {
|
|
|
25
26
|
_key?: string;
|
|
26
27
|
_type?: string;
|
|
27
28
|
}>;
|
|
29
|
+
hiddenPageBuilderKeys?: string[];
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
type PageSlugValidationContext = {
|
|
@@ -101,6 +103,13 @@ export const page = defineType({
|
|
|
101
103
|
type: "string",
|
|
102
104
|
validation: (Rule) => Rule.required(),
|
|
103
105
|
}),
|
|
106
|
+
defineField({
|
|
107
|
+
name: "shortTitle",
|
|
108
|
+
title: "Short Title",
|
|
109
|
+
type: "string",
|
|
110
|
+
description:
|
|
111
|
+
"Label for this page in breadcrumbs (e.g. Blog). Falls back to the folder name for a folder's index page, then to Title.",
|
|
112
|
+
}),
|
|
104
113
|
defineField({
|
|
105
114
|
name: "type",
|
|
106
115
|
title: "Page Category",
|
|
@@ -149,7 +158,18 @@ export const page = defineType({
|
|
|
149
158
|
title: "Page Builder",
|
|
150
159
|
type: "pageBuilder",
|
|
151
160
|
description: "Composable content blocks for this page",
|
|
152
|
-
validation: (Rule) =>
|
|
161
|
+
validation: (Rule) => [
|
|
162
|
+
// Without a Heading block the page title is the H1, so only more
|
|
163
|
+
// than one is a problem.
|
|
164
|
+
Rule.custom((value, context) => {
|
|
165
|
+
const blocks = Array.isArray(value) ? value : [];
|
|
166
|
+
const hiddenKeys =
|
|
167
|
+
(context.document as PageValidationDocument | undefined)
|
|
168
|
+
?.hiddenPageBuilderKeys ?? [];
|
|
169
|
+
return countPageHeadingBlocks(blocks, hiddenKeys) > 1
|
|
170
|
+
? "More than one visible Heading block, so this page has several H1s"
|
|
171
|
+
: true;
|
|
172
|
+
}).warning(),
|
|
153
173
|
Rule.custom(async (value, context) => {
|
|
154
174
|
const document = context.document as
|
|
155
175
|
| PageValidationDocument
|
|
@@ -181,6 +201,7 @@ export const page = defineType({
|
|
|
181
201
|
|
|
182
202
|
return `These page builder components do not belong to the "${pageType ?? "generic"}" category: ${invalidMemberTitles.join(", ")}`;
|
|
183
203
|
}),
|
|
204
|
+
],
|
|
184
205
|
}),
|
|
185
206
|
defineField({
|
|
186
207
|
name: "collectionCategory",
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UploadIcon } from "@sanity/icons";
|
|
1
2
|
import { from } from "rxjs";
|
|
2
3
|
import { map } from "rxjs/operators";
|
|
3
4
|
import type {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "@/components/page-builder/library";
|
|
13
14
|
import { pageBuilderInsertMenuGroups } from "@/lib/integrations/sanity/page-builder-config";
|
|
14
15
|
import { ComponentPreviewPane } from "./components/component-preview-pane";
|
|
16
|
+
import { openRedirectImport } from "./components/redirect-import-dialog";
|
|
15
17
|
import { apiVersion } from "./env";
|
|
16
18
|
import {
|
|
17
19
|
authorIcon,
|
|
@@ -241,6 +243,18 @@ function buildPageTreeComponent(
|
|
|
241
243
|
.menuItems([...folderMenuItems, ...createMenuItems]);
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
const redirectList = (S: StructureBuilder) => {
|
|
247
|
+
const list = S.documentTypeList("redirect").title("Redirects");
|
|
248
|
+
return list.menuItems([
|
|
249
|
+
...(list.getMenuItems() ?? []),
|
|
250
|
+
S.menuItem()
|
|
251
|
+
.title("Import CSV")
|
|
252
|
+
.icon(UploadIcon)
|
|
253
|
+
.action(openRedirectImport)
|
|
254
|
+
.showAsAction(true),
|
|
255
|
+
]);
|
|
256
|
+
};
|
|
257
|
+
|
|
244
258
|
// https://www.sanity.io/docs/structure-builder-cheat-sheet
|
|
245
259
|
export const structure: StructureResolver = (S, context) =>
|
|
246
260
|
S.list()
|
|
@@ -335,7 +349,8 @@ export const structure: StructureResolver = (S, context) =>
|
|
|
335
349
|
S.divider(),
|
|
336
350
|
S.documentTypeListItem("redirect")
|
|
337
351
|
.title("Redirects")
|
|
338
|
-
.icon(redirectIcon)
|
|
352
|
+
.icon(redirectIcon)
|
|
353
|
+
.child(redirectList(S)),
|
|
339
354
|
]),
|
|
340
355
|
),
|
|
341
356
|
]);
|
|
@@ -10,6 +10,7 @@ const SANITY_STRUCTURED_DATA_TAG = "structuredData";
|
|
|
10
10
|
// Crawler routes: broader than any per-slug tag.
|
|
11
11
|
export const SITEMAP_TAG = "sitemap";
|
|
12
12
|
export const FEED_TAG = "feed";
|
|
13
|
+
export const BREADCRUMBS_TAG = "breadcrumbs";
|
|
13
14
|
|
|
14
15
|
export const BLOG_COLLECTION_TAGS = [
|
|
15
16
|
"blog-categories",
|
|
@@ -31,8 +32,9 @@ export const BLOG_COLLECTION_TYPES = new Set<string>([
|
|
|
31
32
|
export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
|
|
32
33
|
// Redundant while LAYOUT_QUERY is merged; kept so splitting it can't lose this.
|
|
33
34
|
companyData: [SANITY_STRUCTURED_DATA_TAG],
|
|
34
|
-
// Nav links deref pages; sitemap and
|
|
35
|
-
page
|
|
35
|
+
// Nav links deref pages; sitemap, feed and breadcrumbs move whenever any
|
|
36
|
+
// page does.
|
|
37
|
+
page: [SANITY_NAVBAR_TAG, SITEMAP_TAG, FEED_TAG, BREADCRUMBS_TAG],
|
|
36
38
|
// ...plus the collections, which decide membership on the folder slug, so a
|
|
37
39
|
// rename changes which pages are articles at all.
|
|
38
40
|
pageFolder: [
|
|
@@ -40,6 +42,7 @@ export const CROSS_DOC_TAG_DEPENDENCIES: Record<string, readonly string[]> = {
|
|
|
40
42
|
...BLOG_COLLECTION_TAGS,
|
|
41
43
|
SITEMAP_TAG,
|
|
42
44
|
FEED_TAG,
|
|
45
|
+
BREADCRUMBS_TAG,
|
|
43
46
|
],
|
|
44
47
|
// The feed derefs the article's block, then its author and categories.
|
|
45
48
|
blogContent: [FEED_TAG],
|
|
@@ -10,27 +10,24 @@ 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
|
+
// Annotate CI runs: the generated-files check verifies nothing without a project.
|
|
13
14
|
console.info(
|
|
14
|
-
|
|
15
|
+
process.env.CI
|
|
16
|
+
? "::warning::Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set, so committed Sanity types aren't verified"
|
|
17
|
+
: " Skipping sanity:typegen — NEXT_PUBLIC_SANITY_PROJECT_ID not set",
|
|
15
18
|
);
|
|
16
19
|
process.exit(0);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
//
|
|
20
|
-
// match lint — but only locally. In CI/Vercel the build discards the formatted
|
|
21
|
-
// result, so running biome there is pure per-deploy waste. (Scoped to the one
|
|
22
|
-
// file; linting the whole dir would rewrite unrelated source.)
|
|
23
|
-
const format =
|
|
24
|
-
process.env.CI || process.env.VERCEL
|
|
25
|
-
? ""
|
|
26
|
-
: " && bun biome check --write --unsafe sanity.types.ts";
|
|
22
|
+
// No formatting pass: `sanity.types.ts` is in biome's ignore list, so `biome check` on it exits 1.
|
|
27
23
|
|
|
28
24
|
try {
|
|
29
25
|
// No `--env-file` — dotenvx already injected the env into process.env.
|
|
30
26
|
execSync(
|
|
31
|
-
`cd ${sanityDir} && bun sanity schema extract && bun sanity typegen generate
|
|
27
|
+
`cd ${sanityDir} && bun sanity schema extract && bun sanity typegen generate`,
|
|
32
28
|
{ stdio: "inherit" },
|
|
33
29
|
);
|
|
34
30
|
} catch {
|
|
35
|
-
console.
|
|
31
|
+
console.error(" sanity:typegen failed");
|
|
32
|
+
process.exit(1);
|
|
36
33
|
}
|
|
@@ -1,36 +1,13 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
+
import { stegaClean } from "next-sanity";
|
|
2
3
|
import { urlForImage } from "@/lib/integrations/sanity/utils/image";
|
|
3
4
|
import { getBaseUrl } from "./base-url";
|
|
4
5
|
|
|
5
|
-
interface GenerateMetadataOptions {
|
|
6
|
-
title?: string | undefined;
|
|
7
|
-
description?: string | undefined;
|
|
8
|
-
keywords?: string[] | undefined;
|
|
9
|
-
ogTitle?: string | undefined;
|
|
10
|
-
ogDescription?: string | undefined;
|
|
11
|
-
canonicalUrl?: string | undefined;
|
|
12
|
-
image?:
|
|
13
|
-
| {
|
|
14
|
-
url?: string | undefined;
|
|
15
|
-
width?: number | undefined;
|
|
16
|
-
height?: number | undefined;
|
|
17
|
-
}
|
|
18
|
-
| undefined;
|
|
19
|
-
url?: string | undefined;
|
|
20
|
-
siteName?: string | undefined;
|
|
21
|
-
index?: boolean | undefined;
|
|
22
|
-
noIndex?: boolean | undefined;
|
|
23
|
-
type?: "website" | "article";
|
|
24
|
-
publishedTime?: string | undefined;
|
|
25
|
-
modifiedTime?: string | undefined;
|
|
26
|
-
authors?: string[] | undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
6
|
interface SanityMetadataImage {
|
|
30
7
|
asset?: {
|
|
31
8
|
_ref?: string;
|
|
32
9
|
_type?: "reference";
|
|
33
|
-
};
|
|
10
|
+
} | null;
|
|
34
11
|
}
|
|
35
12
|
|
|
36
13
|
interface SanityPageMetadata {
|
|
@@ -44,6 +21,7 @@ interface SanityPageMetadata {
|
|
|
44
21
|
image?: SanityMetadataImage | null;
|
|
45
22
|
} | null;
|
|
46
23
|
index?: boolean | null;
|
|
24
|
+
follow?: boolean | null;
|
|
47
25
|
title?: string | null;
|
|
48
26
|
description?: string | null;
|
|
49
27
|
image?: SanityMetadataImage | null;
|
|
@@ -52,138 +30,133 @@ interface SanityPageMetadata {
|
|
|
52
30
|
|
|
53
31
|
interface SanityMetadataDocument {
|
|
54
32
|
title?: string | null;
|
|
55
|
-
subtitle?: string | null;
|
|
56
33
|
metadata?: SanityPageMetadata | null;
|
|
57
34
|
}
|
|
58
35
|
|
|
36
|
+
// The Structured Data singleton: its name is the og:site_name and its share
|
|
37
|
+
// image covers any page without an OG image of its own.
|
|
38
|
+
interface SiteMetadata {
|
|
39
|
+
name?: string | null;
|
|
40
|
+
shareImage?: SanityMetadataImage | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ArticleMetadata {
|
|
44
|
+
publishedTime?: string | null | undefined;
|
|
45
|
+
modifiedTime?: string | null | undefined;
|
|
46
|
+
authors?: readonly (string | null | undefined)[] | null | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
59
49
|
interface GenerateSanityMetadataOptions {
|
|
60
50
|
document: SanityMetadataDocument;
|
|
61
51
|
url?: string;
|
|
62
|
-
|
|
63
|
-
|
|
52
|
+
site?: SiteMetadata | null | undefined;
|
|
53
|
+
fallbackDescription?: string | null | undefined;
|
|
54
|
+
fallbackImage?: SanityMetadataImage | null | undefined;
|
|
55
|
+
article?: ArticleMetadata | undefined;
|
|
64
56
|
}
|
|
65
57
|
|
|
58
|
+
const OG_WIDTH = 1200;
|
|
59
|
+
const OG_HEIGHT = 630;
|
|
60
|
+
const DESCRIPTION_LENGTH = 160;
|
|
61
|
+
|
|
66
62
|
const APP_BASE_URL = getBaseUrl();
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
url
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const
|
|
64
|
+
// A relative override is rooted, so "blog/post" can't resolve against the
|
|
65
|
+
// current path.
|
|
66
|
+
const resolveCanonical = (
|
|
67
|
+
override: string | null | undefined,
|
|
68
|
+
ownPath: string,
|
|
69
|
+
) => {
|
|
70
|
+
const value = override?.trim();
|
|
71
|
+
if (!value) return ownPath;
|
|
72
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
73
|
+
return `/${value.replace(/^\/+/, "")}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Trims body copy to a meta description, cut at a word boundary.
|
|
77
|
+
const summarize = (
|
|
78
|
+
text: string | null | undefined,
|
|
79
|
+
maxLength = DESCRIPTION_LENGTH,
|
|
80
|
+
) => {
|
|
81
|
+
const clean = text?.replace(/\s+/g, " ").trim();
|
|
82
|
+
if (!clean) return undefined;
|
|
83
|
+
if (clean.length <= maxLength) return clean;
|
|
84
|
+
const cut = clean.slice(0, maxLength - 1);
|
|
85
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
86
|
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[\s,;:.–—-]+$/, "")}…`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const socialImage = (source: SanityMetadataImage | null | undefined) =>
|
|
90
|
+
source?.asset
|
|
91
|
+
? urlForImage(source).width(OG_WIDTH).height(OG_HEIGHT).url()
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
export const generateSanityMetadata = ({
|
|
95
|
+
document,
|
|
96
|
+
url = "/",
|
|
97
|
+
site,
|
|
98
|
+
fallbackDescription,
|
|
99
|
+
fallbackImage,
|
|
100
|
+
article,
|
|
101
|
+
}: GenerateSanityMetadataOptions): Metadata => {
|
|
102
|
+
const { name, shareImage } = stegaClean(site) ?? {};
|
|
103
|
+
const siteName = name?.trim() || undefined;
|
|
104
|
+
|
|
105
|
+
const seo = document.metadata;
|
|
106
|
+
const title = seo?.metaTitle || seo?.title || document.title || siteName;
|
|
107
|
+
const description =
|
|
108
|
+
seo?.metaDescription || seo?.description || summarize(fallbackDescription);
|
|
109
|
+
const socialTitle = seo?.ogTitle || title;
|
|
110
|
+
const socialDescription = seo?.ogDescription || description;
|
|
111
|
+
const imageUrl =
|
|
112
|
+
socialImage(seo?.image) ||
|
|
113
|
+
socialImage(seo?.og?.image) ||
|
|
114
|
+
socialImage(fallbackImage) ||
|
|
115
|
+
socialImage(shareImage);
|
|
116
|
+
const images = imageUrl
|
|
117
|
+
? [{ url: imageUrl, width: OG_WIDTH, height: OG_HEIGHT }]
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
const canonical = resolveCanonical(seo?.canonicalUrl, url);
|
|
121
|
+
|
|
122
|
+
const index = seo?.index ?? (seo?.noIndex != null ? !seo.noIndex : true);
|
|
123
|
+
// Unset on pages that predate the toggle, which were `nofollow` whenever
|
|
124
|
+
// they were `noindex`.
|
|
125
|
+
const follow = seo?.follow ?? index;
|
|
126
|
+
const authors = article?.authors?.filter((author): author is string =>
|
|
127
|
+
Boolean(author),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return {
|
|
116
131
|
metadataBase: new URL(APP_BASE_URL),
|
|
117
132
|
title,
|
|
118
133
|
description,
|
|
119
|
-
keywords,
|
|
120
|
-
alternates: { canonical
|
|
134
|
+
keywords: seo?.keywords?.length ? seo.keywords : undefined,
|
|
135
|
+
alternates: { canonical },
|
|
121
136
|
openGraph: {
|
|
137
|
+
title: socialTitle,
|
|
122
138
|
description: socialDescription,
|
|
123
|
-
url
|
|
139
|
+
// Follows the canonical, so an override doesn't leave og:url pointing
|
|
140
|
+
// at the duplicate.
|
|
141
|
+
url: new URL(canonical, APP_BASE_URL).toString(),
|
|
124
142
|
siteName,
|
|
125
143
|
locale: "en_US",
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
images,
|
|
145
|
+
...(article
|
|
146
|
+
? {
|
|
147
|
+
type: "article",
|
|
148
|
+
publishedTime: article.publishedTime || undefined,
|
|
149
|
+
modifiedTime: article.modifiedTime || undefined,
|
|
150
|
+
authors: authors?.length ? authors : undefined,
|
|
151
|
+
}
|
|
152
|
+
: { type: "website" }),
|
|
132
153
|
},
|
|
133
154
|
twitter: {
|
|
134
155
|
card: "summary_large_image",
|
|
156
|
+
title: socialTitle,
|
|
135
157
|
description: socialDescription,
|
|
136
|
-
images
|
|
137
|
-
...(socialTitle && { title: socialTitle }),
|
|
158
|
+
images,
|
|
138
159
|
},
|
|
139
|
-
|
|
160
|
+
...(index && follow ? {} : { robots: { index, follow } }),
|
|
140
161
|
};
|
|
141
|
-
|
|
142
|
-
if (!shouldIndex) metadata.robots = { index: false, follow: false };
|
|
143
|
-
|
|
144
|
-
return metadata;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export const generateSanityMetadata = ({
|
|
148
|
-
document,
|
|
149
|
-
url,
|
|
150
|
-
siteName,
|
|
151
|
-
type = "website",
|
|
152
|
-
}: GenerateSanityMetadataOptions): Metadata => {
|
|
153
|
-
const pageMetadata = document.metadata;
|
|
154
|
-
const title =
|
|
155
|
-
pageMetadata?.metaTitle ||
|
|
156
|
-
pageMetadata?.title ||
|
|
157
|
-
document.title ||
|
|
158
|
-
undefined;
|
|
159
|
-
const description =
|
|
160
|
-
pageMetadata?.metaDescription ||
|
|
161
|
-
pageMetadata?.description ||
|
|
162
|
-
document.subtitle ||
|
|
163
|
-
undefined;
|
|
164
|
-
const socialImageSource = pageMetadata?.image || pageMetadata?.og?.image;
|
|
165
|
-
const imageUrl = socialImageSource
|
|
166
|
-
? urlForImage(socialImageSource).width(1200).height(630).url()
|
|
167
|
-
: undefined;
|
|
168
|
-
const index =
|
|
169
|
-
pageMetadata?.index ??
|
|
170
|
-
(pageMetadata?.noIndex != null ? !pageMetadata.noIndex : true);
|
|
171
|
-
|
|
172
|
-
const keywords = pageMetadata?.keywords?.length
|
|
173
|
-
? pageMetadata.keywords
|
|
174
|
-
: undefined;
|
|
175
|
-
|
|
176
|
-
return generatePageMetadata({
|
|
177
|
-
title,
|
|
178
|
-
description,
|
|
179
|
-
keywords,
|
|
180
|
-
ogTitle: pageMetadata?.ogTitle || undefined,
|
|
181
|
-
ogDescription: pageMetadata?.ogDescription || undefined,
|
|
182
|
-
canonicalUrl: pageMetadata?.canonicalUrl || undefined,
|
|
183
|
-
image: imageUrl ? { url: imageUrl, width: 1200, height: 630 } : undefined,
|
|
184
|
-
url,
|
|
185
|
-
siteName,
|
|
186
|
-
index,
|
|
187
|
-
type,
|
|
188
|
-
});
|
|
189
162
|
};
|
package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx
CHANGED
|
@@ -80,7 +80,9 @@ export const RichText = ({ content }: RichTextProps) => {
|
|
|
80
80
|
externalLink: LinkMark,
|
|
81
81
|
},
|
|
82
82
|
block: {
|
|
83
|
-
h1:
|
|
83
|
+
// The h1 style is retired: the page owns its H1, so legacy content
|
|
84
|
+
// drops a level.
|
|
85
|
+
h1: ({ children }) => <h2 className="h2">{children}</h2>,
|
|
84
86
|
h2: ({ children }) => <h2 className="h2">{children}</h2>,
|
|
85
87
|
h3: ({ children }) => <h3 className="h3">{children}</h3>,
|
|
86
88
|
h4: ({ children }) => <h4 className="h4">{children}</h4>,
|