@morphika/andami 0.6.0 → 0.8.3
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 +7 -0
- package/app/(site)/layout.tsx +4 -15
- package/app/(site)/work/[slug]/page.tsx +10 -0
- package/app/admin/settings/page.tsx +136 -132
- package/app/api/admin/settings/route.ts +69 -0
- package/app/llms.txt/route.ts +146 -0
- package/app/robots.ts +73 -54
- package/app/sitemap.ts +25 -6
- package/components/admin/MetadataEditor.tsx +331 -32
- package/components/seo/BreadcrumbJsonLd.tsx +49 -0
- 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 +219 -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 +23 -0
|
@@ -7,6 +7,7 @@ import type { Page } from "../../../lib/sanity/types";
|
|
|
7
7
|
import { PageRenderer } from "../../../components/blocks";
|
|
8
8
|
import { getSiteConfig } from "../../../lib/config";
|
|
9
9
|
import { assetUrl } from "../../../lib/assets";
|
|
10
|
+
import BreadcrumbJsonLd from "../../../components/seo/BreadcrumbJsonLd";
|
|
10
11
|
|
|
11
12
|
const cfg = getSiteConfig();
|
|
12
13
|
|
|
@@ -83,6 +84,12 @@ export default async function DynamicPage({ params }: PageProps) {
|
|
|
83
84
|
|
|
84
85
|
return (
|
|
85
86
|
<main className="min-h-screen">
|
|
87
|
+
<BreadcrumbJsonLd
|
|
88
|
+
items={[
|
|
89
|
+
{ name: "Home", path: "/" },
|
|
90
|
+
{ name: page.title },
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
86
93
|
<PageRenderer page={page} />
|
|
87
94
|
</main>
|
|
88
95
|
);
|
package/app/(site)/layout.tsx
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { client } from "../../lib/sanity/client";
|
|
3
|
-
import { siteSettingsQuery } from "../../lib/sanity/queries";
|
|
4
|
-
import type { SiteSettings } from "../../lib/sanity/types";
|
|
1
|
+
import { getCachedSiteSettings } from "../../lib/seo/site-settings";
|
|
5
2
|
import { NavColorProvider } from "../../lib/contexts/NavColorContext";
|
|
6
3
|
import { NavAnimationProvider } from "../../lib/contexts/NavAnimationContext";
|
|
7
4
|
import { PageExitProvider } from "../../lib/contexts/PageExitContext";
|
|
@@ -10,23 +7,14 @@ import { getSiteConfig } from "../../lib/config";
|
|
|
10
7
|
import Navbar from "../../components/ui/Navbar";
|
|
11
8
|
import CustomCursor from "../../components/ui/CustomCursor";
|
|
12
9
|
import ScrollToTop from "../../components/ui/ScrollToTop";
|
|
13
|
-
|
|
14
|
-
// CPU Audit Fix 3: React cache() deduplicates within a render pass.
|
|
15
|
-
const getSiteSettings = cache(async (): Promise<SiteSettings | null> => {
|
|
16
|
-
try {
|
|
17
|
-
return await client.fetch<SiteSettings>(siteSettingsQuery);
|
|
18
|
-
} catch (error) {
|
|
19
|
-
console.error("[SiteLayout] Failed to fetch site settings:", error);
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
});
|
|
10
|
+
import SiteSeoHead from "../../components/seo/SiteSeoHead";
|
|
23
11
|
|
|
24
12
|
export default async function SiteLayout({
|
|
25
13
|
children,
|
|
26
14
|
}: {
|
|
27
15
|
children: React.ReactNode;
|
|
28
16
|
}) {
|
|
29
|
-
const settings = await
|
|
17
|
+
const settings = await getCachedSiteSettings();
|
|
30
18
|
const navColor = settings?.nav_design?.color || "yellow-lime";
|
|
31
19
|
|
|
32
20
|
const cfg = getSiteConfig();
|
|
@@ -36,6 +24,7 @@ export default async function SiteLayout({
|
|
|
36
24
|
<NavColorProvider initialColor={navColor}>
|
|
37
25
|
<NavAnimationProvider>
|
|
38
26
|
<PageExitProvider>
|
|
27
|
+
<SiteSeoHead />
|
|
39
28
|
<div data-site {...(cfg.features.customCursor ? { "data-custom-cursor": true } : {})}>
|
|
40
29
|
<Navbar
|
|
41
30
|
navItems={settings?.nav_items}
|
|
@@ -7,6 +7,8 @@ import type { Page } from "../../../../lib/sanity/types";
|
|
|
7
7
|
import { PageRenderer } from "../../../../components/blocks";
|
|
8
8
|
import { getSiteConfig } from "../../../../lib/config";
|
|
9
9
|
import { assetUrl } from "../../../../lib/assets";
|
|
10
|
+
import ProjectJsonLd from "../../../../components/seo/ProjectJsonLd";
|
|
11
|
+
import BreadcrumbJsonLd from "../../../../components/seo/BreadcrumbJsonLd";
|
|
10
12
|
|
|
11
13
|
const cfg = getSiteConfig();
|
|
12
14
|
|
|
@@ -83,6 +85,14 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|
|
83
85
|
|
|
84
86
|
return (
|
|
85
87
|
<main className="min-h-screen">
|
|
88
|
+
<ProjectJsonLd page={page} />
|
|
89
|
+
<BreadcrumbJsonLd
|
|
90
|
+
items={[
|
|
91
|
+
{ name: "Home", path: "/" },
|
|
92
|
+
{ name: "Work", path: "/work" },
|
|
93
|
+
{ name: page.title },
|
|
94
|
+
]}
|
|
95
|
+
/>
|
|
86
96
|
<PageRenderer page={page} />
|
|
87
97
|
</main>
|
|
88
98
|
);
|
|
@@ -1,132 +1,136 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from "react";
|
|
4
|
-
import MetadataEditor from "../../../components/admin/MetadataEditor";
|
|
5
|
-
import { csrfHeaders } from "../../../lib/csrf-client";
|
|
6
|
-
import type { SiteSettings } from "../../../lib/sanity/types";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* /admin/settings — Global settings page.
|
|
10
|
-
* Only contains Site Metadata.
|
|
11
|
-
* Navigation + Footer are managed in /admin/navigation.
|
|
12
|
-
*/
|
|
13
|
-
export default function AdminSettingsPage() {
|
|
14
|
-
// ── Global settings state ──
|
|
15
|
-
const [settings, setSettings] = useState<SiteSettings | null>(null);
|
|
16
|
-
|
|
17
|
-
// ── UI state ──
|
|
18
|
-
const [loading, setLoading] = useState(true);
|
|
19
|
-
const [savingMeta, setSavingMeta] = useState(false);
|
|
20
|
-
const [message, setMessage] = useState<{
|
|
21
|
-
type: "success" | "error";
|
|
22
|
-
text: string;
|
|
23
|
-
} | null>(null);
|
|
24
|
-
|
|
25
|
-
// ── Load settings ──
|
|
26
|
-
const fetchSettings = useCallback(async () => {
|
|
27
|
-
try {
|
|
28
|
-
const res = await fetch("/api/admin/settings");
|
|
29
|
-
if (res.ok) {
|
|
30
|
-
const data = await res.json();
|
|
31
|
-
setSettings(data.settings || null);
|
|
32
|
-
}
|
|
33
|
-
} catch {
|
|
34
|
-
setMessage({ type: "error", text: "Failed to load settings" });
|
|
35
|
-
} finally {
|
|
36
|
-
setLoading(false);
|
|
37
|
-
}
|
|
38
|
-
}, []);
|
|
39
|
-
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
fetchSettings();
|
|
42
|
-
}, [fetchSettings]);
|
|
43
|
-
|
|
44
|
-
// ── Auto-dismiss messages ──
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (!message) return;
|
|
47
|
-
const timer = setTimeout(() => setMessage(null), 5000);
|
|
48
|
-
return () => clearTimeout(timer);
|
|
49
|
-
}, [message]);
|
|
50
|
-
|
|
51
|
-
// ── Save handler ──
|
|
52
|
-
const handleSaveMeta = async (data: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
className={
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import MetadataEditor, { type MetadataData } from "../../../components/admin/MetadataEditor";
|
|
5
|
+
import { csrfHeaders } from "../../../lib/csrf-client";
|
|
6
|
+
import type { SiteSettings } from "../../../lib/sanity/types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* /admin/settings — Global settings page.
|
|
10
|
+
* Only contains Site Metadata.
|
|
11
|
+
* Navigation + Footer are managed in /admin/navigation.
|
|
12
|
+
*/
|
|
13
|
+
export default function AdminSettingsPage() {
|
|
14
|
+
// ── Global settings state ──
|
|
15
|
+
const [settings, setSettings] = useState<SiteSettings | null>(null);
|
|
16
|
+
|
|
17
|
+
// ── UI state ──
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [savingMeta, setSavingMeta] = useState(false);
|
|
20
|
+
const [message, setMessage] = useState<{
|
|
21
|
+
type: "success" | "error";
|
|
22
|
+
text: string;
|
|
23
|
+
} | null>(null);
|
|
24
|
+
|
|
25
|
+
// ── Load settings ──
|
|
26
|
+
const fetchSettings = useCallback(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch("/api/admin/settings");
|
|
29
|
+
if (res.ok) {
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
setSettings(data.settings || null);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
setMessage({ type: "error", text: "Failed to load settings" });
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
fetchSettings();
|
|
42
|
+
}, [fetchSettings]);
|
|
43
|
+
|
|
44
|
+
// ── Auto-dismiss messages ──
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!message) return;
|
|
47
|
+
const timer = setTimeout(() => setMessage(null), 5000);
|
|
48
|
+
return () => clearTimeout(timer);
|
|
49
|
+
}, [message]);
|
|
50
|
+
|
|
51
|
+
// ── Save handler ──
|
|
52
|
+
const handleSaveMeta = async (data: MetadataData) => {
|
|
53
|
+
setSavingMeta(true);
|
|
54
|
+
setMessage(null);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch("/api/admin/settings", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json", ...csrfHeaders() },
|
|
59
|
+
body: JSON.stringify({ section: "metadata", data }),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const errData = await res.json();
|
|
63
|
+
throw new Error(errData.error || "Save failed");
|
|
64
|
+
}
|
|
65
|
+
setMessage({ type: "success", text: "Metadata saved" });
|
|
66
|
+
// Refresh settings to get updated data
|
|
67
|
+
const settingsRes = await fetch("/api/admin/settings");
|
|
68
|
+
if (settingsRes.ok) {
|
|
69
|
+
const settingsData = await settingsRes.json();
|
|
70
|
+
setSettings(settingsData.settings || null);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setMessage({
|
|
74
|
+
type: "error",
|
|
75
|
+
text: err instanceof Error ? err.message : "Save failed",
|
|
76
|
+
});
|
|
77
|
+
} finally {
|
|
78
|
+
setSavingMeta(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ── Loading state ──
|
|
83
|
+
if (loading) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-center justify-center py-20">
|
|
86
|
+
<span className="text-sm text-neutral-400 animate-pulse">
|
|
87
|
+
Loading settings...
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-8">
|
|
95
|
+
<h1 className="text-2xl font-semibold text-neutral-900">
|
|
96
|
+
Metadata
|
|
97
|
+
</h1>
|
|
98
|
+
|
|
99
|
+
{/* Message banner */}
|
|
100
|
+
{message && (
|
|
101
|
+
<div
|
|
102
|
+
className={`p-3 rounded-xl border transition-opacity ${
|
|
103
|
+
message.type === "success"
|
|
104
|
+
? "border-green-200 bg-green-50 text-green-700"
|
|
105
|
+
: "border-red-200 bg-red-50 text-red-700"
|
|
106
|
+
}`}
|
|
107
|
+
>
|
|
108
|
+
<p className="text-sm">{message.text}</p>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* ====== SITE METADATA ====== */}
|
|
113
|
+
<MetadataEditor
|
|
114
|
+
initialData={{
|
|
115
|
+
default_title: settings?.default_title || "",
|
|
116
|
+
default_description: settings?.default_description || "",
|
|
117
|
+
default_og_image: settings?.default_og_image || "",
|
|
118
|
+
favicon_path: settings?.favicon_path || "",
|
|
119
|
+
analytics_id: settings?.analytics_id || "",
|
|
120
|
+
org_logo: settings?.org_logo || "",
|
|
121
|
+
default_author: settings?.default_author || "",
|
|
122
|
+
social_links: settings?.social_links || [],
|
|
123
|
+
founding_year: settings?.founding_year,
|
|
124
|
+
address_locality: settings?.address_locality || "",
|
|
125
|
+
address_country: settings?.address_country || "",
|
|
126
|
+
contact_email: settings?.contact_email || "",
|
|
127
|
+
keywords: settings?.keywords || [],
|
|
128
|
+
verification_google: settings?.verification_google || "",
|
|
129
|
+
verification_bing: settings?.verification_bing || "",
|
|
130
|
+
}}
|
|
131
|
+
onSave={handleSaveMeta}
|
|
132
|
+
saving={savingMeta}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -296,12 +296,81 @@ export async function POST(request: NextRequest) {
|
|
|
296
296
|
{ status: 400 }
|
|
297
297
|
);
|
|
298
298
|
}
|
|
299
|
+
// Validate social_links array — each entry must be a safe URL
|
|
300
|
+
const socialLinks: Array<{ _key?: string; label?: string; url: string }> = [];
|
|
301
|
+
if (Array.isArray(data.social_links)) {
|
|
302
|
+
if (data.social_links.length > 25) {
|
|
303
|
+
return NextResponse.json(
|
|
304
|
+
{ error: "social_links exceeds maximum of 25 entries" },
|
|
305
|
+
{ status: 400 }
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
for (const link of data.social_links) {
|
|
309
|
+
if (!link || typeof link !== "object") continue;
|
|
310
|
+
if (typeof link.url !== "string" || !link.url.trim()) continue;
|
|
311
|
+
if (!isSafeUrl(link.url)) {
|
|
312
|
+
return NextResponse.json(
|
|
313
|
+
{ error: "Social link URL uses a disallowed protocol" },
|
|
314
|
+
{ status: 400 }
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
socialLinks.push({
|
|
318
|
+
_key: typeof link._key === "string" ? link._key : crypto.randomUUID().slice(0, 8),
|
|
319
|
+
...(typeof link.label === "string" && link.label.trim()
|
|
320
|
+
? { label: link.label.slice(0, 200) }
|
|
321
|
+
: {}),
|
|
322
|
+
url: link.url.slice(0, 500),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Validate keywords array — strings, length-limited
|
|
327
|
+
const keywords: string[] = [];
|
|
328
|
+
if (Array.isArray(data.keywords)) {
|
|
329
|
+
if (data.keywords.length > 20) {
|
|
330
|
+
return NextResponse.json(
|
|
331
|
+
{ error: "keywords exceeds maximum of 20 entries" },
|
|
332
|
+
{ status: 400 }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
for (const k of data.keywords) {
|
|
336
|
+
if (typeof k === "string" && k.trim()) {
|
|
337
|
+
keywords.push(k.trim().slice(0, 100));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Validate founding_year
|
|
342
|
+
let foundingYear: number | undefined;
|
|
343
|
+
if (typeof data.founding_year === "number" && data.founding_year > 0) {
|
|
344
|
+
const currentYear = new Date().getFullYear();
|
|
345
|
+
if (data.founding_year >= 1800 && data.founding_year <= currentYear) {
|
|
346
|
+
foundingYear = Math.round(data.founding_year);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Validate email format (basic)
|
|
350
|
+
let contactEmail = "";
|
|
351
|
+
if (typeof data.contact_email === "string" && data.contact_email.trim()) {
|
|
352
|
+
const email = data.contact_email.trim().slice(0, 200);
|
|
353
|
+
// Basic email shape check — full validation lives in Sanity schema
|
|
354
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
355
|
+
contactEmail = email;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
299
358
|
patch = {
|
|
300
359
|
default_title: data.default_title || "",
|
|
301
360
|
default_description: data.default_description || "",
|
|
302
361
|
default_og_image: data.default_og_image || "",
|
|
303
362
|
favicon_path: data.favicon_path || "",
|
|
304
363
|
analytics_id: data.analytics_id || "",
|
|
364
|
+
org_logo: typeof data.org_logo === "string" ? data.org_logo.slice(0, 500) : "",
|
|
365
|
+
default_author: typeof data.default_author === "string" ? data.default_author.slice(0, 200) : "",
|
|
366
|
+
social_links: socialLinks,
|
|
367
|
+
founding_year: foundingYear,
|
|
368
|
+
address_locality: typeof data.address_locality === "string" ? data.address_locality.slice(0, 200) : "",
|
|
369
|
+
address_country: typeof data.address_country === "string" ? data.address_country.slice(0, 100) : "",
|
|
370
|
+
contact_email: contactEmail,
|
|
371
|
+
keywords,
|
|
372
|
+
verification_google: typeof data.verification_google === "string" ? data.verification_google.slice(0, 500) : "",
|
|
373
|
+
verification_bing: typeof data.verification_bing === "string" ? data.verification_bing.slice(0, 500) : "",
|
|
305
374
|
};
|
|
306
375
|
break;
|
|
307
376
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /llms.txt — AI/LLM-friendly site summary.
|
|
3
|
+
*
|
|
4
|
+
* Emerging standard (adopted by Vercel, Anthropic, Mintlify): a markdown file
|
|
5
|
+
* that gives LLM agents (ChatGPT, Perplexity, Claude, etc.) a curated,
|
|
6
|
+
* machine-readable overview of the site. Reduces hallucination and improves
|
|
7
|
+
* citation accuracy when AI assistants answer questions about the site.
|
|
8
|
+
*
|
|
9
|
+
* Format follows the llms.txt convention: H1 title, blockquote description,
|
|
10
|
+
* optional facts block, then sections of links with brief descriptions.
|
|
11
|
+
*
|
|
12
|
+
* Cached via ISR (24h, matching pages) — zero serverless CPU after first
|
|
13
|
+
* generation per revalidation window.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { NextResponse } from "next/server";
|
|
17
|
+
import { client } from "../../lib/sanity/client";
|
|
18
|
+
import {
|
|
19
|
+
allPagesForSitemapQuery,
|
|
20
|
+
allProjectsForSitemapQuery,
|
|
21
|
+
} from "../../lib/sanity/queries";
|
|
22
|
+
import { getCachedSiteSettings } from "../../lib/seo/site-settings";
|
|
23
|
+
import { getSiteConfig } from "../../lib/config";
|
|
24
|
+
|
|
25
|
+
// ISR — regenerate at most once per 24h. Admin can trigger on-demand
|
|
26
|
+
// revalidation via /api/admin/revalidate (same as pages).
|
|
27
|
+
export const revalidate = 86400;
|
|
28
|
+
|
|
29
|
+
interface PageEntry {
|
|
30
|
+
slug: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ProjectEntry extends PageEntry {
|
|
37
|
+
thumbnail_path?: string;
|
|
38
|
+
published_at?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeMd(s: string | undefined): string {
|
|
42
|
+
if (!s) return "";
|
|
43
|
+
return s.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function GET() {
|
|
47
|
+
const cfg = getSiteConfig();
|
|
48
|
+
const domain = cfg.domain.replace(/\/$/, "");
|
|
49
|
+
|
|
50
|
+
const [settings, pages, projects] = await Promise.all([
|
|
51
|
+
getCachedSiteSettings(),
|
|
52
|
+
client.fetch<PageEntry[]>(allPagesForSitemapQuery).catch(() => [] as PageEntry[]),
|
|
53
|
+
client.fetch<ProjectEntry[]>(allProjectsForSitemapQuery).catch(() => [] as ProjectEntry[]),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
|
|
58
|
+
// ─── HEADER ───────────────────────────────────────────────
|
|
59
|
+
lines.push(`# ${cfg.name}`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
|
|
62
|
+
const description =
|
|
63
|
+
settings?.default_description ||
|
|
64
|
+
cfg.defaults.metaDescription ||
|
|
65
|
+
"";
|
|
66
|
+
if (description) {
|
|
67
|
+
lines.push(`> ${escapeMd(description)}`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── ORGANIZATION FACTS ──────────────────────────────────
|
|
72
|
+
// Citation-friendly: discrete facts LLMs can quote directly.
|
|
73
|
+
const facts: string[] = [];
|
|
74
|
+
if (settings?.founding_year) {
|
|
75
|
+
facts.push(`**Founded:** ${settings.founding_year}`);
|
|
76
|
+
}
|
|
77
|
+
if (settings?.address_locality || settings?.address_country) {
|
|
78
|
+
const loc = [settings.address_locality, settings.address_country]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(", ");
|
|
81
|
+
facts.push(`**Location:** ${loc}`);
|
|
82
|
+
}
|
|
83
|
+
if (settings?.contact_email) {
|
|
84
|
+
facts.push(`**Contact:** ${settings.contact_email}`);
|
|
85
|
+
}
|
|
86
|
+
if (settings?.keywords && settings.keywords.length > 0) {
|
|
87
|
+
facts.push(`**Specializes in:** ${settings.keywords.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
if (facts.length > 0) {
|
|
90
|
+
lines.push(...facts);
|
|
91
|
+
lines.push("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── SOCIAL PROFILES ─────────────────────────────────────
|
|
95
|
+
if (settings?.social_links && settings.social_links.length > 0) {
|
|
96
|
+
lines.push("**Social profiles:**");
|
|
97
|
+
for (const link of settings.social_links) {
|
|
98
|
+
if (!link.url) continue;
|
|
99
|
+
const label = link.label?.trim();
|
|
100
|
+
// Emit "Label: URL" only when a meaningful label is present (and is not
|
|
101
|
+
// just a duplicate of the URL). Otherwise emit the URL alone — avoids
|
|
102
|
+
// the "URL: URL" duplication when a user types the URL into both fields.
|
|
103
|
+
const showLabel = label && label !== link.url;
|
|
104
|
+
lines.push(showLabel ? `- ${label}: ${link.url}` : `- ${link.url}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── PROJECTS ────────────────────────────────────────────
|
|
110
|
+
if (projects.length > 0) {
|
|
111
|
+
lines.push("## Projects");
|
|
112
|
+
lines.push("");
|
|
113
|
+
for (const p of projects) {
|
|
114
|
+
const url = `${domain}/work/${p.slug}`;
|
|
115
|
+
const title = escapeMd(p.title) || p.slug;
|
|
116
|
+
const desc = escapeMd(p.description);
|
|
117
|
+
lines.push(desc ? `- [${title}](${url}): ${desc}` : `- [${title}](${url})`);
|
|
118
|
+
}
|
|
119
|
+
lines.push("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── PAGES ───────────────────────────────────────────────
|
|
123
|
+
if (pages.length > 0) {
|
|
124
|
+
lines.push("## Pages");
|
|
125
|
+
lines.push("");
|
|
126
|
+
for (const p of pages) {
|
|
127
|
+
const url = `${domain}/${p.slug}`;
|
|
128
|
+
const title = escapeMd(p.title) || p.slug;
|
|
129
|
+
const desc = escapeMd(p.description);
|
|
130
|
+
lines.push(desc ? `- [${title}](${url}): ${desc}` : `- [${title}](${url})`);
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const body = lines.join("\n");
|
|
136
|
+
|
|
137
|
+
return new NextResponse(body, {
|
|
138
|
+
status: 200,
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
141
|
+
// CDN cache: serve from edge for 24h, allow stale-while-revalidate up
|
|
142
|
+
// to 1h. Matches the ISR window.
|
|
143
|
+
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=3600",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|