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
|
@@ -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
|
|
7
|
-
* 3. VERCEL_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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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 {
|
|
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
|
|
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
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
-
<
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
|
10
|
+
run: bun biome check --max-diagnostics=200 --write {staged_files}
|
|
11
11
|
stage_fixed: true
|
|
12
12
|
typecheck:
|
|
13
13
|
glob: '*.{ts,tsx}'
|