@morphika/andami 0.5.11 → 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.
@@ -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 { allPageSlugsQuery, allProjectSlugsQuery } from "../lib/sanity/queries";
4
- import { getSiteConfig } from "../lib/config";
5
-
6
- const cfg = getSiteConfig();
7
-
8
- export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
9
- const baseUrl = cfg.domain;
10
-
11
- // Fetch all published slugs
12
- const [pageSlugs, projectSlugs] = await Promise.all([
13
- client.fetch<string[]>(allPageSlugsQuery).catch(() => [] as string[]),
14
- client.fetch<string[]>(allProjectSlugsQuery).catch(() => [] as string[]),
15
- ]);
16
-
17
- // Homepage
18
- const routes: MetadataRoute.Sitemap = [
19
- {
20
- url: baseUrl,
21
- lastModified: new Date(),
22
- changeFrequency: "weekly",
23
- priority: 1,
24
- },
25
- ];
26
-
27
- // Dynamic pages (About, Contact, Archive, etc.)
28
- for (const slug of pageSlugs) {
29
- routes.push({
30
- url: `${baseUrl}/${slug}`,
31
- lastModified: new Date(),
32
- changeFrequency: "monthly",
33
- priority: 0.8,
34
- });
35
- }
36
-
37
- // Project pages
38
- for (const slug of projectSlugs) {
39
- routes.push({
40
- url: `${baseUrl}/work/${slug}`,
41
- lastModified: new Date(),
42
- changeFrequency: "monthly",
43
- priority: 0.7,
44
- });
45
- }
46
-
47
- return routes;
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
- interface MetadataData {
7
- default_title: string;
8
- default_description: string;
9
- default_og_image: string;
10
- favicon_path: string;
11
- analytics_id: string;
12
- }
13
-
14
- interface MetadataEditorProps {
15
- initialData: MetadataData;
16
- onSave: (data: MetadataData) => Promise<void>;
17
- saving: boolean;
18
- }
19
-
20
- export default function MetadataEditor({
21
- initialData,
22
- onSave,
23
- saving,
24
- }: MetadataEditorProps) {
25
- const [title, setTitle] = useState(initialData.default_title);
26
- const [description, setDescription] = useState(
27
- initialData.default_description
28
- );
29
- const [ogImage, setOgImage] = useState(initialData.default_og_image);
30
- const [favicon, setFavicon] = useState(initialData.favicon_path);
31
- const [analyticsId, setAnalyticsId] = useState(initialData.analytics_id);
32
-
33
- useEffect(() => {
34
- setTitle(initialData.default_title);
35
- setDescription(initialData.default_description);
36
- setOgImage(initialData.default_og_image);
37
- setFavicon(initialData.favicon_path);
38
- setAnalyticsId(initialData.analytics_id);
39
- }, [initialData]);
40
-
41
- const handleSave = () => {
42
- onSave({
43
- default_title: title,
44
- default_description: description,
45
- default_og_image: ogImage,
46
- favicon_path: favicon,
47
- analytics_id: analyticsId,
48
- });
49
- };
50
-
51
- return (
52
- <section className="space-y-4">
53
- <div className="flex items-center justify-between border-b border-neutral-200 pb-2">
54
- <h2 className="text-base font-semibold text-neutral-800">
55
- Site Metadata
56
- </h2>
57
- </div>
58
-
59
- <p className="text-xs text-neutral-400">
60
- Default SEO metadata used when pages don&apos;t specify their own.
61
- Affects search results and social sharing.
62
- </p>
63
-
64
- {/* Title */}
65
- <div className="space-y-1">
66
- <label className="text-xs font-medium text-neutral-500">
67
- Default Page Title
68
- </label>
69
- <input
70
- type="text"
71
- value={title}
72
- onChange={(e) => setTitle(e.target.value)}
73
- placeholder={getSiteConfig().defaults.metaTitle}
74
- 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"
75
- />
76
- <p className="text-xs text-neutral-400">
77
- Shown in browser tabs and search results when no page-specific title is
78
- set.
79
- </p>
80
- </div>
81
-
82
- {/* Description */}
83
- <div className="space-y-1">
84
- <label className="text-xs font-medium text-neutral-500">
85
- Default Description
86
- </label>
87
- <textarea
88
- value={description}
89
- onChange={(e) => setDescription(e.target.value)}
90
- placeholder="Motion graphics studio based in Barcelona..."
91
- rows={3}
92
- 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"
93
- />
94
- <div className="flex justify-between">
95
- <p className="text-xs text-neutral-400">
96
- Used in search results and social media previews.
97
- </p>
98
- <span
99
- className={`text-xs ${
100
- description.length > 160 ? "text-red-400" : "text-neutral-400"
101
- }`}
102
- >
103
- {description.length}/160
104
- </span>
105
- </div>
106
- </div>
107
-
108
- {/* OG Image */}
109
- <div className="space-y-1">
110
- <label className="text-xs font-medium text-neutral-500">
111
- Default OG Image Path
112
- </label>
113
- <input
114
- type="text"
115
- value={ogImage}
116
- onChange={(e) => setOgImage(e.target.value)}
117
- placeholder="meta/og-image.jpg"
118
- 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"
119
- />
120
- <p className="text-xs text-neutral-400">
121
- Relative path to the default image shown when sharing on social media.
122
- Resolved via the asset seed URL.
123
- </p>
124
- </div>
125
-
126
- {/* Favicon */}
127
- <div className="space-y-1">
128
- <label className="text-xs font-medium text-neutral-500">
129
- Favicon Path
130
- </label>
131
- <input
132
- type="text"
133
- value={favicon}
134
- onChange={(e) => setFavicon(e.target.value)}
135
- placeholder="meta/favicon.ico"
136
- 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"
137
- />
138
- <p className="text-xs text-neutral-400">
139
- Relative path to the favicon. Resolved via the asset seed URL.
140
- </p>
141
- </div>
142
-
143
- {/* Analytics ID */}
144
- <div className="space-y-1">
145
- <label className="text-xs font-medium text-neutral-500">
146
- Analytics ID (optional)
147
- </label>
148
- <input
149
- type="text"
150
- value={analyticsId}
151
- onChange={(e) => setAnalyticsId(e.target.value)}
152
- placeholder="G-XXXXXXXXXX or plausible domain"
153
- 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"
154
- />
155
- <p className="text-xs text-neutral-400">
156
- Google Analytics measurement ID or Plausible domain. Leave empty to
157
- disable analytics.
158
- </p>
159
- </div>
160
-
161
- {/* Save */}
162
- <div className="flex justify-end">
163
- <button
164
- onClick={handleSave}
165
- disabled={saving}
166
- className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50"
167
- >
168
- {saving ? "Saving..." : "Save Metadata"}
169
- </button>
170
- </div>
171
- </section>
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&apos;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&apos;s display rules.
82
+ </p>
83
+ </div>
84
+ );
85
+ }
@@ -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
- <input
183
- type="text"
184
- value={metadata.seo_title || ""}
185
- onChange={(e) => setMetadata({ seo_title: e.target.value })}
186
- className={INPUT_CLASS}
187
- placeholder="Page title for search engines"
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
- <textarea
192
- value={metadata.seo_description || ""}
193
- onChange={(e) =>
194
- setMetadata({ seo_description: e.target.value })
195
- }
196
- rows={2}
197
- className={`${INPUT_CLASS} resize-y`}
198
- placeholder="Brief description for search results"
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 path">
202
- <input
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={(e) =>
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
  ? {
@@ -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
  // ============================================
@@ -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
@@ -6,4 +6,4 @@
6
6
  * Exposed as a plain constant so it can be imported without reading
7
7
  * package.json at runtime.
8
8
  */
9
- export const ANDAMI_VERSION = "0.5.11";
9
+ export const ANDAMI_VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.5.11",
3
+ "version": "0.6.0",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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({