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.
Files changed (116) hide show
  1. package/dist/core/create/internal-ids.d.ts +1 -0
  2. package/dist/core/create/internal-ids.d.ts.map +1 -1
  3. package/dist/core/create/internal-ids.js +1 -0
  4. package/dist/core/create/internal-ids.js.map +1 -1
  5. package/dist/domain/skills.d.ts +5 -0
  6. package/dist/domain/skills.d.ts.map +1 -1
  7. package/dist/domain/skills.js +9 -1
  8. package/dist/domain/skills.js.map +1 -1
  9. package/dist/infrastructure/create/format-project.d.ts +9 -0
  10. package/dist/infrastructure/create/format-project.d.ts.map +1 -0
  11. package/dist/infrastructure/create/format-project.js +21 -0
  12. package/dist/infrastructure/create/format-project.js.map +1 -0
  13. package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
  14. package/dist/infrastructure/create/lifecycle-registry.js +5 -0
  15. package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
  16. package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
  17. package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
  18. package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
  19. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
  20. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
  21. package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/agent-skills/launch-ready/SKILL.md +114 -0
  24. package/src/agent-skills/launch-ready/references/a11y.md +74 -0
  25. package/src/agent-skills/launch-ready/references/content.md +65 -0
  26. package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
  27. package/src/agent-skills/launch-ready/references/launch.md +44 -0
  28. package/src/agent-skills/launch-ready/references/metadata.md +83 -0
  29. package/src/agent-skills/launch-ready/references/performance.md +36 -0
  30. package/src/agent-skills/launch-ready/references/routing.md +47 -0
  31. package/src/agent-skills/launch-ready/references/security.md +39 -0
  32. package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
  33. package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
  34. package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
  35. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +3 -1
  36. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  37. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
  38. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
  39. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +15 -0
  40. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +20 -0
  41. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
  42. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +2 -0
  43. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  44. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  45. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  46. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
  47. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  48. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +16 -1
  49. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
  50. package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
  51. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
  52. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +3 -1
  53. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
  54. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
  55. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
  56. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +15 -0
  57. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +20 -0
  58. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
  59. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +2 -0
  60. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
  61. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
  62. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
  63. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
  64. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
  65. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +16 -1
  66. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
  67. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
  68. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
  69. package/src/templates/next-default/.env.example +3 -1
  70. package/src/templates/next-default/app/layout.tsx +2 -1
  71. package/src/templates/next-default/app/robots.ts +9 -3
  72. package/src/templates/next-default/lib/utils/metadata.ts +2 -1
  73. package/src/templates/next-default/lib/utils/url.ts +20 -3
  74. package/src/templates/next-default/next.config.ts +16 -0
  75. package/src/templates/next-experiments/.env.example +3 -1
  76. package/src/templates/next-experiments/app/layout.tsx +2 -1
  77. package/src/templates/next-experiments/app/robots.ts +9 -3
  78. package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
  79. package/src/templates/next-experiments/lib/utils/url.ts +20 -3
  80. package/src/templates/next-experiments/next.config.ts +16 -0
  81. package/src/templates/next-pagebuilder/.env.example +3 -1
  82. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
  83. package/src/templates/next-pagebuilder/AGENTS.md +3 -0
  84. package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
  85. package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
  86. package/src/templates/next-pagebuilder/app/robots.ts +9 -3
  87. package/src/templates/next-pagebuilder/biome.json +2 -1
  88. package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
  89. package/src/templates/next-pagebuilder/components/page-document/index.tsx +27 -35
  90. package/src/templates/next-pagebuilder/lefthook.yml +1 -1
  91. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +32 -29
  92. package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
  93. package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
  94. package/src/templates/next-pagebuilder/next.config.ts +16 -0
  95. package/src/templates/next-pagebuilder/package.json +1 -1
  96. package/src/templates/next-pagebuilder-cache/.env.example +3 -1
  97. package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
  98. package/src/templates/next-pagebuilder-cache/AGENTS.md +3 -0
  99. package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
  100. package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
  101. package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
  102. package/src/templates/next-pagebuilder-cache/biome.json +2 -1
  103. package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
  104. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +27 -35
  105. package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
  106. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +32 -29
  107. package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
  108. package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
  109. package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
  110. package/src/templates/next-pagebuilder-cache/package.json +1 -1
  111. package/src/templates/next-webgl/.env.example +3 -1
  112. package/src/templates/next-webgl/app/layout.tsx +2 -1
  113. package/src/templates/next-webgl/app/robots.ts +9 -3
  114. package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
  115. package/src/templates/next-webgl/lib/utils/url.ts +20 -3
  116. package/src/templates/next-webgl/next.config.ts +16 -0
