@morphika/andami 0.5.11 → 0.8.1
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)/layout.tsx +4 -15
- package/app/(site)/work/[slug]/page.tsx +4 -0
- package/app/admin/settings/page.tsx +136 -132
- package/app/api/admin/settings/route.ts +69 -0
- package/app/llms.txt/route.ts +142 -0
- package/app/robots.ts +73 -54
- package/app/sitemap.ts +85 -48
- package/components/admin/MetadataEditor.tsx +487 -173
- package/components/admin/SERPPreview.tsx +85 -0
- package/components/builder/settings-panel/PageSettings.tsx +79 -23
- package/components/seo/JsonLd.tsx +50 -0
- package/components/seo/ProjectJsonLd.tsx +44 -0
- package/components/seo/SiteSeoHead.tsx +66 -0
- package/lib/builder/serializer/serializers.ts +1 -0
- package/lib/sanity/queries.ts +35 -0
- package/lib/sanity/types.ts +30 -0
- package/lib/seo/jsonld.ts +174 -0
- package/lib/seo/site-settings.ts +37 -0
- package/lib/version.ts +1 -1
- package/package.json +2 -1
- package/sanity/schemas/page.ts +7 -0
- package/sanity/schemas/siteSettings.ts +102 -0
- package/site/llms-txt.ts +10 -0
|
@@ -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
|
+
}
|
|
@@ -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>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonLd — Inject one or more JSON-LD structured-data objects as
|
|
3
|
+
* <script type="application/ld+json"> tags.
|
|
4
|
+
*
|
|
5
|
+
* Accepts either a single object or an array. Null/undefined entries are
|
|
6
|
+
* filtered out, so callers can pass build results directly:
|
|
7
|
+
*
|
|
8
|
+
* <JsonLd data={[
|
|
9
|
+
* buildOrganizationJsonLd({ ... }),
|
|
10
|
+
* buildWebSiteJsonLd({ ... }),
|
|
11
|
+
* ]} />
|
|
12
|
+
*
|
|
13
|
+
* Server-rendered safely — uses `dangerouslySetInnerHTML` with a
|
|
14
|
+
* JSON.stringify that guards against XSS via `</script>` injection.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type LdRecord = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
interface JsonLdProps {
|
|
20
|
+
data: LdRecord | null | undefined | Array<LdRecord | null | undefined>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Escape `</` sequences inside JSON to prevent breaking out of the
|
|
25
|
+
* <script> tag. Standard hardening per OWASP recommendation.
|
|
26
|
+
*/
|
|
27
|
+
function safeStringify(obj: LdRecord): string {
|
|
28
|
+
return JSON.stringify(obj).replace(/<\/(script)/gi, "<\\/$1");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function JsonLd({ data }: JsonLdProps) {
|
|
32
|
+
const items = (Array.isArray(data) ? data : [data]).filter(
|
|
33
|
+
(item): item is LdRecord => item != null && typeof item === "object"
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (items.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{items.map((item, i) => (
|
|
41
|
+
<script
|
|
42
|
+
key={i}
|
|
43
|
+
type="application/ld+json"
|
|
44
|
+
// eslint-disable-next-line react/no-danger
|
|
45
|
+
dangerouslySetInnerHTML={{ __html: safeStringify(item) }}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectJsonLd — Server component that emits a `CreativeWork` JSON-LD
|
|
3
|
+
* record for a project page (`/work/[slug]`). Rendered inline at the top
|
|
4
|
+
* of the project page so search engines see the structured data alongside
|
|
5
|
+
* the HTML body.
|
|
6
|
+
*
|
|
7
|
+
* Falls back to siteSettings.default_author for the author field when the
|
|
8
|
+
* page doesn't specify one. Returns null when the page has insufficient
|
|
9
|
+
* data — search engines tolerate partial schemas, but emitting an empty
|
|
10
|
+
* CreativeWork would be noise.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import JsonLd from "./JsonLd";
|
|
14
|
+
import { buildCreativeWorkJsonLd } from "../../lib/seo/jsonld";
|
|
15
|
+
import { getCachedSiteSettings, toAbsoluteUrl } from "../../lib/seo/site-settings";
|
|
16
|
+
import { getSiteConfig } from "../../lib/config";
|
|
17
|
+
import { assetUrl } from "../../lib/assets";
|
|
18
|
+
import type { Page } from "../../lib/sanity/types";
|
|
19
|
+
|
|
20
|
+
interface ProjectJsonLdProps {
|
|
21
|
+
page: Page;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default async function ProjectJsonLd({ page }: ProjectJsonLdProps) {
|
|
25
|
+
const settings = await getCachedSiteSettings();
|
|
26
|
+
const cfg = getSiteConfig();
|
|
27
|
+
|
|
28
|
+
const url = `${cfg.domain.replace(/\/$/, "")}/work/${page.slug?.current || ""}`;
|
|
29
|
+
const imageAbsolute = page.thumbnail_path
|
|
30
|
+
? toAbsoluteUrl(assetUrl(page.thumbnail_path), cfg.domain)
|
|
31
|
+
: undefined;
|
|
32
|
+
const authorName = settings?.default_author || cfg.name;
|
|
33
|
+
|
|
34
|
+
const ld = buildCreativeWorkJsonLd({
|
|
35
|
+
name: page.title,
|
|
36
|
+
url,
|
|
37
|
+
image: imageAbsolute,
|
|
38
|
+
datePublished: page.published_at,
|
|
39
|
+
description: page.metadata?.seo_description,
|
|
40
|
+
authorName,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return <JsonLd data={ld} />;
|
|
44
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SiteSeoHead — Server component that emits site-wide SEO surfaces:
|
|
3
|
+
*
|
|
4
|
+
* - JSON-LD `Organization` (logo + sameAs profiles → Google Knowledge Panel)
|
|
5
|
+
* - JSON-LD `WebSite` (basic site identity)
|
|
6
|
+
* - `<meta name="google-site-verification">` (when configured)
|
|
7
|
+
* - `<meta name="msvalidate.01">` for Bing Webmaster (when configured)
|
|
8
|
+
*
|
|
9
|
+
* Rendered inside the public site layout so every public page emits these.
|
|
10
|
+
* Admin and Studio routes are excluded (different layout subtree).
|
|
11
|
+
*
|
|
12
|
+
* Data source: siteSettings document in Sanity (cached via React `cache()`).
|
|
13
|
+
* Empty fields are omitted — partial schemas are tolerated by search engines.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import JsonLd from "./JsonLd";
|
|
17
|
+
import { buildOrganizationJsonLd, buildWebSiteJsonLd } from "../../lib/seo/jsonld";
|
|
18
|
+
import { getCachedSiteSettings, toAbsoluteUrl } from "../../lib/seo/site-settings";
|
|
19
|
+
import { getSiteConfig } from "../../lib/config";
|
|
20
|
+
import { assetUrl } from "../../lib/assets";
|
|
21
|
+
|
|
22
|
+
export default async function SiteSeoHead() {
|
|
23
|
+
const settings = await getCachedSiteSettings();
|
|
24
|
+
const cfg = getSiteConfig();
|
|
25
|
+
|
|
26
|
+
const logoAbsolute = settings?.org_logo
|
|
27
|
+
? toAbsoluteUrl(assetUrl(settings.org_logo), cfg.domain)
|
|
28
|
+
: undefined;
|
|
29
|
+
|
|
30
|
+
const description =
|
|
31
|
+
settings?.default_description || cfg.defaults.metaDescription || undefined;
|
|
32
|
+
|
|
33
|
+
const orgLd = buildOrganizationJsonLd({
|
|
34
|
+
name: cfg.name,
|
|
35
|
+
url: cfg.domain,
|
|
36
|
+
logo: logoAbsolute,
|
|
37
|
+
socialLinks: settings?.social_links,
|
|
38
|
+
description,
|
|
39
|
+
foundingYear: settings?.founding_year,
|
|
40
|
+
addressLocality: settings?.address_locality,
|
|
41
|
+
addressCountry: settings?.address_country,
|
|
42
|
+
contactEmail: settings?.contact_email,
|
|
43
|
+
keywords: settings?.keywords,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const siteLd = buildWebSiteJsonLd({
|
|
47
|
+
name: cfg.name,
|
|
48
|
+
url: cfg.domain,
|
|
49
|
+
description,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<JsonLd data={[orgLd, siteLd]} />
|
|
55
|
+
{settings?.verification_google && (
|
|
56
|
+
<meta
|
|
57
|
+
name="google-site-verification"
|
|
58
|
+
content={settings.verification_google}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
{settings?.verification_bing && (
|
|
62
|
+
<meta name="msvalidate.01" content={settings.verification_bing} />
|
|
63
|
+
)}
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -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,18 @@ 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 + llms.txt — includes _updatedAt for
|
|
231
|
+
// accurate <lastmod>, plus title and description for llms.txt entries.
|
|
232
|
+
// Respects metadata.noindex to exclude hidden pages.
|
|
233
|
+
export const allPagesForSitemapQuery = groq`
|
|
234
|
+
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] | order(is_home desc, title asc) {
|
|
235
|
+
"slug": slug.current,
|
|
236
|
+
"updatedAt": _updatedAt,
|
|
237
|
+
title,
|
|
238
|
+
"description": metadata.seo_description
|
|
239
|
+
}
|
|
240
|
+
`;
|
|
241
|
+
|
|
230
242
|
// Get a published project page by slug (for public /work/[slug] route)
|
|
231
243
|
export const publishedProjectBySlugQuery = groq`
|
|
232
244
|
*[_type == "page" && slug.current == $slug && page_type == "project" && draft_mode != true][0] {
|
|
@@ -249,6 +261,19 @@ export const allProjectSlugsQuery = groq`
|
|
|
249
261
|
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
250
262
|
`;
|
|
251
263
|
|
|
264
|
+
// All published projects for sitemap.xml — includes _updatedAt for accurate <lastmod>,
|
|
265
|
+
// thumbnail_path for image sitemap, and respects metadata.noindex.
|
|
266
|
+
export const allProjectsForSitemapQuery = groq`
|
|
267
|
+
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] {
|
|
268
|
+
"slug": slug.current,
|
|
269
|
+
"updatedAt": _updatedAt,
|
|
270
|
+
thumbnail_path,
|
|
271
|
+
title,
|
|
272
|
+
"description": metadata.seo_description,
|
|
273
|
+
published_at
|
|
274
|
+
}
|
|
275
|
+
`;
|
|
276
|
+
|
|
252
277
|
// ============================================
|
|
253
278
|
// ASSET REGISTRY
|
|
254
279
|
// ============================================
|
|
@@ -422,6 +447,16 @@ export const siteSettingsQuery = groq`
|
|
|
422
447
|
default_og_image,
|
|
423
448
|
favicon_path,
|
|
424
449
|
analytics_id,
|
|
450
|
+
org_logo,
|
|
451
|
+
default_author,
|
|
452
|
+
social_links,
|
|
453
|
+
founding_year,
|
|
454
|
+
address_locality,
|
|
455
|
+
address_country,
|
|
456
|
+
contact_email,
|
|
457
|
+
keywords,
|
|
458
|
+
verification_google,
|
|
459
|
+
verification_bing,
|
|
425
460
|
seed_url,
|
|
426
461
|
last_scanned_at,
|
|
427
462
|
asset_status
|
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 {
|
|
@@ -1029,6 +1031,14 @@ export interface AssetRegistry {
|
|
|
1029
1031
|
// Site Settings
|
|
1030
1032
|
// ============================================
|
|
1031
1033
|
|
|
1034
|
+
export interface SocialLink {
|
|
1035
|
+
_key?: string;
|
|
1036
|
+
/** Optional display label (e.g. "Instagram", "Behance"). For admin organization only. */
|
|
1037
|
+
label?: string;
|
|
1038
|
+
/** Profile URL — emitted as schema.org sameAs[]. */
|
|
1039
|
+
url: string;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1032
1042
|
export interface SiteSettings {
|
|
1033
1043
|
nav_items?: NavItem[];
|
|
1034
1044
|
nav_design?: NavDesign;
|
|
@@ -1038,6 +1048,26 @@ export interface SiteSettings {
|
|
|
1038
1048
|
default_og_image?: string;
|
|
1039
1049
|
favicon_path?: string;
|
|
1040
1050
|
analytics_id?: string;
|
|
1051
|
+
/** Asset path to the organization logo used by JSON-LD `Organization.logo`. */
|
|
1052
|
+
org_logo?: string;
|
|
1053
|
+
/** Default author for project pages (used in `CreativeWork.author` when not overridden). */
|
|
1054
|
+
default_author?: string;
|
|
1055
|
+
/** Social profile URLs emitted as schema.org `sameAs[]`. */
|
|
1056
|
+
social_links?: SocialLink[];
|
|
1057
|
+
/** Year the organization was founded. Emitted as schema.org `foundingDate`. */
|
|
1058
|
+
founding_year?: number;
|
|
1059
|
+
/** City/town (schema.org `address.addressLocality`). */
|
|
1060
|
+
address_locality?: string;
|
|
1061
|
+
/** Country name or ISO code (schema.org `address.addressCountry`). */
|
|
1062
|
+
address_country?: string;
|
|
1063
|
+
/** Public contact email (schema.org `contactPoint.email`). */
|
|
1064
|
+
contact_email?: string;
|
|
1065
|
+
/** Areas of expertise — short tags emitted as schema.org `knowsAbout[]`. */
|
|
1066
|
+
keywords?: string[];
|
|
1067
|
+
/** Google Search Console verification code (HTML tag method). */
|
|
1068
|
+
verification_google?: string;
|
|
1069
|
+
/** Bing Webmaster Tools verification code. */
|
|
1070
|
+
verification_bing?: string;
|
|
1041
1071
|
seed_url?: string;
|
|
1042
1072
|
last_scanned_at?: string;
|
|
1043
1073
|
asset_status?: "ok" | "scanning" | "error";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD structured data builders for schema.org types.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — each takes input data and returns a plain JSON-LD object
|
|
5
|
+
* ready to be serialized into a <script type="application/ld+json"> tag.
|
|
6
|
+
*
|
|
7
|
+
* Empty/missing fields are simply omitted from the output (search engines
|
|
8
|
+
* tolerate partial schemas). All builders return `null` when the minimum
|
|
9
|
+
* required fields aren't present, so callers can skip emission entirely.
|
|
10
|
+
*
|
|
11
|
+
* Schema reference: https://schema.org/
|
|
12
|
+
* Google guidance: https://developers.google.com/search/docs/appearance/structured-data
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SocialLink } from "../sanity/types";
|
|
16
|
+
|
|
17
|
+
// ============================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
export interface OrganizationInput {
|
|
22
|
+
name: string;
|
|
23
|
+
url: string;
|
|
24
|
+
/** Absolute URL to the organization logo (NOT a relative asset path). */
|
|
25
|
+
logo?: string;
|
|
26
|
+
/** Profile URLs (filtered & trimmed). */
|
|
27
|
+
socialLinks?: SocialLink[];
|
|
28
|
+
/** Optional descriptive tagline for the organization. */
|
|
29
|
+
description?: string;
|
|
30
|
+
/** Year the org was founded (emitted as ISO date `YYYY`). */
|
|
31
|
+
foundingYear?: number;
|
|
32
|
+
/** City/town of the org's primary address. */
|
|
33
|
+
addressLocality?: string;
|
|
34
|
+
/** Country name or ISO code. */
|
|
35
|
+
addressCountry?: string;
|
|
36
|
+
/** Public contact email (creates a `contactPoint`). */
|
|
37
|
+
contactEmail?: string;
|
|
38
|
+
/** Tags / areas of expertise emitted as `knowsAbout[]`. */
|
|
39
|
+
keywords?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WebSiteInput {
|
|
43
|
+
name: string;
|
|
44
|
+
url: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreativeWorkInput {
|
|
49
|
+
/** Title of the work. */
|
|
50
|
+
name: string;
|
|
51
|
+
/** Absolute URL of the work's page. */
|
|
52
|
+
url: string;
|
|
53
|
+
/** Absolute URL to the work's primary image (thumbnail / hero). */
|
|
54
|
+
image?: string;
|
|
55
|
+
/** ISO 8601 publication date. */
|
|
56
|
+
datePublished?: string;
|
|
57
|
+
/** Short descriptive text. */
|
|
58
|
+
description?: string;
|
|
59
|
+
/** Name of the creating organization or person (falls back to org name). */
|
|
60
|
+
authorName?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================
|
|
64
|
+
// Builders
|
|
65
|
+
// ============================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Organization — emitted site-wide. Powers Google Knowledge Panel for brand
|
|
69
|
+
* searches when combined with verified profiles via `sameAs`.
|
|
70
|
+
*/
|
|
71
|
+
export function buildOrganizationJsonLd(
|
|
72
|
+
input: OrganizationInput
|
|
73
|
+
): Record<string, unknown> | null {
|
|
74
|
+
if (!input.name || !input.url) return null;
|
|
75
|
+
|
|
76
|
+
const ld: Record<string, unknown> = {
|
|
77
|
+
"@context": "https://schema.org",
|
|
78
|
+
"@type": "Organization",
|
|
79
|
+
name: input.name,
|
|
80
|
+
url: input.url,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (input.logo) ld.logo = input.logo;
|
|
84
|
+
if (input.description) ld.description = input.description;
|
|
85
|
+
|
|
86
|
+
// foundingDate — schema.org expects ISO date; year-only is valid per spec
|
|
87
|
+
if (input.foundingYear && Number.isFinite(input.foundingYear)) {
|
|
88
|
+
ld.foundingDate = String(Math.round(input.foundingYear));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Address — PostalAddress with locality/country
|
|
92
|
+
if (input.addressLocality || input.addressCountry) {
|
|
93
|
+
const address: Record<string, unknown> = {
|
|
94
|
+
"@type": "PostalAddress",
|
|
95
|
+
};
|
|
96
|
+
if (input.addressLocality) address.addressLocality = input.addressLocality;
|
|
97
|
+
if (input.addressCountry) address.addressCountry = input.addressCountry;
|
|
98
|
+
ld.address = address;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ContactPoint — only emit if email present (schema.org requires contactType)
|
|
102
|
+
if (input.contactEmail) {
|
|
103
|
+
ld.contactPoint = {
|
|
104
|
+
"@type": "ContactPoint",
|
|
105
|
+
contactType: "customer service",
|
|
106
|
+
email: input.contactEmail,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// knowsAbout — array of strings (or schema.org Things)
|
|
111
|
+
const knowsAbout = (input.keywords || [])
|
|
112
|
+
.map((k) => k?.trim())
|
|
113
|
+
.filter((k): k is string => Boolean(k));
|
|
114
|
+
if (knowsAbout.length > 0) ld.knowsAbout = knowsAbout;
|
|
115
|
+
|
|
116
|
+
// sameAs — social profile URLs
|
|
117
|
+
const sameAs = (input.socialLinks || [])
|
|
118
|
+
.map((l) => l?.url?.trim())
|
|
119
|
+
.filter((u): u is string => Boolean(u));
|
|
120
|
+
if (sameAs.length > 0) ld.sameAs = sameAs;
|
|
121
|
+
|
|
122
|
+
return ld;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* WebSite — emitted site-wide. Helps Google identify the site as a coherent
|
|
127
|
+
* web property (separate from individual pages).
|
|
128
|
+
*/
|
|
129
|
+
export function buildWebSiteJsonLd(
|
|
130
|
+
input: WebSiteInput
|
|
131
|
+
): Record<string, unknown> | null {
|
|
132
|
+
if (!input.name || !input.url) return null;
|
|
133
|
+
|
|
134
|
+
const ld: Record<string, unknown> = {
|
|
135
|
+
"@context": "https://schema.org",
|
|
136
|
+
"@type": "WebSite",
|
|
137
|
+
name: input.name,
|
|
138
|
+
url: input.url,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (input.description) ld.description = input.description;
|
|
142
|
+
|
|
143
|
+
return ld;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* CreativeWork — emitted on project pages. Tells Google the page represents a
|
|
148
|
+
* specific creative work (motion graphic, illustration, photograph, etc.) and
|
|
149
|
+
* unlocks richer result presentation.
|
|
150
|
+
*/
|
|
151
|
+
export function buildCreativeWorkJsonLd(
|
|
152
|
+
input: CreativeWorkInput
|
|
153
|
+
): Record<string, unknown> | null {
|
|
154
|
+
if (!input.name || !input.url) return null;
|
|
155
|
+
|
|
156
|
+
const ld: Record<string, unknown> = {
|
|
157
|
+
"@context": "https://schema.org",
|
|
158
|
+
"@type": "CreativeWork",
|
|
159
|
+
name: input.name,
|
|
160
|
+
url: input.url,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (input.image) ld.image = input.image;
|
|
164
|
+
if (input.datePublished) ld.datePublished = input.datePublished;
|
|
165
|
+
if (input.description) ld.description = input.description;
|
|
166
|
+
if (input.authorName) {
|
|
167
|
+
ld.author = {
|
|
168
|
+
"@type": "Organization",
|
|
169
|
+
name: input.authorName,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return ld;
|
|
174
|
+
}
|