bsmnt 0.7.1 → 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.
Files changed (86) hide show
  1. package/dist/application/add-hooks/index.d.ts.map +1 -1
  2. package/dist/application/add-hooks/index.js +1 -0
  3. package/dist/application/add-hooks/index.js.map +1 -1
  4. package/dist/application/add-integration/index.d.ts.map +1 -1
  5. package/dist/application/add-integration/index.js +1 -0
  6. package/dist/application/add-integration/index.js.map +1 -1
  7. package/dist/core/create/execute-plan.d.ts +2 -0
  8. package/dist/core/create/execute-plan.d.ts.map +1 -1
  9. package/dist/core/create/execute-plan.js +3 -0
  10. package/dist/core/create/execute-plan.js.map +1 -1
  11. package/dist/domain/version.d.ts +14 -0
  12. package/dist/domain/version.d.ts.map +1 -0
  13. package/dist/domain/version.js +81 -0
  14. package/dist/domain/version.js.map +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  18. package/dist/infrastructure/create/executor.js +2 -0
  19. package/dist/infrastructure/create/executor.js.map +1 -1
  20. package/dist/infrastructure/create/render-generated-docs.d.ts +18 -0
  21. package/dist/infrastructure/create/render-generated-docs.d.ts.map +1 -0
  22. package/dist/infrastructure/create/render-generated-docs.js +72 -0
  23. package/dist/infrastructure/create/render-generated-docs.js.map +1 -0
  24. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -1
  25. package/dist/infrastructure/create/setup-dotenvx.js +2 -1
  26. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -1
  27. package/dist/infrastructure/create/setup-remote.d.ts +6 -1
  28. package/dist/infrastructure/create/setup-remote.d.ts.map +1 -1
  29. package/dist/infrastructure/create/setup-remote.js +7 -2
  30. package/dist/infrastructure/create/setup-remote.js.map +1 -1
  31. package/dist/infrastructure/update/cache.d.ts +14 -0
  32. package/dist/infrastructure/update/cache.d.ts.map +1 -0
  33. package/dist/infrastructure/update/cache.js +41 -0
  34. package/dist/infrastructure/update/cache.js.map +1 -0
  35. package/dist/infrastructure/update/check-version.d.ts +2 -0
  36. package/dist/infrastructure/update/check-version.d.ts.map +1 -0
  37. package/dist/infrastructure/update/check-version.js +33 -0
  38. package/dist/infrastructure/update/check-version.js.map +1 -0
  39. package/dist/infrastructure/update/notifier.d.ts +8 -0
  40. package/dist/infrastructure/update/notifier.d.ts.map +1 -0
  41. package/dist/infrastructure/update/notifier.js +141 -0
  42. package/dist/infrastructure/update/notifier.js.map +1 -0
  43. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  44. package/dist/modules/features/cms/sanity-pagebuilder/config.js +2 -0
  45. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +81 -0
  48. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -0
  49. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +7 -0
  50. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +72 -21
  51. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
  52. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/gated-content.ts +178 -0
  53. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +9 -1
  54. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +54 -1
  55. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -0
  56. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -0
  57. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +4 -0
  58. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-builder-markdown.ts +20 -0
  59. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +27 -5
  60. package/src/modules/features/env/dotenvx/files/setup-remote.ts +3 -1
  61. package/src/templates/next-default/.env.example +8 -0
  62. package/src/templates/next-default/.github/workflows/ci.yml +7 -0
  63. package/src/templates/next-default/README.md +14 -0
  64. package/src/templates/next-default/knip.json +8 -6
  65. package/src/templates/next-default/lefthook.yml +0 -5
  66. package/src/templates/next-experiments/.env.example +8 -0
  67. package/src/templates/next-experiments/.github/workflows/ci.yml +7 -0
  68. package/src/templates/next-experiments/README.md +14 -0
  69. package/src/templates/next-experiments/knip.json +8 -6
  70. package/src/templates/next-experiments/lefthook.yml +0 -5
  71. package/src/templates/next-pagebuilder/.env.example +8 -0
  72. package/src/templates/next-pagebuilder/.github/PULL_REQUEST_TEMPLATE.md +13 -0
  73. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +15 -0
  74. package/src/templates/next-pagebuilder/README.md +14 -0
  75. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +2 -2
  76. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +39 -0
  77. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +169 -0
  78. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -0
  79. package/src/templates/next-pagebuilder/knip.json +8 -6
  80. package/src/templates/next-pagebuilder/lefthook.yml +0 -5
  81. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +81 -0
  82. package/src/templates/next-webgl/.env.example +8 -0
  83. package/src/templates/next-webgl/.github/workflows/ci.yml +7 -0
  84. package/src/templates/next-webgl/README.md +14 -0
  85. package/src/templates/next-webgl/knip.json +8 -6
  86. package/src/templates/next-webgl/lefthook.yml +0 -5
