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
@@ -11,12 +11,13 @@ import {
11
11
  generateWebSiteJsonLd,
12
12
  JsonLd,
13
13
  } from "@/lib/utils/json-ld"
14
+ import { getBaseUrl } from "@/lib/utils/url"
14
15
 
15
16
  const APP_NAME = AppData.name
16
17
  const APP_DEFAULT_TITLE = "Basement Starter"
17
18
  const APP_TITLE_TEMPLATE = "%s - Basement Starter"
18
19
  const APP_DESCRIPTION = AppData.description
19
- const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
20
+ const APP_BASE_URL = getBaseUrl()
20
21
 
21
22
  const geist = Geist({
22
23
  subsets: ["latin"],
@@ -1,12 +1,18 @@
1
1
  import type { MetadataRoute } from "next"
2
- import { getBaseUrl } from "@/lib/utils/url"
2
+ import { headers } from "next/headers"
3
+ import { getBaseUrl, isIndexableHost } from "@/lib/utils/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
  }
@@ -1,4 +1,5 @@
1
1
  import type { Metadata } from "next"
2
+ import { getBaseUrl } from "@/lib/utils/url"
2
3
 
3
4
  /**
4
5
  * Metadata Generation Utilities
@@ -26,7 +27,7 @@ interface GenerateMetadataOptions {
26
27
  authors?: string[]
27
28
  }
28
29
 
29
- const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
30
+ const APP_BASE_URL = getBaseUrl()
30
31
 
31
32
  /**
32
33
  * Generate complete metadata object for pages
@@ -3,17 +3,34 @@
3
3
  *
4
4
  * Priority:
5
5
  * 1. NEXT_PUBLIC_BASE_URL - explicit override (custom domains)
6
- * 2. VERCEL_PROJECT_PRODUCTION_URL - auto-set by Vercel (production domain)
7
- * 3. VERCEL_URL - auto-set by Vercel (preview/branch deploys)
6
+ * 2. VERCEL_PROJECT_PRODUCTION_URL - auto-set by Vercel, on production deployments only
7
+ * 3. VERCEL_URL - the deployment's own URL, so previews never emit production URLs
8
8
  * 4. localhost fallback
9
9
  */
10
10
  export function getBaseUrl(): string {
11
11
  if (process.env.NEXT_PUBLIC_BASE_URL) {
12
12
  return process.env.NEXT_PUBLIC_BASE_URL
13
13
  }
14
- if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
14
+ if (
15
+ process.env.VERCEL_ENV === "production" &&
16
+ process.env.VERCEL_PROJECT_PRODUCTION_URL
17
+ ) {
15
18
  return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
16
19
  }
17
20
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
18
21
  return "http://localhost:3000"
19
22
  }
23
+
24
+ // The production build also answers on *.vercel.app; only the launch domain is indexed.
25
+ export function getIndexableHost(): string | null {
26
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
27
+ // Off Vercel VERCEL_ENV is unset, and the configured host is the launch domain.
28
+ const vercelEnv = process.env.VERCEL_ENV
29
+ if (!baseUrl || (vercelEnv && vercelEnv !== "production")) return null
30
+ return URL.canParse(baseUrl) ? new URL(baseUrl).host : null
31
+ }
32
+
33
+ export function isIndexableHost(host: string | null | undefined): boolean {
34
+ const indexable = getIndexableHost()
35
+ return indexable !== null && host?.toLowerCase() === indexable
36
+ }
@@ -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/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(?:/|$)).*)",
@@ -33,7 +33,9 @@
33
33
  # Development: http://localhost:3000
34
34
  # Production: https://your-domain.com
35
35
  # Inlined at build time — scope this to the Production environment only in
36
- # Vercel so Preview deployments fall through to VERCEL_PROJECT_PRODUCTION_URL.
36
+ # Vercel so Preview deployments fall through to their own VERCEL_URL. On a
37
+ # production deployment it is also the only host that gets indexed: every other
38
+ # host is sent `X-Robots-Tag: noindex` and a disallow-all robots.txt.
37
39
  NEXT_PUBLIC_BASE_URL=""
38
40
 
39
41
  # Draft mode secret for preview functionality
@@ -11,12 +11,13 @@ import {
11
11
  generateWebSiteJsonLd,
12
12
  JsonLd,
13
13
  } from "@/lib/utils/json-ld"
14
+ import { getBaseUrl } from "@/lib/utils/url"
14
15
 
15
16
  const APP_NAME = AppData.name
16
17
  const APP_DEFAULT_TITLE = "Basement Starter"
