Skip to main content

logicaffeine_web/ui/
seo.rs

1//! SEO utilities and JSON-LD structured data generators.
2//!
3//! Provides structured data schemas for rich results in search engines:
4//! - Organization schema (sitewide)
5//! - WebSite schema with search action
6//! - SoftwareApplication schema (Studio page)
7//! - Course schema (Learn page)
8//! - Article schema (News articles)
9//! - FAQPage schema (Guide page)
10//! - BreadcrumbList schema (all pages)
11//! - TechArticle schema (Crates page)
12//!
13//! # Usage
14//!
15//! ```no_run
16//! use logicaffeine_web::ui::seo::{organization_schema, breadcrumb_schema, BreadcrumbItem};
17//!
18//! let org_json = organization_schema();
19//! let breadcrumbs = breadcrumb_schema(&[
20//!     BreadcrumbItem { name: "Home", path: "/" },
21//!     BreadcrumbItem { name: "Learn", path: "/learn" },
22//! ]);
23//! ```
24
25use dioxus::prelude::*;
26
27const BASE_URL: &str = "https://logicaffeine.com";
28const LOGO_URL: &str = "https://logicaffeine.com/assets/logo.jpeg";
29const ORG_NAME: &str = "LOGICAFFEINE";
30const GITHUB_URL: &str = "https://github.com/Brahmastra-Labs/logicaffeine";
31
32/// Generate Organization schema (used on all pages)
33pub fn organization_schema() -> String {
34    format!(
35        r#"{{
36  "@context": "https://schema.org",
37  "@type": "Organization",
38  "name": "{ORG_NAME}",
39  "url": "{BASE_URL}",
40  "logo": "{LOGO_URL}",
41  "description": "Turn everyday English into rigorous First-Order Logic. Debug your thoughts with precision.",
42  "sameAs": ["{GITHUB_URL}", "https://x.com/logicaffeine"]
43}}"#
44    )
45}
46
47/// Generate WebSite schema with search action
48pub fn website_schema() -> String {
49    format!(
50        r#"{{
51  "@context": "https://schema.org",
52  "@type": "WebSite",
53  "name": "{ORG_NAME}",
54  "url": "{BASE_URL}",
55  "description": "Turn everyday English into rigorous First-Order Logic",
56  "potentialAction": {{
57    "@type": "SearchAction",
58    "target": "{BASE_URL}/registry?q={{search_term}}",
59    "query-input": "required name=search_term"
60  }}
61}}"#
62    )
63}
64
65/// Generate SoftwareApplication schema (for Studio page)
66pub fn software_application_schema() -> String {
67    format!(
68        r#"{{
69  "@context": "https://schema.org",
70  "@type": "SoftwareApplication",
71  "name": "LOGICAFFEINE Studio",
72  "applicationCategory": "DeveloperApplication",
73  "operatingSystem": "Web Browser",
74  "description": "Interactive playground for experimenting with First-Order Logic translations",
75  "url": "{BASE_URL}/studio",
76  "offers": {{
77    "@type": "Offer",
78    "price": "0",
79    "priceCurrency": "USD"
80  }},
81  "provider": {{
82    "@type": "Organization",
83    "name": "{ORG_NAME}",
84    "url": "{BASE_URL}"
85  }}
86}}"#
87    )
88}
89
90/// Generate Course schema (for Learn page)
91pub fn course_schema() -> String {
92    format!(
93        r#"{{
94  "@context": "https://schema.org",
95  "@type": "Course",
96  "name": "First-Order Logic Fundamentals",
97  "description": "Learn to translate everyday English into rigorous First-Order Logic through interactive exercises and real-world examples.",
98  "url": "{BASE_URL}/learn",
99  "provider": {{
100    "@type": "Organization",
101    "name": "{ORG_NAME}",
102    "url": "{BASE_URL}"
103  }},
104  "educationalLevel": "Beginner to Advanced",
105  "isAccessibleForFree": true,
106  "inLanguage": "en",
107  "teaches": ["First-Order Logic", "Formal Logic", "Logical Reasoning", "Symbolic Logic"]
108}}"#
109    )
110}
111
112/// Generate Article schema for news articles
113pub fn article_schema(
114    headline: &str,
115    description: &str,
116    date_published: &str,
117    slug: &str,
118) -> String {
119    format!(
120        r#"{{
121  "@context": "https://schema.org",
122  "@type": "Article",
123  "headline": "{headline}",
124  "description": "{description}",
125  "datePublished": "{date_published}",
126  "dateModified": "{date_published}",
127  "url": "{BASE_URL}/news/{slug}",
128  "author": {{
129    "@type": "Organization",
130    "name": "{ORG_NAME}",
131    "url": "{BASE_URL}"
132  }},
133  "publisher": {{
134    "@type": "Organization",
135    "name": "{ORG_NAME}",
136    "logo": {{
137      "@type": "ImageObject",
138      "url": "{LOGO_URL}"
139    }}
140  }}
141}}"#
142    )
143}
144
145/// Generate FAQPage schema
146pub fn faq_schema(questions: &[(&str, &str)]) -> String {
147    let qa_items: Vec<String> = questions
148        .iter()
149        .map(|(q, a)| {
150            format!(
151                r#"{{
152      "@type": "Question",
153      "name": "{}",
154      "acceptedAnswer": {{
155        "@type": "Answer",
156        "text": "{}"
157      }}
158    }}"#,
159                q, a
160            )
161        })
162        .collect();
163
164    format!(
165        r#"{{
166  "@context": "https://schema.org",
167  "@type": "FAQPage",
168  "mainEntity": [
169    {}
170  ]
171}}"#,
172        qa_items.join(",\n    ")
173    )
174}
175
176/// Generate TechArticle schema (for Crates documentation)
177pub fn tech_article_schema(title: &str, description: &str, path: &str) -> String {
178    format!(
179        r#"{{
180  "@context": "https://schema.org",
181  "@type": "TechArticle",
182  "headline": "{title}",
183  "description": "{description}",
184  "url": "{BASE_URL}{path}",
185  "author": {{
186    "@type": "Organization",
187    "name": "{ORG_NAME}"
188  }},
189  "publisher": {{
190    "@type": "Organization",
191    "name": "{ORG_NAME}",
192    "logo": {{
193      "@type": "ImageObject",
194      "url": "{LOGO_URL}"
195    }}
196  }},
197  "proficiencyLevel": "Expert"
198}}"#
199    )
200}
201
202/// Generate ContactPage schema (for Contact page)
203pub fn contact_schema() -> String {
204    format!(
205        r#"{{
206  "@context": "https://schema.org",
207  "@type": "ContactPage",
208  "name": "Contact {ORG_NAME}",
209  "description": "Get in touch about commercial licensing, partnerships, or enterprise needs. Free for individuals, universities, and teams under 25.",
210  "url": "{BASE_URL}/pricing",
211  "publisher": {{
212    "@type": "Organization",
213    "name": "{ORG_NAME}",
214    "url": "{BASE_URL}"
215  }}
216}}"#
217    )
218}
219
220/// Breadcrumb item for schema generation
221pub struct BreadcrumbItem {
222    pub name: &'static str,
223    pub path: &'static str,
224}
225
226/// Generate BreadcrumbList schema
227pub fn breadcrumb_schema(items: &[BreadcrumbItem]) -> String {
228    let list_items: Vec<String> = items
229        .iter()
230        .enumerate()
231        .map(|(i, item)| {
232            format!(
233                r#"{{
234      "@type": "ListItem",
235      "position": {},
236      "name": "{}",
237      "item": "{}{}"
238    }}"#,
239                i + 1,
240                item.name,
241                BASE_URL,
242                item.path
243            )
244        })
245        .collect();
246
247    format!(
248        r#"{{
249  "@context": "https://schema.org",
250  "@type": "BreadcrumbList",
251  "itemListElement": [
252    {}
253  ]
254}}"#,
255        list_items.join(",\n    ")
256    )
257}
258
259/// Generate ItemList schema for Roadmap page
260pub fn roadmap_schema() -> String {
261    format!(
262        r#"{{
263  "@context": "https://schema.org",
264  "@type": "ItemList",
265  "name": "LOGICAFFEINE Development Roadmap",
266  "description": "Development milestones from English-to-Logic transpiler to universal compilation",
267  "url": "{BASE_URL}/roadmap",
268  "numberOfItems": 9,
269  "itemListElement": [
270    {{"@type": "ListItem", "position": 1, "name": "Core Transpiler", "description": "Parse English, produce First-Order Logic. 53+ linguistic phenomena."}},
271    {{"@type": "ListItem", "position": 2, "name": "Web Platform", "description": "Interactive learning, studio playground, gamification."}},
272    {{"@type": "ListItem", "position": 3, "name": "Imperative Language", "description": "Functions, structs, enums, pattern matching, standard library, I/O."}},
273    {{"@type": "ListItem", "position": 4, "name": "Type System", "description": "Refinement types, generics, type inference, sum types."}},
274    {{"@type": "ListItem", "position": 5, "name": "Concurrency & Actors", "description": "Channels, agents, structured parallelism, async/await."}},
275    {{"@type": "ListItem", "position": 6, "name": "Distributed Systems", "description": "CRDTs, P2P networking, persistent storage, conflict resolution."}},
276    {{"@type": "ListItem", "position": 7, "name": "Security & Policies", "description": "Capability-based security with policy blocks in plain English."}},
277    {{"@type": "ListItem", "position": 8, "name": "Proof Assistant", "description": "Curry-Howard in English. Trust statements, termination proofs, Z3 verification."}},
278    {{"@type": "ListItem", "position": 9, "name": "Universal Compilation", "description": "Compile to WASM. Live Codex IDE for real-time proof visualization."}}
279  ]
280}}"#
281    )
282}
283
284/// Generate WebPage schema for generic pages
285pub fn webpage_schema(name: &str, description: &str, path: &str) -> String {
286    format!(
287        r#"{{
288  "@context": "https://schema.org",
289  "@type": "WebPage",
290  "name": "{name}",
291  "description": "{description}",
292  "url": "{BASE_URL}{path}",
293  "isPartOf": {{
294    "@type": "WebSite",
295    "name": "{ORG_NAME}",
296    "url": "{BASE_URL}"
297  }},
298  "publisher": {{
299    "@type": "Organization",
300    "name": "{ORG_NAME}",
301    "url": "{BASE_URL}"
302  }}
303}}"#
304    )
305}
306
307/// Generate ProfilePage schema for user profile
308pub fn profile_page_schema() -> String {
309    format!(
310        r#"{{
311  "@context": "https://schema.org",
312  "@type": "ProfilePage",
313  "name": "User Profile - {ORG_NAME}",
314  "description": "Track your logic learning progress, achievements, and streaks.",
315  "url": "{BASE_URL}/profile",
316  "isPartOf": {{
317    "@type": "WebSite",
318    "name": "{ORG_NAME}",
319    "url": "{BASE_URL}"
320  }}
321}}"#
322    )
323}
324
325/// Page metadata for SEO
326pub struct PageMeta {
327    pub title: &'static str,
328    pub description: &'static str,
329    pub canonical_path: &'static str,
330    pub og_image: Option<&'static str>,
331}
332
333/// Default page metadata
334impl Default for PageMeta {
335    fn default() -> Self {
336        Self {
337            title: "LOGICAFFEINE | Debug Your Thoughts",
338            description: "Humanity's last programming language. Transform plain English into compiled Rust code with Z3-powered verification. Debug your thoughts with mathematical certainty.",
339            canonical_path: "/",
340            og_image: Some("/assets/OG-photo.png"),
341        }
342    }
343}
344
345/// Page-specific metadata definitions
346pub mod pages {
347    use super::PageMeta;
348
349    pub const LANDING: PageMeta = PageMeta {
350        title: "LOGICAFFEINE | Debug Your Thoughts",
351        description: "Humanity's last programming language. Transform plain English into compiled Rust code with Z3-powered verification. Debug your thoughts with mathematical certainty.",
352        canonical_path: "/",
353        og_image: Some("/assets/OG-photo.png"),
354    };
355
356    pub const LEARN: PageMeta = PageMeta {
357        title: "Learn First-Order Logic | LOGICAFFEINE",
358        description: "Master First-Order Logic through interactive exercises. From syllogisms to modal logic, learn to reason precisely.",
359        canonical_path: "/learn",
360        og_image: Some("/assets/OG-photo.png"),
361    };
362
363    pub const STUDIO: PageMeta = PageMeta {
364        title: "Studio | LOGICAFFEINE",
365        description: "Interactive playground for experimenting with First-Order Logic translations. Try examples and see results in real-time.",
366        canonical_path: "/studio",
367        og_image: Some("/assets/OG-photo.png"),
368    };
369
370    pub const GUIDE: PageMeta = PageMeta {
371        title: "Documentation | LOGICAFFEINE",
372        description: "Comprehensive guide to LOGICAFFEINE syntax, features, and First-Order Logic concepts.",
373        canonical_path: "/guide",
374        og_image: Some("/assets/OG-photo.png"),
375    };
376
377    pub const PRICING: PageMeta = PageMeta {
378        title: "Contact | LOGICAFFEINE",
379        description: "Get in touch about commercial licensing, partnerships, or enterprise needs. Free for individuals, universities, and teams under 25.",
380        canonical_path: "/pricing",
381        og_image: Some("/assets/OG-photo.png"),
382    };
383
384    pub const CRATES: PageMeta = PageMeta {
385        title: "Crates Documentation | LOGICAFFEINE",
386        description: "Technical documentation for LOGICAFFEINE Rust crates. Integrate First-Order Logic parsing into your applications.",
387        canonical_path: "/crates",
388        og_image: Some("/assets/OG-photo.png"),
389    };
390
391    pub const ROADMAP: PageMeta = PageMeta {
392        title: "Roadmap | LOGICAFFEINE",
393        description: "See what's coming next for LOGICAFFEINE. Track our progress and upcoming features.",
394        canonical_path: "/roadmap",
395        og_image: Some("/assets/OG-photo.png"),
396    };
397
398    pub const NEWS: PageMeta = PageMeta {
399        title: "News | LOGICAFFEINE",
400        description: "Latest updates, release notes, and announcements from LOGICAFFEINE.",
401        canonical_path: "/news",
402        og_image: Some("/assets/OG-photo.png"),
403    };
404
405    pub const PRIVACY: PageMeta = PageMeta {
406        title: "Privacy Policy | LOGICAFFEINE",
407        description: "How LOGICAFFEINE collects, uses, and protects your personal information. Read our full privacy policy.",
408        canonical_path: "/privacy",
409        og_image: Some("/assets/OG-photo.png"),
410    };
411
412    pub const TERMS: PageMeta = PageMeta {
413        title: "Terms of Service | LOGICAFFEINE",
414        description: "Terms and conditions for using LOGICAFFEINE. Business Source License details and usage policies.",
415        canonical_path: "/terms",
416        og_image: Some("/assets/OG-photo.png"),
417    };
418
419    pub const PROFILE: PageMeta = PageMeta {
420        title: "Your Profile | LOGICAFFEINE",
421        description: "Track your logic learning progress, achievements, XP, and streaks on LOGICAFFEINE.",
422        canonical_path: "/profile",
423        og_image: Some("/assets/OG-photo.png"),
424    };
425
426    pub const REGISTRY: PageMeta = PageMeta {
427        title: "Package Registry | LOGICAFFEINE",
428        description: "Browse and discover community-contributed logic modules and packages for LOGICAFFEINE.",
429        canonical_path: "/registry",
430        og_image: Some("/assets/OG-photo.png"),
431    };
432
433    pub const BENCHMARKS: PageMeta = PageMeta {
434        title: "Benchmarks | LOGICAFFEINE",
435        description: "LOGOS performance benchmarks across 13 languages. Near-native speed from English source code.",
436        canonical_path: "/benchmarks",
437        og_image: Some("/assets/OG-photo.png"),
438    };
439}
440
441/// Per-page head tags: title, description, Open Graph, Twitter Card, canonical.
442///
443/// Server renders (the SSG prerender) emit real `<meta>`/`<link>` elements via
444/// `document::*`, so every prerendered page carries its own share-card and
445/// canonical tags for crawlers and link unfurlers. The wasm client instead
446/// creates-or-patches the live head (the tags may come from a prerendered page
447/// OR be absent on an SPA-fallback route), keeping the DOM tag-set identical
448/// without ever duplicating an element.
449#[component]
450pub fn PageHead(
451    title: String,
452    description: String,
453    canonical_path: String,
454    #[props(default = String::from("/assets/OG-photo.png"))]
455    og_image: String,
456) -> Element {
457    let canonical_url = format!("{}{}", BASE_URL, canonical_path);
458    let image_url = format!("{}{}", BASE_URL, og_image);
459    // The default card is the resized 1200x630 OG photo; a page supplying its own
460    // image also owns that image's dimensions, so the size tags are skipped.
461    let default_card = og_image == "/assets/OG-photo.png";
462
463    #[cfg(target_arch = "wasm32")]
464    {
465        update_head_meta(&title, &description, &canonical_url, &image_url);
466    }
467
468    rsx! {
469        document::Title { "{title}" }
470        if cfg!(not(target_arch = "wasm32")) {
471            document::Meta { name: "title", content: title.clone() }
472            document::Meta { name: "description", content: description.clone() }
473            document::Meta { property: "og:type", content: "website" }
474            document::Meta { property: "og:url", content: canonical_url.clone() }
475            document::Meta { property: "og:title", content: title.clone() }
476            document::Meta { property: "og:description", content: description.clone() }
477            document::Meta { property: "og:image", content: image_url.clone() }
478            if default_card {
479                document::Meta { property: "og:image:width", content: "1200" }
480                document::Meta { property: "og:image:height", content: "630" }
481                document::Meta { property: "og:image:alt", content: "LOGICAFFEINE — Debug Your Thoughts" }
482            }
483            document::Meta { name: "twitter:url", content: canonical_url.clone() }
484            document::Meta { name: "twitter:title", content: title.clone() }
485            document::Meta { name: "twitter:description", content: description.clone() }
486            document::Meta { name: "twitter:image", content: image_url.clone() }
487            document::Link { rel: "canonical", href: canonical_url.clone() }
488        }
489    }
490}
491
492/// Create-or-patch `<meta>` and `<link>` tags in the live `<head>`.
493///
494/// Prerendered pages already carry these elements (patched in place); a route
495/// served by the SPA fallback shell has none, so missing elements are created.
496#[cfg(target_arch = "wasm32")]
497fn update_head_meta(title: &str, description: &str, canonical_url: &str, image_url: &str) {
498    let Some(window) = web_sys::window() else { return };
499    let Some(doc) = window.document() else { return };
500    let Some(head) = doc.head() else { return };
501
502    let set = |selector: &str, key_attr: &str, key: &str, attr: &str, value: &str| {
503        if let Ok(Some(el)) = doc.query_selector(selector) {
504            let _ = el.set_attribute(attr, value);
505        } else if let Ok(el) = doc.create_element(if key_attr == "rel" { "link" } else { "meta" }) {
506            let _ = el.set_attribute(key_attr, key);
507            let _ = el.set_attribute(attr, value);
508            let _ = head.append_child(&el);
509        }
510    };
511    let set_name = |name: &str, value: &str| {
512        set(&format!("meta[name='{name}']"), "name", name, "content", value);
513    };
514    let set_property = |property: &str, value: &str| {
515        set(&format!("meta[property='{property}']"), "property", property, "content", value);
516    };
517
518    // Primary
519    set_name("description", description);
520    set_name("title", title);
521    // Open Graph
522    set_property("og:type", "website");
523    set_property("og:url", canonical_url);
524    set_property("og:title", title);
525    set_property("og:description", description);
526    set_property("og:image", image_url);
527    // Twitter
528    set_name("twitter:url", canonical_url);
529    set_name("twitter:title", title);
530    set_name("twitter:description", description);
531    set_name("twitter:image", image_url);
532    // Canonical
533    set("link[rel='canonical']", "rel", "canonical", "href", canonical_url);
534}
535
536/// Render JSON-LD script tag for a schema
537#[component]
538pub fn JsonLd(schema: String) -> Element {
539    rsx! {
540        script {
541            r#type: "application/ld+json",
542            dangerous_inner_html: "{schema}"
543        }
544    }
545}
546
547/// Render multiple JSON-LD schemas
548#[component]
549pub fn JsonLdMultiple(schemas: Vec<String>) -> Element {
550    rsx! {
551        for schema in schemas.iter() {
552            script {
553                r#type: "application/ld+json",
554                dangerous_inner_html: "{schema}"
555            }
556        }
557    }
558}