@@ -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
- _type == "blogContent" => {
63
- ...,
64
- categories[]->{
65
- _id,
66
- title,
67
- slug
68
- },
69
- author->{
70
- _id,
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
- relatedPosts[]->{
79
- _id,
80
- title,
81
- "resolvedSlug": ${pageResolvedSlugExpression},
82
- metadata
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
+ });
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { SchemaTypeDefinition } from "sanity";
11
- import { blogCollection, blogContent, description, hero } from "./components";
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,
@@ -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
- description: "Title used by search engines",
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",
@@ -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 =
@@ -6,6 +6,9 @@ interface GenerateMetadataOptions {
6
6
  title?: string | undefined;
7
7
  description?: string | undefined;
8
8
  keywords?: string[] | undefined;
9
+ ogTitle?: string | undefined;
10
+ ogDescription?: string | undefined;
11
+ canonicalUrl?: string | undefined;
9
12
  image?:
10
13
  | {
11
14
  url?: string | undefined;
@@ -33,6 +36,10 @@ interface SanityMetadataImage {
33
36
  interface SanityPageMetadata {
34
37
  metaTitle?: string | null;
35
38
  metaDescription?: string | null;
39
+ keywords?: string[] | null;
40
+ ogTitle?: string | null;
41
+ ogDescription?: string | null;
42
+ canonicalUrl?: string | null;
36
43
  og?: {
37
44
  image?: SanityMetadataImage | null;
38
45
  } | null;
@@ -81,6 +88,9 @@ const generatePageMetadata = (options: GenerateMetadataOptions): Metadata => {
81
88
  title,
82
89
  description,
83
90
  keywords,
91
+ ogTitle,
92
+ ogDescription,
93
+ canonicalUrl,
84
94
  image,
85
95
  url,
86
96
  siteName = "Basement",
@@ -98,29 +108,33 @@ const generatePageMetadata = (options: GenerateMetadataOptions): Metadata => {
98
108
  const width = image?.width || 1200;
99
109
  const height = image?.height || 630;
100
110
 
111
+ // Social title/description fall back to the SEO meta title/description.
112
+ const socialTitle = ogTitle || title;
113
+ const socialDescription = ogDescription || description;
114
+
101
115
  const metadata: Metadata = {
102
116
  metadataBase: new URL(APP_BASE_URL),
103
117
  title,
104
118
  description,
105
119
  keywords,
106
- alternates: { canonical: url || "/" },
120
+ alternates: { canonical: canonicalUrl || url || "/" },
107
121
  openGraph: {
108
- title,
109
- description,
122
+ description: socialDescription,
110
123
  url: fullUrl,
111
124
  siteName,
112
125
  locale: "en_US",
113
126
  type,
114
127
  images: [{ url: imageUrl, width, height }],
128
+ ...(socialTitle && { title: socialTitle }),
115
129
  ...(publishedTime && { publishedTime }),
116
130
  ...(modifiedTime && { modifiedTime }),
117
131
  ...(authors && { authors }),
118
132
  },
119
133
  twitter: {
120
134
  card: "summary_large_image",
121
- title,
122
- description,
135
+ description: socialDescription,
123
136
  images: [{ url: imageUrl, width, height }],
137
+ ...(socialTitle && { title: socialTitle }),
124
138
  },
125
139
  other: { "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "" },
126
140
  };
@@ -155,9 +169,17 @@ export const generateSanityMetadata = ({
155
169
  pageMetadata?.index ??
156
170
  (pageMetadata?.noIndex != null ? !pageMetadata.noIndex : true);
157
171
 
172
+ const keywords = pageMetadata?.keywords?.length
173
+ ? pageMetadata.keywords
174
+ : undefined;
175
+
158
176
  return generatePageMetadata({
159
177
  title,
160
178
  description,
179
+ keywords,
180
+ ogTitle: pageMetadata?.ogTitle || undefined,
181
+ ogDescription: pageMetadata?.ogDescription || undefined,
182
+ canonicalUrl: pageMetadata?.canonicalUrl || undefined,
161
183
  image: imageUrl ? { url: imageUrl, width: 1200, height: 630 } : undefined,
162
184
  url,
163
185
  siteName,
@@ -166,8 +166,10 @@ function main(): void {
166
166
  }
167
167
 
168
168
  // Push after connecting so the git integration builds the first deployment.
169
+ // `--no-verify` skips pre-push hooks — this is the initial scaffold push, and
170
+ // a hook failure here would strand it behind an already-created empty remote.
169
171
  console.info("→ git push");
170
- if (!run("git", ["push", "-u", "origin", "HEAD"], "git not found.")) {
172
+ if (!run("git", ["push", "-u", "origin", "HEAD", "--no-verify"], "git not found.")) {
171
173
  console.error(
172
174
  "\nConnected, but the push failed — run `git push` to trigger the first deploy.",
173
175
  );
@@ -1,6 +1,7 @@
1
1
  # ============================================
2
2
  # Basement Next Starter - Environment Variables
3
3
  # ============================================
4
+ # bsmnt:if dotenvx
4
5
  # This project uses dotenvx (https://dotenvx.com): the committed `.env` holds
5
6
  # shared values, with secrets encrypted there. The private keys live in
6
7
  # `.env.keys` (gitignored) — restore them with `bun run env:setup`.
@@ -15,6 +16,13 @@
15
16
  #
16
17
  # This file just documents the available variables; real values live in `.env`.
17
18
  # ============================================
19
+ # bsmnt:endif
20
+ # bsmnt:if !dotenvx
21
+ # Copy this file to `.env.local` and fill in the values you need.
22
+ # `.env.local` is gitignored, so real values stay off version control.
23
+ # Only NEXT_PUBLIC_* variables are exposed to the browser.
24
+ # ============================================
25
+ # bsmnt:endif
18
26
 
19
27
 
20
28
  # ============================================
@@ -46,6 +46,7 @@ jobs:
46
46
  - name: Lint
47
47
  run: bun run lint
48
48
 
49
+ # bsmnt:if dotenvx
49
50
  # dotenvx needs the private key to decrypt secrets at build time. It's
50
51
  # pulled from 1Password via an org-wide service-account token + the op-ref
51
52
  # committed in .env. Optional: builds pass without it (only plaintext /
@@ -70,6 +71,12 @@ jobs:
70
71
  fi
71
72
  bun run build
72
73
 
74
+ # bsmnt:endif
75
+ # bsmnt:if !dotenvx
76
+ - name: Build
77
+ run: bun run build
78
+
79
+ # bsmnt:endif
73
80
  # Runs your tests if any exist; a fresh project with none passes cleanly.
74
81
  - name: Test
75
82
  run: |