17
18
  const APP_TITLE_TEMPLATE = "%s - Basement Starter"
18
19
  const APP_DESCRIPTION = AppData.description
19
- const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
20
+ const APP_BASE_URL = getBaseUrl()
20
21
 
21
22
  const geist = Geist({
22
23
  subsets: ["latin"],
@@ -1,12 +1,18 @@
1
1
  import type { MetadataRoute } from "next"
2
- import { getBaseUrl } from "@/lib/utils/url"
2
+ import { headers } from "next/headers"
3
+ import { getBaseUrl, isIndexableHost } from "@/lib/utils/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
  }
@@ -1,4 +1,5 @@
1
1
  import type { Metadata } from "next"
2
+ import { getBaseUrl } from "@/lib/utils/url"
2
3
 
3
4
  /**
4
5
  * Metadata Generation Utilities
@@ -26,7 +27,7 @@ interface GenerateMetadataOptions {
26
27
  authors?: string[]
27
28
  }
28
29
 
29
- const APP_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"
30
+ const APP_BASE_URL = getBaseUrl()
30
31
 
31
32
  /**
32
33
  * Generate complete metadata object for pages
@@ -3,17 +3,34 @@
3
3
  *
4
4
  * Priority:
5
5
  * 1. NEXT_PUBLIC_BASE_URL - explicit override (custom domains)
6
- * 2. VERCEL_PROJECT_PRODUCTION_URL - auto-set by Vercel (production domain)
7
- * 3. VERCEL_URL - auto-set by Vercel (preview/branch deploys)
6
+ * 2. VERCEL_PROJECT_PRODUCTION_URL - auto-set by Vercel, on production deployments only
7
+ * 3. VERCEL_URL - the deployment's own URL, so previews never emit production URLs
8
8
  * 4. localhost fallback
9
9
  */
10
10
  export function getBaseUrl(): string {
11
11
  if (process.env.NEXT_PUBLIC_BASE_URL) {
12
12
  return process.env.NEXT_PUBLIC_BASE_URL
13
13
  }
14
- if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
14
+ if (
15
+ process.env.VERCEL_ENV === "production" &&
16
+ process.env.VERCEL_PROJECT_PRODUCTION_URL
17
+ ) {
15
18
  return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
16
19
  }
17
20
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
18
21
  return "http://localhost:3000"
19
22
  }
23
+
24
+ // The production build also answers on *.vercel.app; only the launch domain is indexed.
25
+ export function getIndexableHost(): string | null {
26
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
27
+ // Off Vercel VERCEL_ENV is unset, and the configured host is the launch domain.
28
+ const vercelEnv = process.env.VERCEL_ENV
29
+ if (!baseUrl || (vercelEnv && vercelEnv !== "production")) return null
30
+ return URL.canParse(baseUrl) ? new URL(baseUrl).host : null
31
+ }
32
+
33
+ export function isIndexableHost(host: string | null | undefined): boolean {
34
+ const indexable = getIndexableHost()
35
+ return indexable !== null && host?.toLowerCase() === indexable
36
+ }
@@ -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/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(?:/|$)).*)",
@@ -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
@@ -139,3 +139,6 @@ the "Page-builder blocks" section of the README.
139
139
 
