@morphika/andami 0.5.10 → 0.6.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/app/(site)/[slug]/page.tsx +2 -0
- package/app/(site)/work/[slug]/page.tsx +2 -0
- package/app/sitemap.ts +66 -48
- package/components/admin/MetadataEditor.tsx +188 -173
- package/components/admin/SERPPreview.tsx +85 -0
- package/components/blocks/SectionV2Renderer.tsx +9 -2
- package/components/builder/settings-panel/PageSettings.tsx +79 -23
- package/lib/builder/serializer/serializers.ts +1 -0
- package/lib/sanity/queries.ts +18 -0
- package/lib/sanity/types.ts +2 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/page.ts +7 -0
|
@@ -49,11 +49,13 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
49
49
|
const ogImagePath = page.metadata?.og_image_path || page.thumbnail_path;
|
|
50
50
|
const ogImage = ogImagePath ? assetUrl(ogImagePath) : undefined;
|
|
51
51
|
const url = `${cfg.domain}/${slug}`;
|
|
52
|
+
const noindex = page.metadata?.noindex === true;
|
|
52
53
|
|
|
53
54
|
return {
|
|
54
55
|
title,
|
|
55
56
|
description,
|
|
56
57
|
alternates: { canonical: url },
|
|
58
|
+
...(noindex && { robots: { index: false, follow: false } }),
|
|
57
59
|
openGraph: {
|
|
58
60
|
title,
|
|
59
61
|
description,
|
|
@@ -49,11 +49,13 @@ export async function generateMetadata({ params }: ProjectPageProps): Promise<Me
|
|
|
49
49
|
const ogImagePath = page.metadata?.og_image_path || page.thumbnail_path;
|
|
50
50
|
const ogImage = ogImagePath ? assetUrl(ogImagePath) : undefined;
|
|
51
51
|
const url = `${cfg.domain}/work/${slug}`;
|
|
52
|
+
const noindex = page.metadata?.noindex === true;
|
|
52
53
|
|
|
53
54
|
return {
|
|
54
55
|
title,
|
|
55
56
|
description,
|
|
56
57
|
alternates: { canonical: url },
|
|
58
|
+
...(noindex && { robots: { index: false, follow: false } }),
|
|
57
59
|
openGraph: {
|
|
58
60
|
title,
|
|
59
61
|
description,
|
package/app/sitemap.ts
CHANGED
|
@@ -1,48 +1,66 @@
|
|
|
1
|
-
import type { MetadataRoute } from "next";
|
|
2
|
-
import { client } from "../lib/sanity/client";
|
|
3
|
-
import {
|
|
4
|
-
import { getSiteConfig } from "../lib/config";
|
|
5
|
-
|
|
6
|
-
const cfg = getSiteConfig();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
url:
|
|
41
|
-
lastModified:
|
|
42
|
-
changeFrequency: "
|
|
43
|
-
priority:
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { client } from "../lib/sanity/client";
|
|
3
|
+
import { allPagesForSitemapQuery, allProjectsForSitemapQuery } from "../lib/sanity/queries";
|
|
4
|
+
import { getSiteConfig } from "../lib/config";
|
|
5
|
+
|
|
6
|
+
const cfg = getSiteConfig();
|
|
7
|
+
|
|
8
|
+
interface SitemapEntry {
|
|
9
|
+
slug: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* sitemap.xml — Dynamic generation from Sanity content.
|
|
15
|
+
*
|
|
16
|
+
* Each entry's <lastmod> uses Sanity's _updatedAt field (not the build time),
|
|
17
|
+
* which is the correct SEO signal: Google deprioritizes sitemaps with all
|
|
18
|
+
* identical timestamps. Pages/projects with metadata.noindex == true are
|
|
19
|
+
* excluded at the GROQ level (see lib/sanity/queries.ts).
|
|
20
|
+
*/
|
|
21
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
22
|
+
const baseUrl = cfg.domain;
|
|
23
|
+
|
|
24
|
+
const [pages, projects] = await Promise.all([
|
|
25
|
+
client.fetch<SitemapEntry[]>(allPagesForSitemapQuery).catch(() => [] as SitemapEntry[]),
|
|
26
|
+
client.fetch<SitemapEntry[]>(allProjectsForSitemapQuery).catch(() => [] as SitemapEntry[]),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Homepage — lastModified derived from the most recent page or project update
|
|
30
|
+
const allTimestamps = [
|
|
31
|
+
...pages.map((p) => p.updatedAt),
|
|
32
|
+
...projects.map((p) => p.updatedAt),
|
|
33
|
+
].filter(Boolean);
|
|
34
|
+
const homeLastMod = allTimestamps.length > 0
|
|
35
|
+
? new Date(Math.max(...allTimestamps.map((t) => new Date(t).getTime())))
|
|
36
|
+
: new Date();
|
|
37
|
+
|
|
38
|
+
const routes: MetadataRoute.Sitemap = [
|
|
39
|
+
{
|
|
40
|
+
url: baseUrl,
|
|
41
|
+
lastModified: homeLastMod,
|
|
42
|
+
changeFrequency: "weekly",
|
|
43
|
+
priority: 1,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const { slug, updatedAt } of pages) {
|
|
48
|
+
routes.push({
|
|
49
|
+
url: `${baseUrl}/${slug}`,
|
|
50
|
+
lastModified: updatedAt ? new Date(updatedAt) : new Date(),
|
|
51
|
+
changeFrequency: "monthly",
|
|
52
|
+
priority: 0.8,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const { slug, updatedAt } of projects) {
|
|
57
|
+
routes.push({
|
|
58
|
+
url: `${baseUrl}/work/${slug}`,
|
|
59
|
+
lastModified: updatedAt ? new Date(updatedAt) : new Date(),
|
|
60
|
+
changeFrequency: "monthly",
|
|
61
|
+
priority: 0.7,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return routes;
|
|
66
|
+
}
|
|
@@ -1,173 +1,188 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect } from "react";
|
|
4
|
-
import { getSiteConfig } from "../../lib/config";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const [
|
|
31
|
-
const [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { getSiteConfig } from "../../lib/config";
|
|
5
|
+
import { AssetPathInput } from "../builder/editors/shared";
|
|
6
|
+
import SERPPreview from "./SERPPreview";
|
|
7
|
+
|
|
8
|
+
const TITLE_LIMIT = 60;
|
|
9
|
+
const DESCRIPTION_LIMIT = 160;
|
|
10
|
+
|
|
11
|
+
interface MetadataData {
|
|
12
|
+
default_title: string;
|
|
13
|
+
default_description: string;
|
|
14
|
+
default_og_image: string;
|
|
15
|
+
favicon_path: string;
|
|
16
|
+
analytics_id: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MetadataEditorProps {
|
|
20
|
+
initialData: MetadataData;
|
|
21
|
+
onSave: (data: MetadataData) => Promise<void>;
|
|
22
|
+
saving: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function MetadataEditor({
|
|
26
|
+
initialData,
|
|
27
|
+
onSave,
|
|
28
|
+
saving,
|
|
29
|
+
}: MetadataEditorProps) {
|
|
30
|
+
const [title, setTitle] = useState(initialData.default_title);
|
|
31
|
+
const [description, setDescription] = useState(
|
|
32
|
+
initialData.default_description
|
|
33
|
+
);
|
|
34
|
+
const [ogImage, setOgImage] = useState(initialData.default_og_image);
|
|
35
|
+
const [favicon, setFavicon] = useState(initialData.favicon_path);
|
|
36
|
+
const [analyticsId, setAnalyticsId] = useState(initialData.analytics_id);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setTitle(initialData.default_title);
|
|
40
|
+
setDescription(initialData.default_description);
|
|
41
|
+
setOgImage(initialData.default_og_image);
|
|
42
|
+
setFavicon(initialData.favicon_path);
|
|
43
|
+
setAnalyticsId(initialData.analytics_id);
|
|
44
|
+
}, [initialData]);
|
|
45
|
+
|
|
46
|
+
const handleSave = () => {
|
|
47
|
+
onSave({
|
|
48
|
+
default_title: title,
|
|
49
|
+
default_description: description,
|
|
50
|
+
default_og_image: ogImage,
|
|
51
|
+
favicon_path: favicon,
|
|
52
|
+
analytics_id: analyticsId,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const titleOver = title.length > TITLE_LIMIT;
|
|
57
|
+
const descOver = description.length > DESCRIPTION_LIMIT;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section className="space-y-4">
|
|
61
|
+
<div className="flex items-center justify-between border-b border-neutral-200 pb-2">
|
|
62
|
+
<h2 className="text-base font-semibold text-neutral-800">
|
|
63
|
+
Site Metadata
|
|
64
|
+
</h2>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<p className="text-xs text-neutral-400">
|
|
68
|
+
Default SEO metadata used when pages don't specify their own.
|
|
69
|
+
Affects search results and social sharing.
|
|
70
|
+
</p>
|
|
71
|
+
|
|
72
|
+
{/* Title */}
|
|
73
|
+
<div className="space-y-1">
|
|
74
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
75
|
+
Default Page Title
|
|
76
|
+
</label>
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
value={title}
|
|
80
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
81
|
+
placeholder={getSiteConfig().defaults.metaTitle}
|
|
82
|
+
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none"
|
|
83
|
+
/>
|
|
84
|
+
<div className="flex justify-between">
|
|
85
|
+
<p className="text-xs text-neutral-400">
|
|
86
|
+
Shown in browser tabs and search results when no page-specific title
|
|
87
|
+
is set.
|
|
88
|
+
</p>
|
|
89
|
+
<span
|
|
90
|
+
className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}
|
|
91
|
+
>
|
|
92
|
+
{title.length}/{TITLE_LIMIT}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Description */}
|
|
98
|
+
<div className="space-y-1">
|
|
99
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
100
|
+
Default Description
|
|
101
|
+
</label>
|
|
102
|
+
<textarea
|
|
103
|
+
value={description}
|
|
104
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
105
|
+
placeholder="Motion graphics studio based in Barcelona..."
|
|
106
|
+
rows={3}
|
|
107
|
+
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none resize-none"
|
|
108
|
+
/>
|
|
109
|
+
<div className="flex justify-between">
|
|
110
|
+
<p className="text-xs text-neutral-400">
|
|
111
|
+
Used in search results and social media previews.
|
|
112
|
+
</p>
|
|
113
|
+
<span
|
|
114
|
+
className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}
|
|
115
|
+
>
|
|
116
|
+
{description.length}/{DESCRIPTION_LIMIT}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* SERP Preview */}
|
|
122
|
+
<SERPPreview title={title} description={description} />
|
|
123
|
+
|
|
124
|
+
{/* OG Image */}
|
|
125
|
+
<div className="space-y-1">
|
|
126
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
127
|
+
Default OG Image
|
|
128
|
+
</label>
|
|
129
|
+
<AssetPathInput
|
|
130
|
+
value={ogImage}
|
|
131
|
+
onChange={setOgImage}
|
|
132
|
+
placeholder="meta/og-image.jpg"
|
|
133
|
+
filterType="image"
|
|
134
|
+
/>
|
|
135
|
+
<p className="text-xs text-neutral-400">
|
|
136
|
+
Default image used when sharing on social media (Facebook, Twitter,
|
|
137
|
+
LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Favicon */}
|
|
142
|
+
<div className="space-y-1">
|
|
143
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
144
|
+
Favicon
|
|
145
|
+
</label>
|
|
146
|
+
<AssetPathInput
|
|
147
|
+
value={favicon}
|
|
148
|
+
onChange={setFavicon}
|
|
149
|
+
placeholder="meta/favicon.ico"
|
|
150
|
+
filterType="image"
|
|
151
|
+
/>
|
|
152
|
+
<p className="text-xs text-neutral-400">
|
|
153
|
+
Icon shown in browser tabs and bookmarks. Square images recommended
|
|
154
|
+
(16×16 .ico or 32×32+ .png/.svg).
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Analytics ID */}
|
|
159
|
+
<div className="space-y-1">
|
|
160
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
161
|
+
Analytics ID (optional)
|
|
162
|
+
</label>
|
|
163
|
+
<input
|
|
164
|
+
type="text"
|
|
165
|
+
value={analyticsId}
|
|
166
|
+
onChange={(e) => setAnalyticsId(e.target.value)}
|
|
167
|
+
placeholder="G-XXXXXXXXXX or plausible domain"
|
|
168
|
+
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none"
|
|
169
|
+
/>
|
|
170
|
+
<p className="text-xs text-neutral-400">
|
|
171
|
+
Google Analytics measurement ID or Plausible domain. Leave empty to
|
|
172
|
+
disable analytics.
|
|
173
|
+
</p>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Save */}
|
|
177
|
+
<div className="flex justify-end">
|
|
178
|
+
<button
|
|
179
|
+
onClick={handleSave}
|
|
180
|
+
disabled={saving}
|
|
181
|
+
className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50"
|
|
182
|
+
>
|
|
183
|
+
{saving ? "Saving..." : "Save Metadata"}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</section>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { getSiteConfig } from "../../lib/config";
|
|
4
|
+
|
|
5
|
+
interface SERPPreviewProps {
|
|
6
|
+
/** Page or site title — what shows as the SERP heading. */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Description / meta description — what shows below the URL. */
|
|
9
|
+
description: string;
|
|
10
|
+
/** Optional path portion of the URL after the domain (e.g. "about" or "work/project-x"). */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** Optional fallback title when `title` is empty (e.g. site config default). */
|
|
13
|
+
fallbackTitle?: string;
|
|
14
|
+
/** Optional fallback description when `description` is empty. */
|
|
15
|
+
fallbackDescription?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TITLE_LIMIT = 60;
|
|
19
|
+
const DESCRIPTION_LIMIT = 160;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Google SERP preview — renders an approximation of how a result will look in
|
|
23
|
+
* search results. Truncates title at 60 chars and description at 160 chars
|
|
24
|
+
* (Google's typical display limits). Falls back to site-config defaults when
|
|
25
|
+
* the page-level field is empty.
|
|
26
|
+
*/
|
|
27
|
+
export default function SERPPreview({
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
path = "",
|
|
31
|
+
fallbackTitle,
|
|
32
|
+
fallbackDescription,
|
|
33
|
+
}: SERPPreviewProps) {
|
|
34
|
+
const cfg = getSiteConfig();
|
|
35
|
+
const effectiveTitle = title.trim() || fallbackTitle?.trim() || cfg.defaults.metaTitle || cfg.name;
|
|
36
|
+
const effectiveDescription =
|
|
37
|
+
description.trim() ||
|
|
38
|
+
fallbackDescription?.trim() ||
|
|
39
|
+
cfg.defaults.metaDescription ||
|
|
40
|
+
"";
|
|
41
|
+
|
|
42
|
+
const displayTitle =
|
|
43
|
+
effectiveTitle.length > TITLE_LIMIT
|
|
44
|
+
? effectiveTitle.slice(0, TITLE_LIMIT - 1).trimEnd() + "…"
|
|
45
|
+
: effectiveTitle;
|
|
46
|
+
|
|
47
|
+
const displayDescription =
|
|
48
|
+
effectiveDescription.length > DESCRIPTION_LIMIT
|
|
49
|
+
? effectiveDescription.slice(0, DESCRIPTION_LIMIT - 1).trimEnd() + "…"
|
|
50
|
+
: effectiveDescription;
|
|
51
|
+
|
|
52
|
+
// Format URL like Google does: domain › breadcrumb
|
|
53
|
+
const domain = cfg.domain.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
54
|
+
const segments = path.split("/").filter(Boolean);
|
|
55
|
+
const breadcrumb = segments.length > 0 ? ` › ${segments.join(" › ")}` : "";
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-1.5">
|
|
59
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
60
|
+
Google Search Preview
|
|
61
|
+
</label>
|
|
62
|
+
<div className="rounded-xl border border-neutral-200 bg-white px-4 py-3 font-[system-ui]">
|
|
63
|
+
<div className="text-xs text-neutral-600 truncate">
|
|
64
|
+
{domain}
|
|
65
|
+
<span className="text-neutral-400">{breadcrumb}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="mt-1 text-[18px] leading-snug text-[#1a0dab] hover:underline cursor-default truncate">
|
|
68
|
+
{displayTitle || (
|
|
69
|
+
<span className="italic text-neutral-400">No title set</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
<div className="mt-1 text-[13px] leading-snug text-neutral-700 line-clamp-2">
|
|
73
|
+
{displayDescription || (
|
|
74
|
+
<span className="italic text-neutral-400">
|
|
75
|
+
No description set — search engines may auto-generate one from page content.
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<p className="text-[11px] text-neutral-400">
|
|
81
|
+
Approximation of how this page will look in Google search results. Actual rendering varies by device and Google's display rules.
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -374,12 +374,19 @@ export default function SectionV2Renderer({ section, pageEnterAnimation, fillHei
|
|
|
374
374
|
</section>
|
|
375
375
|
);
|
|
376
376
|
|
|
377
|
-
// Section-level enter animation (without stagger — wraps entire section)
|
|
377
|
+
// Section-level enter animation (without stagger — wraps entire section).
|
|
378
|
+
// When fillHeight is on, propagate height + flex through the animation
|
|
379
|
+
// wrapper so the section + grid + column can stretch to the slide's 100vh.
|
|
380
|
+
// Without this, the wrapper's natural height (= content) collapses the chain
|
|
381
|
+
// and `align_v` has no spare space to apply.
|
|
378
382
|
let result: React.ReactNode = sectionContent;
|
|
379
383
|
|
|
380
384
|
if (hasAnimation && !staggerEnabled && resolvedSectionEnter) {
|
|
381
385
|
result = (
|
|
382
|
-
<EnterAnimationWrapper
|
|
386
|
+
<EnterAnimationWrapper
|
|
387
|
+
config={resolvedSectionEnter}
|
|
388
|
+
style={fillHeight ? { height: "100%", display: "flex", flexDirection: "column" } : undefined}
|
|
389
|
+
>
|
|
383
390
|
{result}
|
|
384
391
|
</EnterAnimationWrapper>
|
|
385
392
|
);
|
|
@@ -21,9 +21,14 @@ import {
|
|
|
21
21
|
SettingsField,
|
|
22
22
|
SettingsSection,
|
|
23
23
|
INPUT_CLASS,
|
|
24
|
+
AssetPathInput,
|
|
24
25
|
} from "../editors/shared";
|
|
25
26
|
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
26
27
|
import { serializeColorField, parseColorField } from "../../../lib/color-utils";
|
|
28
|
+
import SERPPreview from "../../admin/SERPPreview";
|
|
29
|
+
|
|
30
|
+
const SEO_TITLE_LIMIT = 60;
|
|
31
|
+
const SEO_DESCRIPTION_LIMIT = 160;
|
|
27
32
|
|
|
28
33
|
/** Convert a title to a URL-safe slug. Handles unicode (é, ñ, ü, etc.). */
|
|
29
34
|
function slugify(text: string): string {
|
|
@@ -174,39 +179,90 @@ export default function PageSettings() {
|
|
|
174
179
|
export function PageSeoSettings() {
|
|
175
180
|
const metadata = useBuilderStore((s) => s.metadata);
|
|
176
181
|
const setMetadata = useBuilderStore((s) => s.setMetadata);
|
|
182
|
+
const pageSlug = useBuilderStore((s) => s.pageSlug);
|
|
183
|
+
const pageType = useBuilderStore((s) => s.pageType);
|
|
184
|
+
const pageTitle = useBuilderStore((s) => s.pageTitle);
|
|
185
|
+
|
|
186
|
+
const seoTitle = metadata.seo_title || "";
|
|
187
|
+
const seoDescription = metadata.seo_description || "";
|
|
188
|
+
const titleOver = seoTitle.length > SEO_TITLE_LIMIT;
|
|
189
|
+
const descOver = seoDescription.length > SEO_DESCRIPTION_LIMIT;
|
|
190
|
+
|
|
191
|
+
// Build the path portion of the URL for SERP preview
|
|
192
|
+
const previewPath = pageType === "project" ? `work/${pageSlug}` : pageSlug;
|
|
177
193
|
|
|
178
194
|
return (
|
|
179
195
|
<>
|
|
180
196
|
<SettingsSection title="SEO" defaultOpen icon={<SEOIcon />}>
|
|
181
197
|
<SettingsField label="SEO Title">
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
<div className="space-y-1">
|
|
199
|
+
<input
|
|
200
|
+
type="text"
|
|
201
|
+
value={seoTitle}
|
|
202
|
+
onChange={(e) => setMetadata({ seo_title: e.target.value })}
|
|
203
|
+
className={INPUT_CLASS}
|
|
204
|
+
placeholder={pageTitle || "Page title for search engines"}
|
|
205
|
+
/>
|
|
206
|
+
<div className="flex justify-end">
|
|
207
|
+
<span
|
|
208
|
+
className={`text-[10px] ${titleOver ? "text-red-400" : "text-neutral-400"}`}
|
|
209
|
+
>
|
|
210
|
+
{seoTitle.length}/{SEO_TITLE_LIMIT}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
189
214
|
</SettingsField>
|
|
190
215
|
<SettingsField label="Description">
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
setMetadata({ seo_description: e.target.value })
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
216
|
+
<div className="space-y-1">
|
|
217
|
+
<textarea
|
|
218
|
+
value={seoDescription}
|
|
219
|
+
onChange={(e) => setMetadata({ seo_description: e.target.value })}
|
|
220
|
+
rows={2}
|
|
221
|
+
className={`${INPUT_CLASS} resize-y`}
|
|
222
|
+
placeholder="Brief description for search results"
|
|
223
|
+
/>
|
|
224
|
+
<div className="flex justify-end">
|
|
225
|
+
<span
|
|
226
|
+
className={`text-[10px] ${descOver ? "text-red-400" : "text-neutral-400"}`}
|
|
227
|
+
>
|
|
228
|
+
{seoDescription.length}/{SEO_DESCRIPTION_LIMIT}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
200
232
|
</SettingsField>
|
|
201
|
-
<SettingsField label="OG Image" hint="Social sharing image
|
|
202
|
-
<
|
|
203
|
-
type="text"
|
|
233
|
+
<SettingsField label="OG Image" hint="Social sharing image (1200×630 recommended)">
|
|
234
|
+
<AssetPathInput
|
|
204
235
|
value={metadata.og_image_path || ""}
|
|
205
|
-
onChange={(
|
|
206
|
-
setMetadata({ og_image_path: e.target.value })
|
|
207
|
-
}
|
|
208
|
-
className={INPUT_CLASS}
|
|
236
|
+
onChange={(v) => setMetadata({ og_image_path: v })}
|
|
209
237
|
placeholder="og/page-image.jpg"
|
|
238
|
+
filterType="image"
|
|
239
|
+
/>
|
|
240
|
+
</SettingsField>
|
|
241
|
+
<SettingsField
|
|
242
|
+
label="Hide from Search"
|
|
243
|
+
hint="Excludes this page from sitemap.xml and emits robots: noindex,nofollow. Useful for thank-you pages, drafts, internal routes."
|
|
244
|
+
>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => setMetadata({ noindex: !metadata.noindex })}
|
|
248
|
+
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
249
|
+
metadata.noindex ? "bg-[#3580f9]" : "bg-neutral-300"
|
|
250
|
+
}`}
|
|
251
|
+
aria-label={metadata.noindex ? "Disable noindex" : "Enable noindex"}
|
|
252
|
+
>
|
|
253
|
+
<span
|
|
254
|
+
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
|
255
|
+
metadata.noindex ? "translate-x-[18px]" : "translate-x-[3px]"
|
|
256
|
+
}`}
|
|
257
|
+
/>
|
|
258
|
+
</button>
|
|
259
|
+
</SettingsField>
|
|
260
|
+
<SettingsField>
|
|
261
|
+
<SERPPreview
|
|
262
|
+
title={seoTitle}
|
|
263
|
+
description={seoDescription}
|
|
264
|
+
path={previewPath}
|
|
265
|
+
fallbackTitle={pageTitle}
|
|
210
266
|
/>
|
|
211
267
|
</SettingsField>
|
|
212
268
|
</SettingsSection>
|
|
@@ -368,6 +368,7 @@ export function stateToDocument(
|
|
|
368
368
|
seo_title: state.metadata.seo_title,
|
|
369
369
|
seo_description: state.metadata.seo_description,
|
|
370
370
|
og_image_path: state.metadata.og_image_path,
|
|
371
|
+
noindex: state.metadata.noindex,
|
|
371
372
|
}),
|
|
372
373
|
page_settings: hasPageSettings
|
|
373
374
|
? {
|
package/lib/sanity/queries.ts
CHANGED
|
@@ -227,6 +227,15 @@ export const allPageSlugsQuery = groq`
|
|
|
227
227
|
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
228
228
|
`;
|
|
229
229
|
|
|
230
|
+
// All non-project pages for sitemap.xml — includes _updatedAt for accurate <lastmod>
|
|
231
|
+
// and respects metadata.noindex to exclude hidden pages from the sitemap.
|
|
232
|
+
export const allPagesForSitemapQuery = groq`
|
|
233
|
+
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] {
|
|
234
|
+
"slug": slug.current,
|
|
235
|
+
"updatedAt": _updatedAt
|
|
236
|
+
}
|
|
237
|
+
`;
|
|
238
|
+
|
|
230
239
|
// Get a published project page by slug (for public /work/[slug] route)
|
|
231
240
|
export const publishedProjectBySlugQuery = groq`
|
|
232
241
|
*[_type == "page" && slug.current == $slug && page_type == "project" && draft_mode != true][0] {
|
|
@@ -249,6 +258,15 @@ export const allProjectSlugsQuery = groq`
|
|
|
249
258
|
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
250
259
|
`;
|
|
251
260
|
|
|
261
|
+
// All published projects for sitemap.xml — includes _updatedAt for accurate <lastmod>
|
|
262
|
+
// and respects metadata.noindex to exclude hidden projects from the sitemap.
|
|
263
|
+
export const allProjectsForSitemapQuery = groq`
|
|
264
|
+
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] {
|
|
265
|
+
"slug": slug.current,
|
|
266
|
+
"updatedAt": _updatedAt
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
|
|
252
270
|
// ============================================
|
|
253
271
|
// ASSET REGISTRY
|
|
254
272
|
// ============================================
|
package/lib/sanity/types.ts
CHANGED
|
@@ -821,6 +821,8 @@ export interface PageMetadata {
|
|
|
821
821
|
seo_title?: string;
|
|
822
822
|
seo_description?: string;
|
|
823
823
|
og_image_path?: string;
|
|
824
|
+
/** When true, page is excluded from sitemap.xml and emits robots: noindex,nofollow. */
|
|
825
|
+
noindex?: boolean;
|
|
824
826
|
}
|
|
825
827
|
|
|
826
828
|
export interface Page {
|
package/lib/version.ts
CHANGED
package/package.json
CHANGED
package/sanity/schemas/page.ts
CHANGED
|
@@ -66,6 +66,13 @@ export default defineType({
|
|
|
66
66
|
type: "string",
|
|
67
67
|
description: "Relative path to the Open Graph image",
|
|
68
68
|
}),
|
|
69
|
+
defineField({
|
|
70
|
+
name: "noindex",
|
|
71
|
+
title: "Hide from Search Engines",
|
|
72
|
+
type: "boolean",
|
|
73
|
+
description: "When enabled, this page is excluded from sitemap.xml and emits robots: noindex,nofollow. Use for thank-you pages, drafts, internal-only routes.",
|
|
74
|
+
initialValue: false,
|
|
75
|
+
}),
|
|
69
76
|
],
|
|
70
77
|
}),
|
|
71
78
|
defineField({
|