@@ -3,15 +3,30 @@
3
3
  *
4
4
  * Priority:
5
5
  * 1. NEXT_PUBLIC_BASE_URL (explicit production URL)
6
- * 2. VERCEL_PROJECT_PRODUCTION_URL (Vercel production domain)
7
- * 3. VERCEL_URL (Vercel preview/deployment URL)
6
+ * 2. VERCEL_PROJECT_PRODUCTION_URL, on production deployments only
7
+ * 3. VERCEL_URL (the deployment's own URL, so previews never emit production URLs)
8
8
  * 4. http://localhost:3000 (local development)
9
9
  */
10
10
  export const getBaseUrl = () =>
11
11
  process.env.NEXT_PUBLIC_BASE_URL ||
12
- (process.env.VERCEL_PROJECT_PRODUCTION_URL
12
+ (process.env.VERCEL_ENV === "production" &&
13
+ process.env.VERCEL_PROJECT_PRODUCTION_URL
13
14
  ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
14
15
  : undefined) ||
15
16
  (process.env.VERCEL_URL
16
17
  ? `https://${process.env.VERCEL_URL}`
17
18
  : "http://localhost:3000")
19
+
20
+ // The production build also answers on *.vercel.app; only the launch domain is indexed.
21
+ export const getIndexableHost = () => {
22
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
23
+ // Off Vercel VERCEL_ENV is unset, and the configured host is the launch domain.
24
+ const vercelEnv = process.env.VERCEL_ENV
25
+ if (!baseUrl || (vercelEnv && vercelEnv !== "production")) return null
26
+ return URL.canParse(baseUrl) ? new URL(baseUrl).host : null
27
+ }
28
+
29
+ export const isIndexableHost = (host: string | null | undefined) => {
30
+ const indexable = getIndexableHost()
31
+ return indexable !== null && host?.toLowerCase() === indexable
32
+ }
@@ -0,0 +1,70 @@
1
+ import type { BREADCRUMB_PAGES_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types"
2
+
3
+ type BreadcrumbPage = BREADCRUMB_PAGES_QUERY_RESULT[number]
4
+
5
+ type Breadcrumb = {
6
+ name: string
7
+ url: string
8
+ }
9
+
10
+ const HOME_NAME = "Home"
11
+
12
+ const trimSlashes = (path: string) => path.replace(/^\/+|\/+$/g, "")
13
+
14
+ const firstNonEmpty = (...values: (string | null | undefined)[]) =>
15
+ values.find((value) => value?.trim())?.trim()
16
+
17
+ const urlFor = (path: string) => (path ? `/${path}` : "/")
18
+
19
+ const labelFor = (page: BreadcrumbPage | undefined) =>
20
+ page && firstNonEmpty(page.shortTitle, page.folderTitle, page.title)
21
+
22
+ // Every parent path, not the path itself: "a/b/c" → ["a", "a/b"].
23
+ const ancestorPaths = (path: string) => {
24
+ const segments = path.split("/").filter(Boolean)
25
+ return segments
26
+ .slice(0, -1)
27
+ .map((_, index) => segments.slice(0, index + 1).join("/"))
28
+ }
29
+
30
+ // The paths a page's crumbs are named from, besides the homepage: its
31
+ // ancestors and itself.
32
+ export const breadcrumbPaths = (path: string | null) => {
33
+ const current = trimSlashes(path ?? "")
34
+ return current ? [...ancestorPaths(current), current] : []
35
+ }
36
+
37
+ // Crumbs are named from the pages at each level, the current one included:
38
+ // Short Title, then the folder name for a folder's index page, then Title. A
39
+ // level with no page is skipped, so a crumb never links to a 404.
40
+ export const buildBreadcrumbs = ({
41
+ path,
42
+ name,
43
+ pages,
44
+ }: {
45
+ path: string | null
46
+ name: string
47
+ pages: readonly BreadcrumbPage[]
48
+ }): Breadcrumb[] => {
49
+ const pageAt = (target: string) =>
50
+ pages.find((page) => trimSlashes(page.path ?? "") === target)
51
+
52
+ const home = pageAt("")
53
+ const crumbs: Breadcrumb[] = [
54
+ {
55
+ name: firstNonEmpty(home?.shortTitle, home?.title) ?? HOME_NAME,
56
+ url: "/",
57
+ },
58
+ ]
59
+
60
+ const current = trimSlashes(path ?? "")
61
+ if (!current) return crumbs
62
+
63
+ for (const ancestor of ancestorPaths(current)) {
64
+ const ancestorName = labelFor(pageAt(ancestor))
65
+ if (ancestorName) crumbs.push({ name: ancestorName, url: urlFor(ancestor) })
66
+ }
67
+
68
+ crumbs.push({ name: labelFor(pageAt(current)) ?? name, url: urlFor(current) })
69
+ return crumbs
70
+ }
@@ -1,5 +1,9 @@
1
1
  import withBundleAnalyzer from "@next/bundle-analyzer"
2
2
  import type { NextConfig } from "next"
3
+ import { getIndexableHost } from "./lib/utils/base-url"
4
+
5
+ // Only the launch domain is indexed; every other host is sent noindex.
6
+ const indexableHost = getIndexableHost()
3
7
 
4
8
  const nextConfig: NextConfig = {
5
9
  reactStrictMode: true,
@@ -92,6 +96,18 @@ const nextConfig: NextConfig = {
92
96
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
93
97
  },
94
98
  headers: async () => [
99
+ {
100
+ source: "/:path*",
101
+ ...(indexableHost && {
102
+ missing: [
103
+ {
104
+ type: "host" as const,
105
+ value: indexableHost.replaceAll(".", "\\."),
106
+ },
107
+ ],
108
+ }),
109
+ headers: [{ key: "X-Robots-Tag", value: "noindex" }],
110
+ },
95
111
  {
96
112
  // Excludes /studio* — must stay mutually exclusive with the rule below.
97
113
  source: "/((?!studio(?:/|$)).*)",
@@ -17,7 +17,7 @@
17
17
  "knip": "knip",
18
18
  "knip:fix": "knip --fix",
19
19
  "lighthouse": "bunx @unlighthouse/cli --site http://localhost:3000",
20
- "lint": "biome lint --max-diagnostics=200",
20
+ "lint": "biome ci --error-on-warnings --max-diagnostics=200 .",
21
21
  "lint:fix": "biome check --write --unsafe --max-diagnostics=200",
22
22
  "prepare": "lefthook install",
23
23
  "start": "next start",
@@ -26,7 +26,9 @@
26
26
 
27
27
  # Base URL
28
28
  # Inlined at build time — scope this to the Production environment only in
29
- # Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
29
+ # Vercel so Preview deployments fall through to their own VERCEL_URL. On a
30
+ # production deployment it is also the only host that gets indexed: every other
31
+ # host is sent `X-Robots-Tag: noindex` and a disallow-all robots.txt.
30
32
  NEXT_PUBLIC_BASE_URL=
31
33
 
32
34
  # Draft Mode
@@ -8,6 +8,13 @@ on:
8
8
  branches:
9
9
  - main
10
10
 
11
+ permissions:
12
+ contents: read
13
+
14
+ concurrency:
15
+ group: ci-${{ github.ref }}
16
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
17
+
11
18
  jobs:
12
19
  ci:
13
20
  runs-on: ubuntu-latest
@@ -43,17 +50,33 @@ jobs:
43
50
  - name: Type check
44
51
  run: bun run typecheck
45
52
 
46
- - name: Lint
53
+ - name: Lint and format
47
54
  run: bun run lint
48
55
 
56
+ # Runs your tests if any exist; a fresh project with none passes cleanly.
57
+ - name: Test
58
+ run: |
59
+ if git ls-files '*.test.*' '*.spec.*' '*_test.*' '*_spec.*' | grep -q .; then
60
+ bun test
61
+ else
62
+ echo "No test files found — skipping bun test."
63
+ fi
64
+
49
65
  # bsmnt:if dotenvx
50
66
  # dotenvx needs the private key to decrypt secrets at build time. It's
51
67
  # pulled from 1Password via an org-wide service-account token + the op-ref
52
68
  # committed in .env. Optional: builds pass without it (only plaintext /
53
69
  # NEXT_PUBLIC_* is read), and a per-repo DOTENV_PRIVATE_KEY secret still
54
70
  # works as a fallback.
71
+ # The `secrets` context isn't available in step `if:`, so the check runs as its own step.
72
+ - name: Detect 1Password token
73
+ id: op
74
+ env:
75
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
76
+ run: '[ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] && echo "enabled=true" >> "$GITHUB_OUTPUT" || true'
77
+
55
78
  - name: Install 1Password CLI
56
- if: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN != '' }}
79
+ if: steps.op.outputs.enabled == 'true'
57
80
  uses: 1password/install-cli-action@v1
58
81
 
59
82
  - name: Build
@@ -92,13 +115,14 @@ jobs:
92
115
  run: bun run build
93
116
 
94
117
  # bsmnt:endif
95
- # Runs your tests if any exist; a fresh project with none passes cleanly.
96
- - name: Test
118
+ # `prebuild` regenerated these; any change (or untracked file) means they were committed stale.
119
+ - name: Generated Sanity files are committed
97
120
  run: |
98
- if git ls-files '*.test.*' '*.spec.*' '*_test.*' '*_spec.*' | grep -q .; then
99
- bun test
100
- else
101
- echo "No test files found — skipping bun test."
121
+ cd lib/integrations/sanity
122
+ if [ -n "$(git status --porcelain -- sanity.types.ts schema.json)" ]; then
123
+ git status --short -- sanity.types.ts schema.json
124
+ echo "::error::Run \`bun sanity:typegen\` and commit the result."
125
+ exit 1
102
126
  fi
103
127
 
104
128
  - name: Knip
@@ -146,3 +146,6 @@ the "Page-builder blocks" section of the README.
146
146
 
147
147
  (`bun run build`, not `bun build` — the bare form is Bun's bundler.) Say what
148
148
  you ran and what it printed; don't report success you haven't seen.
149
+ - **Before a commit or PR, or when told to ship, check the `launch-ready`
150
+ skill's rules against the diff.** Run all of them when asked whether the site
151
+ is ready to launch.
@@ -1,6 +1,9 @@
1
1
  import { cacheLife, cacheTag } from "next/cache"
2
2
  import { notFound } from "next/navigation"
3
- import { PageDocument } from "@/components/page-document"
3
+ import { toPlainText } from "next-sanity"
4
+ import { findBlogBlock, PageDocument } from "@/components/page-document"
5
+ import { getBreadcrumbPages } from "@/lib/integrations/sanity/fetchers/breadcrumbs"
6
+ import { getLayoutData } from "@/lib/integrations/sanity/fetchers/layout"
4
7
  import {
5
8
  type DynamicFetchOptions,
6
9
  getDynamicFetchOptions,
@@ -80,6 +83,23 @@ export const generateStaticParams = async () => {
80
83
  ]
81
84
  }
82
85
 
86
+ // Blog posts fall back to their body and thumbnail when the SEO fields are
87
+ // empty, and are tagged as articles.
88
+ const blogPostMetadata = (page: NonNullable<PAGE_QUERY_RESULT>) => {
89
+ const post = findBlogBlock(page)
90
+ if (!post) return {}
91
+
92
+ return {
93
+ fallbackDescription: post.body ? toPlainText(post.body) : undefined,
94
+ fallbackImage: post.thumbnail,
95
+ article: {
96
+ publishedTime: post.date,
97
+ modifiedTime: page._updatedAt,
98
+ authors: [post.author?.name],
99
+ },
100
+ }
101
+ }
102
+
83
103
  export const generateMetadata = async ({ params }: Props) => {
84
104
  const [{ slug }, fetchOptions] = await Promise.all([
85
105
  params,
@@ -91,11 +111,20 @@ export const generateMetadata = async ({ params }: Props) => {
91
111
 
92
112
  // stega off: it would corrupt a <title> or og:image URL, and metadata renders no
93
113
  // DOM for <VisualEditing> anyway. In published mode this is the body's key.
94
- const page = await getCachedPage(path, { ...fetchOptions, stega: false })
114
+ const metadataFetchOptions = { ...fetchOptions, stega: false }
115
+ const [page, { structuredData }] = await Promise.all([
116
+ getCachedPage(path, metadataFetchOptions),
117
+ getLayoutData(metadataFetchOptions),
118
+ ])
95
119
 
96
- return page
97
- ? generateSanityMetadata({ document: page, url: path ? `/${path}` : "/" })
98
- : {}
120
+ if (!page) return {}
121
+
122
+ return generateSanityMetadata({
123
+ document: page,
124
+ url: path ? `/${path}` : "/",
125
+ site: structuredData,
126
+ ...blogPostMetadata(page),
127
+ })
99
128
  }
100
129
 
101
130
  // No Suspense wrapper needed: `getDynamicFetchOptions` is prerender-safe and
@@ -112,7 +141,10 @@ export default async function CatchAllPage({
112
141
 
113
142
  if (slug?.length && path === null) notFound()
114
143
 
115
- const page = await getCachedPage(path, fetchOptions)
144
+ const [page, breadcrumbPages] = await Promise.all([
145
+ getCachedPage(path, fetchOptions),
146
+ getBreadcrumbPages(path, fetchOptions),
147
+ ])
116
148
 
117
149
  if (!page) {
118
150
  // Fall back to a CMS redirect before 404ing.
@@ -120,5 +152,12 @@ export default async function CatchAllPage({
120
152
  notFound()
121
153
  }
122
154
 
123
- return <PageDocument page={page} path={path} searchParams={searchParams} />
155
+ return (
156
+ <PageDocument
157
+ page={page}
158
+ path={path}
159
+ searchParams={searchParams}
160
+ breadcrumbPages={breadcrumbPages}
161
+ />
162
+ )
124
163
  }
@@ -4,17 +4,11 @@ import type { Metadata } from "next"
4
4
  import { Geist } from "next/font/google"
5
5
  import type { PropsWithChildren } from "react"
6
6
  import { cn } from "@/lib/styles/cn"
7
+ import { getBaseUrl } from "@/lib/utils/base-url"
7
8
  import AppData from "@/package.json"
8
9
 
9
10
  const APP_NAME = AppData.name
10
- const APP_DEFAULT_TITLE = "My Site"
11
- const APP_TITLE_TEMPLATE = "%s"
12
- const APP_DESCRIPTION = AppData.description
13
- // Build-time-stable so static routes (Studio, /_not-found) stay prerenderable
14
- // under Cache Components. NEXT_PUBLIC_* is inlined at build; reading runtime env
15
- // (VERCEL_URL) here would taint every route's metadata as request-time data.
16
- const APP_BASE_URL =
17
- process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
11
+ const APP_BASE_URL = getBaseUrl()
18
12
 
19
13
  const geist = Geist({
20
14
  subsets: ["latin"],
@@ -22,55 +16,20 @@ const geist = Geist({
22
16
  })
23
17
 
24
18
  export const metadata: Metadata = {
25
- alternates: {
26
- canonical: "/",
27
- languages: {
28
- "en-US": "/en-US",
29
- },
30
- },
19
+ metadataBase: new URL(APP_BASE_URL),
20
+ applicationName: APP_NAME,
31
21
  appleWebApp: {
32
22
  capable: true,
33
23
  statusBarStyle: "default",
34
- title: APP_DEFAULT_TITLE,
35
24
  },
36
- applicationName: APP_NAME,
37
- authors: [{ name: "basement.studio", url: "https://basement.studio" }],
38
- description: APP_DESCRIPTION,
39
25
  formatDetection: { telephone: false },
40
- metadataBase: new URL(APP_BASE_URL),
41
26
  openGraph: {
42
- description: APP_DESCRIPTION,
43
- images: [
44
- {
45
- alt: APP_DEFAULT_TITLE,
46
- height: 630,
47
- url: "/opengraph-image.jpg",
48
- width: 1200,
49
- },
50
- ],
51
- locale: "en_US",
52
27
  siteName: APP_NAME,
53
- title: {
54
- default: APP_DEFAULT_TITLE,
55
- template: APP_TITLE_TEMPLATE,
56
- },
28
+ locale: "en_US",
57
29
  type: "website",
58
- url: APP_BASE_URL,
59
- },
60
- other: {
61
- "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "",
62
- },
63
- title: {
64
- default: APP_DEFAULT_TITLE,
65
- template: APP_TITLE_TEMPLATE,
66
30
  },
67
31
  twitter: {
68
32
  card: "summary_large_image",
69
- description: APP_DESCRIPTION,
70
- title: {
71
- default: APP_DEFAULT_TITLE,
72
- template: APP_TITLE_TEMPLATE,
73
- },
74
33
  },
75
34
  }
76
35
 
@@ -1,12 +1,18 @@
1
1
  import type { MetadataRoute } from "next"
2
- import { getBaseUrl } from "@/lib/utils/base-url"
2
+ import { headers } from "next/headers"
3
+ import { getBaseUrl, isIndexableHost } from "@/lib/utils/base-url"
4
+
5
+ export default async function robots(): Promise<MetadataRoute.Robots> {
6
+ const host = (await headers()).get("host")
7
+ if (!isIndexableHost(host)) {
8
+ return { rules: { userAgent: "*", disallow: "/" } }
9
+ }
3
10
 
4
- export default function robots(): MetadataRoute.Robots {
5
11
  return {
6
12
  rules: {
7
13
  userAgent: "*",
8
14
  allow: "/",
9
- disallow: [],
15
+ disallow: ["/api/", "/studio"],
10
16
  },
11
17
  sitemap: `${getBaseUrl()}/sitemap.xml`,
12
18
  }
@@ -36,7 +36,8 @@
36
36
  "!**/*.mdx",
37
37
  "!**/tailwind.css",
38
38
  "!**/*.grit",
39
- "!**/sanity.types.ts"
39
+ "!**/sanity.types.ts",
40
+ "!next-env.d.ts"
40
41
  ]
41
42
  },
42
43
  "formatter": {
@@ -60,7 +60,7 @@ async function CachedFooter(fetchOptions: DynamicFetchOptions) {
60
60
  <div className="grid grid-cols-5 gap-x-8 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
61
61
  {links.map((linkGroup: FooterLinkGroup) => (
62
62
  <div key={linkGroup._key} className="flex flex-col gap-2">
63
- <h3>{linkGroup.title}</h3>
63
+ <h2>{linkGroup.title}</h2>
64
64
  {linkGroup.items && linkGroup.items.length > 0 ? (
65
65
  <ul className="space-y-1">
66
66
  {linkGroup.items
@@ -1,9 +1,13 @@
1
1
  import { stegaClean } from "@sanity/client/stega"
2
2
  import type { ContentCollectionSearchParams } from "@/components/page-builder/components/content-collection/types"
3
3
  import { Renderer } from "@/components/page-builder/renderer"
4
- import type { PAGE_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types"
4
+ import type {
5
+ BREADCRUMB_PAGES_QUERY_RESULT,
6
+ PAGE_QUERY_RESULT,
7
+ } from "@/lib/integrations/sanity/sanity.types"
5
8
  import { urlForImage } from "@/lib/integrations/sanity/utils/image"
6
9
  import { buildPageJsonLd } from "@/lib/integrations/sanity/utils/page-json-ld"
10
+ import { buildBreadcrumbs } from "@/lib/utils/breadcrumbs"
7
11
  import {
8
12
  generateBreadcrumbJsonLd,
9
13
  generateSanityArticleJsonLd,
@@ -15,6 +19,7 @@ type PageDocumentProps = {
15
19
  page: NonNullable<PAGE_QUERY_RESULT>
16
20
  path: string | null
17
21
  searchParams: ContentCollectionSearchParams
22
+ breadcrumbPages: BREADCRUMB_PAGES_QUERY_RESULT
18
23
  }
19
24
 
20
25
  type PageBuilderBlock = NonNullable<
@@ -22,6 +27,13 @@ type PageBuilderBlock = NonNullable<
22
27
  >[number]
23
28
  type BlogContentBlock = Extract<PageBuilderBlock, { _type: "blogContent" }>
24
29
 
30
+ // Blog posts are pages with a Blog Posts block; it carries the post's date,
31
+ // author and thumbnail.
32
+ export const findBlogBlock = (page: NonNullable<PAGE_QUERY_RESULT>) =>
33
+ page.pageBuilder?.find(
34
+ (block): block is BlogContentBlock => block?._type === "blogContent"
35
+ )
36
+
25
37
  export const getPageSections = (
26
38
  page: NonNullable<PAGE_QUERY_RESULT>,
27
39
  searchParams: ContentCollectionSearchParams
@@ -40,42 +52,20 @@ export const getPageSections = (
40
52
  ]
41
53
  : []
42
54
 
43
- const buildBreadcrumbs = (
44
- slugPath: string | null,
45
- pageTitle: string | null
46
- ): Array<{ name: string; url: string }> => {
47
- const items: Array<{ name: string; url: string }> = [
48
- { name: "Home", url: "/" },
49
- ]
50
-
51
- if (!slugPath) return items
52
-
53
- const segments = slugPath.split("/").filter(Boolean)
54
- for (let i = 0; i < segments.length; i++) {
55
- const isLast = i === segments.length - 1
56
- const url = `/${segments.slice(0, i + 1).join("/")}`
57
-
58
- //TODO: we might change this in the future if slug title and folder diverge too much :)
59
-
60
- const name =
61
- isLast && pageTitle
62
- ? pageTitle
63
- : (segments[i]
64
- ?.replace(/-/g, " ")
65
- .replace(/\b\w/g, (c) => c.toUpperCase()) ?? "")
66
- items.push({ name, url })
67
- }
68
-
69
- return items
70
- }
71
-
72
55
  export const PageDocument = ({
73
56
  page,
74
57
  path,
75
58
  searchParams,
59
+ breadcrumbPages,
76
60
  }: PageDocumentProps) => {
77
61
  const title = stegaClean(page.title) || page.title
78
62
  const sections = getPageSections(page, searchParams)
63
+ // A Heading block renders the page's H1, so the title only stands in without
64
+ // one. Hidden blocks are already filtered out of the query, and an empty one
65
+ // renders nothing (see the registry's `isEmpty`).
66
+ const hasHeadingBlock = page.pageBuilder?.some(
67
+ (block) => block?._type === "hero" && Boolean(block.headline)
68
+ )
79
69
 
80
70
  const cleanTitle = stegaClean(page.title) || "Basement"
81
71
  const cleanDescription = stegaClean(
@@ -87,7 +77,11 @@ export const PageDocument = ({
87
77
  ? urlForImage(page.metadata.image).width(1200).height(630).url()
88
78
  : undefined
89
79
 
90
- const breadcrumbs = buildBreadcrumbs(path, cleanTitle)
80
+ const breadcrumbs = buildBreadcrumbs({
81
+ path,
82
+ name: cleanTitle,
83
+ pages: breadcrumbPages,
84
+ })
91
85
  const hasBreadcrumbs = breadcrumbs.length > 1
92
86
 
93
87
  // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
@@ -96,9 +90,7 @@ export const PageDocument = ({
96
90
  // Blog posts (pages with a Blog Posts block) emit Article JSON-LD with
97
91
  // fields derived from the post — no Custom block needed. Skipped when the
98
92
  // editor already supplied an Article (via a Custom block) to avoid duplicates.
99
- const blogBlock = page.pageBuilder?.find(
100
- (block): block is BlogContentBlock => block?._type === "blogContent"
101
- )
93
+ const blogBlock = findBlogBlock(page)
102
94
  const hasManualArticle = pageJsonLd.some(
103
95
  ({ data }) => (data as { "@type"?: unknown })["@type"] === "Article"
104
96
  )
@@ -136,7 +128,7 @@ export const PageDocument = ({
136
128
  <JsonLd key={key} data={data} />
137
129
  ))}
138
130
  <article className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-14 md:px-6 md:py-20">
139
- <h1>{title}</h1>
131
+ {hasHeadingBlock ? null : <h1>{title}</h1>}
140
132
  {sections.length ? (
141
133
  sections
142
134
  ) : (
@@ -7,7 +7,7 @@ pre-commit:
7
7
  commands:
8
8
  biome:
9
9
  glob: '*.{js,mjs,ts,jsx,tsx,css,scss}'
10
- run: bun biome check --max-diagnostics=200 --write --unsafe {staged_files}
10
+ run: bun biome check --max-diagnostics=200 --write {staged_files}
11
11
  stage_fixed: true
12
12
  typecheck:
13
13
  glob: '*.{ts,tsx}'