140
140
  (`bun run build`, not `bun build` — the bare form is Bun's bundler.) Say what
141
141
  you ran and what it printed; don't report success you haven't seen.
142
+ - **Before a commit or PR, or when told to ship, check the `launch-ready`
143
+ skill's rules against the diff.** Run all of them when asked whether the site
144
+ is ready to launch.
@@ -1,6 +1,9 @@
1
+ import { toPlainText } from "next-sanity"
1
2
  import { notFound } from "next/navigation"
2
- import { PageDocument } from "@/components/page-document"
3
+ import { findBlogBlock, PageDocument } from "@/components/page-document"
3
4
  import { client } from "@/lib/integrations/sanity/client"
5
+ import { getBreadcrumbPages } from "@/lib/integrations/sanity/fetchers/breadcrumbs"
6
+ import { getLayoutData } from "@/lib/integrations/sanity/fetchers/layout"
4
7
  import { fetchSanity } from "@/lib/integrations/sanity/live"
5
8
  import {
6
9
  ALL_PAGE_SLUGS_QUERY,
@@ -60,17 +63,42 @@ export const generateStaticParams = async () => {
60
63
 
61
64
  type Props = { params: Promise<{ slug?: string[] }> }
62
65
 
66
+ // Blog posts fall back to their body and thumbnail when the SEO fields are
67
+ // empty, and are tagged as articles.
68
+ const blogPostMetadata = (page: NonNullable<PAGE_QUERY_RESULT>) => {
69
+ const post = findBlogBlock(page)
70
+ if (!post) return {}
71
+
72
+ return {
73
+ fallbackDescription: post.body ? toPlainText(post.body) : undefined,
74
+ fallbackImage: post.thumbnail,
75
+ article: {
76
+ publishedTime: post.date,
77
+ modifiedTime: page._updatedAt,
78
+ authors: [post.author?.name],
79
+ },
80
+ }
81
+ }
82
+
63
83
  export const generateMetadata = async ({ params }: Props) => {
64
84
  const { slug } = await params
65
85
  const path = toPath(slug)
66
86
 
67
87
  if (slug?.length && path === null) return {}
68
88
 
69
- const page = await getResolvedPage(path, false)
89
+ const [page, { structuredData }] = await Promise.all([
90
+ getResolvedPage(path, false),
91
+ getLayoutData(),
92
+ ])
70
93
 
71
- return page
72
- ? generateSanityMetadata({ document: page, url: path ? `/${path}` : "/" })
73
- : {}
94
+ if (!page) return {}
95
+
96
+ return generateSanityMetadata({
97
+ document: page,
98
+ url: path ? `/${path}` : "/",
99
+ site: structuredData,
100
+ ...blogPostMetadata(page),
101
+ })
74
102
  }
75
103
 
76
104
  export default async function CatchAllPage({
@@ -85,7 +113,10 @@ export default async function CatchAllPage({
85
113
 
86
114
  if (slug?.length && path === null) notFound()
87
115
 
88
- const page = await getResolvedPage(path)
116
+ const [page, breadcrumbPages] = await Promise.all([
117
+ getResolvedPage(path),
118
+ getBreadcrumbPages(path),
119
+ ])
89
120
 
90
121
  if (!page) {
91
122
  // Fall back to a CMS redirect before 404ing.
@@ -93,5 +124,12 @@ export default async function CatchAllPage({
93
124
  notFound()
94
125
  }
95
126
 
96
- return <PageDocument page={page} path={path} searchParams={searchParams} />
127
+ return (
128
+ <PageDocument
129
+ page={page}
130
+ path={path}
131
+ searchParams={searchParams}
132
+ breadcrumbPages={breadcrumbPages}
133
+ />
134
+ )
97
135
  }
@@ -8,9 +8,6 @@ import { getBaseUrl } from "@/lib/utils/base-url"
8
8
  import AppData from "@/package.json"
9
9
 
10
10
  const APP_NAME = AppData.name
11
- const APP_DEFAULT_TITLE = "My Site"
12
- const APP_TITLE_TEMPLATE = "%s"
13
- const APP_DESCRIPTION = AppData.description
14
11
  const APP_BASE_URL = getBaseUrl()
15
12
 
16
13
  const geist = Geist({
@@ -19,55 +16,20 @@ const geist = Geist({
19
16
  })
20
17
 
21
18
  export const metadata: Metadata = {
22
- alternates: {
23
- canonical: "/",
24
- languages: {
25
- "en-US": "/en-US",
26
- },
27
- },
19
+ metadataBase: new URL(APP_BASE_URL),
20
+ applicationName: APP_NAME,
28
21
  appleWebApp: {
29
22
  capable: true,
30
23
  statusBarStyle: "default",
31
- title: APP_DEFAULT_TITLE,
32
24
  },
33
- applicationName: APP_NAME,
34
- authors: [{ name: "basement.studio", url: "https://basement.studio" }],
35
- description: APP_DESCRIPTION,
36
25
  formatDetection: { telephone: false },
37
- metadataBase: new URL(APP_BASE_URL),
38
26
  openGraph: {
39
- description: APP_DESCRIPTION,
40
- images: [
41
- {
42
- alt: APP_DEFAULT_TITLE,
43
- height: 630,
44
- url: "/opengraph-image.jpg",
45
- width: 1200,
46
- },
47
- ],
48
- locale: "en_US",
49
27
  siteName: APP_NAME,
50
- title: {
51
- default: APP_DEFAULT_TITLE,
52
- template: APP_TITLE_TEMPLATE,
53
- },
28
+ locale: "en_US",
54
29
  type: "website",
55
- url: APP_BASE_URL,
56
- },
57
- other: {
58
- "fb:app_id": process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "",
59
- },
60
- title: {
61
- default: APP_DEFAULT_TITLE,
62
- template: APP_TITLE_TEMPLATE,
63
30
  },
64
31
  twitter: {
65
32
  card: "summary_large_image",
66
- description: APP_DESCRIPTION,
67
- title: {
68
- default: APP_DEFAULT_TITLE,
69
- template: APP_TITLE_TEMPLATE,
70
- },
71
33
  },
72
34
  }
73
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": {
@@ -23,7 +23,7 @@ export const Footer = async () => {
23
23
  <div className="grid grid-cols-5 gap-x-8 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
24
24
  {links.map((linkGroup: FooterLinkGroup) => (
25
25
  <div key={linkGroup._key} className="flex flex-col gap-2">
26
- <h3>{linkGroup.title}</h3>
26
+ <h2>{linkGroup.title}</h2>
27
27
  {linkGroup.items && linkGroup.items.length > 0 ? (
28
28
  <ul className="space-y-1">
29
29
  {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
@@ -38,42 +50,20 @@ export const getPageSections = (
38
50
  ]
39
51
  : []
40
52
 
41
- const buildBreadcrumbs = (
42
- slugPath: string | null,
43
- pageTitle: string | null
44
- ): Array<{ name: string; url: string }> => {
45
- const items: Array<{ name: string; url: string }> = [
46
- { name: "Home", url: "/" },
47
- ]
48
-
49
- if (!slugPath) return items
50
-
51
- const segments = slugPath.split("/").filter(Boolean)
52
- for (let i = 0; i < segments.length; i++) {
53
- const isLast = i === segments.length - 1
54
- const url = `/${segments.slice(0, i + 1).join("/")}`
55
-
56
- //TODO: we might change this in the future if slug title and folder diverge too much :)
57
-
58
- const name =
59
- isLast && pageTitle
60
- ? pageTitle
61
- : (segments[i]
62
- ?.replace(/-/g, " ")
63
- .replace(/\b\w/g, (c) => c.toUpperCase()) ?? "")
64
- items.push({ name, url })
65
- }
66
-
67
- return items
68
- }
69
-
70
53
  export const PageDocument = ({
71
54
  page,
72
55
  path,
73
56
  searchParams,
57
+ breadcrumbPages,
74
58
  }: PageDocumentProps) => {
75
59
  const title = stegaClean(page.title) || page.title
76
60
  const sections = getPageSections(page, searchParams)
61
+ // A Heading block renders the page's H1, so the title only stands in without
62
+ // one. Hidden blocks are already filtered out of the query, and an empty one
63
+ // renders nothing (see the registry's `isEmpty`).
64
+ const hasHeadingBlock = page.pageBuilder?.some(
65
+ (block) => block?._type === "hero" && Boolean(block.headline)
66
+ )
77
67
 
78
68
  const cleanTitle = stegaClean(page.title) || "Basement"
79
69
  const cleanDescription = stegaClean(
@@ -85,7 +75,11 @@ export const PageDocument = ({
85
75
  ? urlForImage(page.metadata.image).width(1200).height(630).url()
86
76
  : undefined
87
77
 
88
- const breadcrumbs = buildBreadcrumbs(path, cleanTitle)
78
+ const breadcrumbs = buildBreadcrumbs({
79
+ path,
80
+ name: cleanTitle,
81
+ pages: breadcrumbPages,
82
+ })
89
83
  const hasBreadcrumbs = breadcrumbs.length > 1
90
84
 
91
85
  // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
@@ -94,9 +88,7 @@ export const PageDocument = ({
94
88
  // Blog posts (pages with a Blog Posts block) emit Article JSON-LD with
95
89
  // fields derived from the post — no Custom block needed. Skipped when the
96
90
  // editor already supplied an Article (via a Custom block) to avoid duplicates.
97
- const blogBlock = page.pageBuilder?.find(
98
- (block): block is BlogContentBlock => block?._type === "blogContent"
99
- )
91
+ const blogBlock = findBlogBlock(page)
100
92
  const hasManualArticle = pageJsonLd.some(
101
93
  ({ data }) => (data as { "@type"?: unknown })["@type"] === "Article"
102
94
  )
@@ -134,7 +126,7 @@ export const PageDocument = ({
134
126
  <JsonLd key={key} data={data} />
135
127
  ))}
136
128
  <article className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-14 md:px-6 md:py-20">
137
- <h1>{title}</h1>
129
+ {hasHeadingBlock ? null : <h1>{title}</h1>}
138
130
  {sections.length ? (
139
131
  sections
140
132
  ) : (
@@ -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}'