@morphika/andami 0.6.0 → 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)/layout.tsx +4 -15
- package/app/(site)/work/[slug]/page.tsx +2 -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 +25 -6
- package/components/admin/MetadataEditor.tsx +331 -32
- package/components/seo/JsonLd.tsx +50 -0
- package/components/seo/ProjectJsonLd.tsx +44 -0
- package/components/seo/SiteSeoHead.tsx +66 -0
- package/lib/sanity/queries.ts +24 -7
- package/lib/sanity/types.ts +28 -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/siteSettings.ts +102 -0
- package/site/llms-txt.ts +10 -0
|
@@ -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
|
+
}
|
package/lib/sanity/queries.ts
CHANGED
|
@@ -227,12 +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
|
|
231
|
-
//
|
|
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.
|
|
232
233
|
export const allPagesForSitemapQuery = groq`
|
|
233
|
-
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] {
|
|
234
|
+
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] | order(is_home desc, title asc) {
|
|
234
235
|
"slug": slug.current,
|
|
235
|
-
"updatedAt": _updatedAt
|
|
236
|
+
"updatedAt": _updatedAt,
|
|
237
|
+
title,
|
|
238
|
+
"description": metadata.seo_description
|
|
236
239
|
}
|
|
237
240
|
`;
|
|
238
241
|
|
|
@@ -258,12 +261,16 @@ export const allProjectSlugsQuery = groq`
|
|
|
258
261
|
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
259
262
|
`;
|
|
260
263
|
|
|
261
|
-
// All published projects for sitemap.xml — includes _updatedAt for accurate <lastmod
|
|
262
|
-
// and respects metadata.noindex
|
|
264
|
+
// All published projects for sitemap.xml — includes _updatedAt for accurate <lastmod>,
|
|
265
|
+
// thumbnail_path for image sitemap, and respects metadata.noindex.
|
|
263
266
|
export const allProjectsForSitemapQuery = groq`
|
|
264
267
|
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current) && metadata.noindex != true] {
|
|
265
268
|
"slug": slug.current,
|
|
266
|
-
"updatedAt": _updatedAt
|
|
269
|
+
"updatedAt": _updatedAt,
|
|
270
|
+
thumbnail_path,
|
|
271
|
+
title,
|
|
272
|
+
"description": metadata.seo_description,
|
|
273
|
+
published_at
|
|
267
274
|
}
|
|
268
275
|
`;
|
|
269
276
|
|
|
@@ -440,6 +447,16 @@ export const siteSettingsQuery = groq`
|
|
|
440
447
|
default_og_image,
|
|
441
448
|
favicon_path,
|
|
442
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,
|
|
443
460
|
seed_url,
|
|
444
461
|
last_scanned_at,
|
|
445
462
|
asset_status
|
package/lib/sanity/types.ts
CHANGED
|
@@ -1031,6 +1031,14 @@ export interface AssetRegistry {
|
|
|
1031
1031
|
// Site Settings
|
|
1032
1032
|
// ============================================
|
|
1033
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
|
+
|
|
1034
1042
|
export interface SiteSettings {
|
|
1035
1043
|
nav_items?: NavItem[];
|
|
1036
1044
|
nav_design?: NavDesign;
|
|
@@ -1040,6 +1048,26 @@ export interface SiteSettings {
|
|
|
1040
1048
|
default_og_image?: string;
|
|
1041
1049
|
favicon_path?: string;
|
|
1042
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;
|
|
1043
1071
|
seed_url?: string;
|
|
1044
1072
|
last_scanned_at?: string;
|
|
1045
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached siteSettings fetch — shared between (site)/layout.tsx and the
|
|
3
|
+
* SEO server components (SiteSeoHead, ProjectJsonLd).
|
|
4
|
+
*
|
|
5
|
+
* React's `cache()` deduplicates within a single render pass: layout and
|
|
6
|
+
* SEO components called from the same route render share one Sanity fetch.
|
|
7
|
+
* The underlying `client` already uses Sanity's CDN (2ms vs 200ms), so
|
|
8
|
+
* cold fetches are cheap anyway.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cache } from "react";
|
|
12
|
+
import { client } from "../sanity/client";
|
|
13
|
+
import { siteSettingsQuery } from "../sanity/queries";
|
|
14
|
+
import type { SiteSettings } from "../sanity/types";
|
|
15
|
+
|
|
16
|
+
export const getCachedSiteSettings = cache(async (): Promise<SiteSettings | null> => {
|
|
17
|
+
try {
|
|
18
|
+
return await client.fetch<SiteSettings>(siteSettingsQuery);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error("[SEO] Failed to fetch site settings:", error);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Coerce any asset URL (relative path, /api proxy, or full CDN URL) to an
|
|
27
|
+
* absolute URL using the configured site domain as base. Required by
|
|
28
|
+
* schema.org — JSON-LD URLs must be fully qualified.
|
|
29
|
+
*/
|
|
30
|
+
export function toAbsoluteUrl(url: string | undefined, base: string): string | undefined {
|
|
31
|
+
if (!url) return undefined;
|
|
32
|
+
try {
|
|
33
|
+
return new URL(url, base).toString();
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/version.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphika/andami",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"./site/error": "./site/error.ts",
|
|
129
129
|
"./site/robots": "./site/robots.ts",
|
|
130
130
|
"./site/sitemap": "./site/sitemap.ts",
|
|
131
|
+
"./site/llms-txt": "./site/llms-txt.ts",
|
|
131
132
|
"./admin": "./admin/index.ts",
|
|
132
133
|
"./admin/login": "./admin/login.ts",
|
|
133
134
|
"./admin/pages": "./admin/pages.ts",
|
|
@@ -11,6 +11,9 @@ export default defineType({
|
|
|
11
11
|
{ name: "nav", title: "Navigation", default: true },
|
|
12
12
|
{ name: "nav_design", title: "Nav Design" },
|
|
13
13
|
{ name: "meta", title: "Metadata" },
|
|
14
|
+
{ name: "social", title: "Social & Branding" },
|
|
15
|
+
{ name: "organization", title: "Organization Info" },
|
|
16
|
+
{ name: "verification", title: "Search Verification" },
|
|
14
17
|
{ name: "assets", title: "Assets" },
|
|
15
18
|
],
|
|
16
19
|
fields: [
|
|
@@ -296,6 +299,105 @@ export default defineType({
|
|
|
296
299
|
defineField({ name: "favicon_path", title: "Favicon Path", type: "string", group: "meta" }),
|
|
297
300
|
defineField({ name: "analytics_id", title: "Analytics ID", type: "string", group: "meta" }),
|
|
298
301
|
|
|
302
|
+
// === SOCIAL & BRANDING (used by JSON-LD structured data) ===
|
|
303
|
+
defineField({
|
|
304
|
+
name: "org_logo",
|
|
305
|
+
title: "Organization Logo",
|
|
306
|
+
type: "string",
|
|
307
|
+
group: "social",
|
|
308
|
+
description: "Asset path to the logo used by Google's Knowledge Panel and rich results. Square images recommended (min 112×112, ideal 500×500).",
|
|
309
|
+
}),
|
|
310
|
+
defineField({
|
|
311
|
+
name: "default_author",
|
|
312
|
+
title: "Default Author",
|
|
313
|
+
type: "string",
|
|
314
|
+
group: "social",
|
|
315
|
+
description: "Used as the author of project pages when not overridden. Typically your studio/agency name.",
|
|
316
|
+
}),
|
|
317
|
+
defineField({
|
|
318
|
+
name: "social_links",
|
|
319
|
+
title: "Social Profiles",
|
|
320
|
+
type: "array",
|
|
321
|
+
group: "social",
|
|
322
|
+
description: "Profile URLs (Instagram, LinkedIn, Behance, Vimeo, etc.) emitted as schema.org sameAs[] so Google links your organization to its social presence.",
|
|
323
|
+
of: [
|
|
324
|
+
{
|
|
325
|
+
type: "object",
|
|
326
|
+
fields: [
|
|
327
|
+
defineField({ name: "label", title: "Label", type: "string", description: "Optional display name (e.g. Instagram, Behance). For admin organization only." }),
|
|
328
|
+
defineField({ name: "url", title: "URL", type: "url", validation: (Rule) => Rule.required().uri({ scheme: ["http", "https"] }) }),
|
|
329
|
+
],
|
|
330
|
+
preview: {
|
|
331
|
+
select: { label: "label", url: "url" },
|
|
332
|
+
prepare({ label, url }: { label?: string; url?: string }) {
|
|
333
|
+
return {
|
|
334
|
+
title: label || url || "(empty)",
|
|
335
|
+
subtitle: label ? url : undefined,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
}),
|
|
342
|
+
|
|
343
|
+
// === ORGANIZATION INFO (citation-friendly facts for LLMs + JSON-LD) ===
|
|
344
|
+
defineField({
|
|
345
|
+
name: "founding_year",
|
|
346
|
+
title: "Founded (year)",
|
|
347
|
+
type: "number",
|
|
348
|
+
group: "organization",
|
|
349
|
+
description: "Year your organization was founded. Cited by AI agents answering 'when was X founded'. Emitted as schema.org foundingDate.",
|
|
350
|
+
validation: (Rule) => Rule.min(1800).max(new Date().getFullYear()),
|
|
351
|
+
}),
|
|
352
|
+
defineField({
|
|
353
|
+
name: "address_locality",
|
|
354
|
+
title: "City",
|
|
355
|
+
type: "string",
|
|
356
|
+
group: "organization",
|
|
357
|
+
description: "City or town where your organization is based (e.g. 'Barcelona', 'Berlin'). Emitted as schema.org address.addressLocality.",
|
|
358
|
+
}),
|
|
359
|
+
defineField({
|
|
360
|
+
name: "address_country",
|
|
361
|
+
title: "Country",
|
|
362
|
+
type: "string",
|
|
363
|
+
group: "organization",
|
|
364
|
+
description: "Country name or ISO code (e.g. 'Spain', 'ES'). Emitted as schema.org address.addressCountry.",
|
|
365
|
+
}),
|
|
366
|
+
defineField({
|
|
367
|
+
name: "contact_email",
|
|
368
|
+
title: "Contact Email",
|
|
369
|
+
type: "string",
|
|
370
|
+
group: "organization",
|
|
371
|
+
description: "Public contact email. Emitted as schema.org contactPoint.email.",
|
|
372
|
+
validation: (Rule) => Rule.email().error("Must be a valid email address"),
|
|
373
|
+
}),
|
|
374
|
+
defineField({
|
|
375
|
+
name: "keywords",
|
|
376
|
+
title: "Areas of Expertise",
|
|
377
|
+
type: "array",
|
|
378
|
+
group: "organization",
|
|
379
|
+
description: "Short list of services / specialties (e.g. 'CGI', 'Motion Graphics', '3D Animation'). Emitted as schema.org knowsAbout — helps LLMs cite you for related queries.",
|
|
380
|
+
of: [{ type: "string" }],
|
|
381
|
+
options: { layout: "tags" },
|
|
382
|
+
validation: (Rule) => Rule.max(20),
|
|
383
|
+
}),
|
|
384
|
+
|
|
385
|
+
// === SEARCH ENGINE VERIFICATION ===
|
|
386
|
+
defineField({
|
|
387
|
+
name: "verification_google",
|
|
388
|
+
title: "Google Search Console",
|
|
389
|
+
type: "string",
|
|
390
|
+
group: "verification",
|
|
391
|
+
description: "The content value from Google Search Console's HTML tag verification method. Just the code, not the full <meta> tag.",
|
|
392
|
+
}),
|
|
393
|
+
defineField({
|
|
394
|
+
name: "verification_bing",
|
|
395
|
+
title: "Bing Webmaster",
|
|
396
|
+
type: "string",
|
|
397
|
+
group: "verification",
|
|
398
|
+
description: "The content value from Bing Webmaster Tools' HTML tag verification. Just the code.",
|
|
399
|
+
}),
|
|
400
|
+
|
|
299
401
|
// === SETUP ===
|
|
300
402
|
defineField({
|
|
301
403
|
name: "setup_complete",
|
package/site/llms-txt.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @morphika/andami/site/llms-txt — AI/LLM-friendly site summary route.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the framework's /llms.txt route handler so instances can mount
|
|
5
|
+
* it at `/llms.txt`. Required instance setup (one-time, see CHANGELOG):
|
|
6
|
+
*
|
|
7
|
+
* // app/llms.txt/route.ts (in your instance repo)
|
|
8
|
+
* export { GET, revalidate } from "@morphika/andami/site/llms-txt";
|
|
9
|
+
*/
|
|
10
|
+
export { GET, revalidate } from "../app/llms.txt/route";
|