bsmnt 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create/internal-ids.d.ts +1 -0
- package/dist/core/create/internal-ids.d.ts.map +1 -1
- package/dist/core/create/internal-ids.js +1 -0
- package/dist/core/create/internal-ids.js.map +1 -1
- package/dist/domain/skills.d.ts +5 -0
- package/dist/domain/skills.d.ts.map +1 -1
- package/dist/domain/skills.js +9 -1
- package/dist/domain/skills.js.map +1 -1
- package/dist/infrastructure/create/format-project.d.ts +9 -0
- package/dist/infrastructure/create/format-project.d.ts.map +1 -0
- package/dist/infrastructure/create/format-project.js +21 -0
- package/dist/infrastructure/create/format-project.js.map +1 -0
- package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
- package/dist/infrastructure/create/lifecycle-registry.js +5 -0
- package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder/index.js.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.d.ts.map +1 -1
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js +1 -0
- package/dist/modules/features/cms/sanity-pagebuilder-cache/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-skills/launch-ready/SKILL.md +114 -0
- package/src/agent-skills/launch-ready/references/a11y.md +74 -0
- package/src/agent-skills/launch-ready/references/content.md +65 -0
- package/src/agent-skills/launch-ready/references/crawlers.md +89 -0
- package/src/agent-skills/launch-ready/references/launch.md +44 -0
- package/src/agent-skills/launch-ready/references/metadata.md +83 -0
- package/src/agent-skills/launch-ready/references/performance.md +36 -0
- package/src/agent-skills/launch-ready/references/routing.md +47 -0
- package/src/agent-skills/launch-ready/references/security.md +39 -0
- package/src/modules/features/cms/sanity/files/app/layout.tsx +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/metadata.ts +2 -2
- package/src/modules/features/cms/sanity/files/lib/utils/url.ts +6 -3
- package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +22 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/tags.ts +5 -2
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/metadata.ts +109 -136
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +3 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/redirect-import-dialog.tsx +538 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/csv.ts +88 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/fetchers/breadcrumbs.ts +25 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +15 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +20 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/redirect-import.ts +651 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/redirect.ts +9 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/layout/structured-data.ts +8 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/metadata.ts +60 -6
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +22 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/richText.ts +0 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +16 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/tags.ts +6 -2
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/scripts/sanity-typegen.ts +8 -11
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/metadata.ts +109 -136
- package/src/templates/next-default/.env.example +3 -1
- package/src/templates/next-default/app/layout.tsx +2 -1
- package/src/templates/next-default/app/robots.ts +9 -3
- package/src/templates/next-default/lib/utils/metadata.ts +2 -1
- package/src/templates/next-default/lib/utils/url.ts +20 -3
- package/src/templates/next-default/next.config.ts +16 -0
- package/src/templates/next-experiments/.env.example +3 -1
- package/src/templates/next-experiments/app/layout.tsx +2 -1
- package/src/templates/next-experiments/app/robots.ts +9 -3
- package/src/templates/next-experiments/lib/utils/metadata.ts +2 -1
- package/src/templates/next-experiments/lib/utils/url.ts +20 -3
- package/src/templates/next-experiments/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/.env.example +3 -1
- package/src/templates/next-pagebuilder/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder/app/(content)/[[...slug]]/page.tsx +45 -7
- package/src/templates/next-pagebuilder/app/layout.tsx +3 -41
- package/src/templates/next-pagebuilder/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder/biome.json +2 -1
- package/src/templates/next-pagebuilder/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder/next.config.ts +16 -0
- package/src/templates/next-pagebuilder/package.json +1 -1
- package/src/templates/next-pagebuilder-cache/.env.example +3 -1
- package/src/templates/next-pagebuilder-cache/.github/workflows/ci.yml +32 -8
- package/src/templates/next-pagebuilder-cache/AGENTS.md +3 -0
- package/src/templates/next-pagebuilder-cache/app/(content)/[[...slug]]/page.tsx +46 -7
- package/src/templates/next-pagebuilder-cache/app/layout.tsx +5 -46
- package/src/templates/next-pagebuilder-cache/app/robots.ts +9 -3
- package/src/templates/next-pagebuilder-cache/biome.json +2 -1
- package/src/templates/next-pagebuilder-cache/components/layout/footer/index.tsx +1 -1
- package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +27 -35
- package/src/templates/next-pagebuilder-cache/lefthook.yml +1 -1
- package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +32 -29
- package/src/templates/next-pagebuilder-cache/lib/utils/base-url.ts +18 -3
- package/src/templates/next-pagebuilder-cache/lib/utils/breadcrumbs.ts +70 -0
- package/src/templates/next-pagebuilder-cache/next.config.ts +16 -0
- package/src/templates/next-pagebuilder-cache/package.json +1 -1
- package/src/templates/next-webgl/.env.example +3 -1
- package/src/templates/next-webgl/app/layout.tsx +2 -1
- package/src/templates/next-webgl/app/robots.ts +9 -3
- package/src/templates/next-webgl/lib/utils/metadata.ts +2 -1
- package/src/templates/next-webgl/lib/utils/url.ts +20 -3
- package/src/templates/next-webgl/next.config.ts +16 -0
|
@@ -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 =
|
|
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 {
|
|
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 =
|
|
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
|
|
7
|
-
* 3. VERCEL_URL -
|
|
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 (
|
|
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
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
|
7
|
-
* 3. VERCEL_URL -
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
96
|
-
- name:
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
echo "
|
|
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
|
|
89
|
+
const [page, { structuredData }] = await Promise.all([
|
|
90
|
+
getResolvedPage(path, false),
|
|
91
|
+
getLayoutData(),
|
|
92
|
+
])
|
|
70
93
|
|
|
71
|
-
return
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
-
<
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
10
|
+
run: bun biome check --max-diagnostics=200 --write {staged_files}
|
|
11
11
|
stage_fixed: true
|
|
12
12
|
typecheck:
|
|
13
13
|
glob: '*.{ts,tsx}'
|