@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.
@@ -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
  );
@@ -1,7 +1,4 @@
1
- import { cache } from "react";
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 getSiteSettings();
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
- default_title: string;
54
- default_description: string;
55
- default_og_image: string;
56
- favicon_path: string;
57
- analytics_id: string;
58
- }) => {
59
- setSavingMeta(true);
60
- setMessage(null);
61
- try {
62
- const res = await fetch("/api/admin/settings", {
63
- method: "POST",
64
- headers: { "Content-Type": "application/json", ...csrfHeaders() },
65
- body: JSON.stringify({ section: "metadata", data }),
66
- });
67
- if (!res.ok) {
68
- const errData = await res.json();
69
- throw new Error(errData.error || "Save failed");
70
- }
71
- setMessage({ type: "success", text: "Metadata saved" });
72
- // Refresh settings to get updated data
73
- const settingsRes = await fetch("/api/admin/settings");
74
- if (settingsRes.ok) {
75
- const settingsData = await settingsRes.json();
76
- setSettings(settingsData.settings || null);
77
- }
78
- } catch (err) {
79
- setMessage({
80
- type: "error",
81
- text: err instanceof Error ? err.message : "Save failed",
82
- });
83
- } finally {
84
- setSavingMeta(false);
85
- }
86
- };
87
-
88
- // ── Loading state ──
89
- if (loading) {
90
- return (
91
- <div className="flex items-center justify-center py-20">
92
- <span className="text-sm text-neutral-400 animate-pulse">
93
- Loading settings...
94
- </span>
95
- </div>
96
- );
97
- }
98
-
99
- return (
100
- <div className="space-y-8">
101
- <h1 className="text-2xl font-semibold text-neutral-900">
102
- Metadata
103
- </h1>
104
-
105
- {/* Message banner */}
106
- {message && (
107
- <div
108
- className={`p-3 rounded-xl border transition-opacity ${
109
- message.type === "success"
110
- ? "border-green-200 bg-green-50 text-green-700"
111
- : "border-red-200 bg-red-50 text-red-700"
112
- }`}
113
- >
114
- <p className="text-sm">{message.text}</p>
115
- </div>
116
- )}
117
-
118
- {/* ====== SITE METADATA ====== */}
119
- <MetadataEditor
120
- initialData={{
121
- default_title: settings?.default_title || "",
122
- default_description: settings?.default_description || "",
123
- default_og_image: settings?.default_og_image || "",
124
- favicon_path: settings?.favicon_path || "",
125
- analytics_id: settings?.analytics_id || "",
126
- }}
127
- onSave={handleSaveMeta}
128
- saving={savingMeta}
129
- />
130
- </div>
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
+ }