@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.
@@ -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>
@@ -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
  ? {
@@ -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
@@ -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
+ }