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), and with a sales contact point (Pricing page)
5//! - WebSite schema with search action
6//! - SoftwareApplication schema (Studio page)
7//! - Course schema (Learn page)
8//! - Article schema (News articles)
9//! - FAQPage schema (Landing and Pricing pages)
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
32fn organization() -> serde_json::Value {
33    serde_json::json!({
34        "@context": "https://schema.org",
35        "@type": "Organization",
36        "name": ORG_NAME,
37        "url": BASE_URL,
38        "logo": LOGO_URL,
39        "description": "Turn everyday English into rigorous First-Order Logic. Debug your thoughts with precision.",
40        "sameAs": [GITHUB_URL, "https://x.com/logicaffeine"],
41    })
42}
43
44/// Generate Organization schema (used on all pages)
45pub fn organization_schema() -> String {
46    organization().to_string()
47}
48
49/// Generate Organization schema that names where to ask for a price: the sales `ContactPoint`
50/// reached at `email` and at the page `contact_path`.
51pub fn organization_contact_schema(email: &str, contact_path: &str) -> String {
52    let mut org = organization();
53    org["contactPoint"] = serde_json::json!({
54        "@type": "ContactPoint",
55        "contactType": "sales",
56        "email": email,
57        "url": format!("{BASE_URL}{contact_path}"),
58        "availableLanguage": "English",
59    });
60    org.to_string()
61}
62
63/// Generate WebSite schema with search action
64pub fn website_schema() -> String {
65    format!(
66        r#"{{
67  "@context": "https://schema.org",
68  "@type": "WebSite",
69  "name": "{ORG_NAME}",
70  "url": "{BASE_URL}",
71  "description": "Turn everyday English into rigorous First-Order Logic",
72  "potentialAction": {{
73    "@type": "SearchAction",
74    "target": "{BASE_URL}/registry?q={{search_term}}",
75    "query-input": "required name=search_term"
76  }}
77}}"#
78    )
79}
80
81/// Generate SoftwareApplication schema (for Studio page)
82pub fn software_application_schema() -> String {
83    format!(
84        r#"{{
85  "@context": "https://schema.org",
86  "@type": "SoftwareApplication",
87  "name": "LOGICAFFEINE Studio",
88  "applicationCategory": "DeveloperApplication",
89  "operatingSystem": "Web Browser",
90  "description": "Interactive playground for experimenting with First-Order Logic translations",
91  "url": "{BASE_URL}/studio",
92  "offers": {{
93    "@type": "Offer",
94    "price": "0",
95    "priceCurrency": "USD"
96  }},
97  "provider": {{
98    "@type": "Organization",
99    "name": "{ORG_NAME}",
100    "url": "{BASE_URL}"
101  }}
102}}"#
103    )
104}
105
106/// Generate Course schema (for Learn page)
107pub fn course_schema() -> String {
108    format!(
109        r#"{{
110  "@context": "https://schema.org",
111  "@type": "Course",
112  "name": "First-Order Logic Fundamentals",
113  "description": "Learn to translate everyday English into rigorous First-Order Logic through interactive exercises and real-world examples.",
114  "url": "{BASE_URL}/learn",
115  "provider": {{
116    "@type": "Organization",
117    "name": "{ORG_NAME}",
118    "url": "{BASE_URL}"
119  }},
120  "educationalLevel": "Beginner to Advanced",
121  "isAccessibleForFree": true,
122  "inLanguage": "en",
123  "teaches": ["First-Order Logic", "Formal Logic", "Logical Reasoning", "Symbolic Logic"]
124}}"#
125    )
126}
127
128/// Generate Article schema for news articles
129pub fn article_schema(
130    headline: &str,
131    description: &str,
132    date_published: &str,
133    slug: &str,
134) -> String {
135    format!(
136        r#"{{
137  "@context": "https://schema.org",
138  "@type": "Article",
139  "headline": "{headline}",
140  "description": "{description}",
141  "datePublished": "{date_published}",
142  "dateModified": "{date_published}",
143  "url": "{BASE_URL}/news/{slug}",
144  "author": {{
145    "@type": "Organization",
146    "name": "{ORG_NAME}",
147    "url": "{BASE_URL}"
148  }},
149  "publisher": {{
150    "@type": "Organization",
151    "name": "{ORG_NAME}",
152    "logo": {{
153      "@type": "ImageObject",
154      "url": "{LOGO_URL}"
155    }}
156  }}
157}}"#
158    )
159}
160
161/// Generate FAQPage schema from (question, answer) pairs. Built as JSON, so a quote in an
162/// answer is escaped rather than ending the string.
163pub fn faq_schema(questions: &[(&str, &str)]) -> String {
164    let items: Vec<serde_json::Value> = questions
165        .iter()
166        .map(|(question, answer)| {
167            serde_json::json!({
168                "@type": "Question",
169                "name": question,
170                "acceptedAnswer": { "@type": "Answer", "text": answer },
171            })
172        })
173        .collect();
174    serde_json::json!({
175        "@context": "https://schema.org",
176        "@type": "FAQPage",
177        "mainEntity": items,
178    })
179    .to_string()
180}
181
182/// Generate TechArticle schema (for Crates documentation)
183pub fn tech_article_schema(title: &str, description: &str, path: &str) -> String {
184    format!(
185        r#"{{
186  "@context": "https://schema.org",
187  "@type": "TechArticle",
188  "headline": "{title}",
189  "description": "{description}",
190  "url": "{BASE_URL}{path}",
191  "author": {{
192    "@type": "Organization",
193    "name": "{ORG_NAME}"
194  }},
195  "publisher": {{
196    "@type": "Organization",
197    "name": "{ORG_NAME}",
198    "logo": {{
199      "@type": "ImageObject",
200      "url": "{LOGO_URL}"
201    }}
202  }},
203  "proficiencyLevel": "Expert"
204}}"#
205    )
206}
207
208/// Breadcrumb item for schema generation
209pub struct BreadcrumbItem {
210    pub name: &'static str,
211    pub path: &'static str,
212}
213
214/// Generate BreadcrumbList schema
215pub fn breadcrumb_schema(items: &[BreadcrumbItem]) -> String {
216    let list_items: Vec<String> = items
217        .iter()
218        .enumerate()
219        .map(|(i, item)| {
220            format!(
221                r#"{{
222      "@type": "ListItem",
223      "position": {},
224      "name": "{}",
225      "item": "{}{}"
226    }}"#,
227                i + 1,
228                item.name,
229                BASE_URL,
230                item.path
231            )
232        })
233        .collect();
234
235    format!(
236        r#"{{
237  "@context": "https://schema.org",
238  "@type": "BreadcrumbList",
239  "itemListElement": [
240    {}
241  ]
242}}"#,
243        list_items.join(",\n    ")
244    )
245}
246
247/// Generate ItemList schema for Roadmap page: one `ListItem` per timeline card, in page order,
248/// each described by its status and the first sentence of its card, so search engines read the
249/// roadmap the page shows.
250pub fn roadmap_schema() -> String {
251    let milestones = crate::ui::pages::roadmap_data::get_milestones();
252    let items: Vec<serde_json::Value> = milestones
253        .iter()
254        .enumerate()
255        .map(|(i, m)| {
256            serde_json::json!({
257                "@type": "ListItem",
258                "position": i + 1,
259                "name": m.title,
260                "description": format!("{} — {}", m.status.label(), first_sentence(m.description)),
261            })
262        })
263        .collect();
264    serde_json::json!({
265        "@context": "https://schema.org",
266        "@type": "ItemList",
267        "name": "LOGICAFFEINE Development Roadmap",
268        "description": "Where LOGOS stands: finished milestones, work in progress, and what is planned next.",
269        "url": format!("{BASE_URL}/roadmap"),
270        "numberOfItems": milestones.len(),
271        "itemListElement": items,
272    })
273    .to_string()
274}
275
276/// A description's first sentence: up to and including the first period that ends a word.
277fn first_sentence(text: &str) -> &str {
278    match text.find(". ") {
279        Some(end) => &text[..=end],
280        None => text,
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/// Page metadata for SEO
308pub struct PageMeta {
309    pub title: &'static str,
310    pub description: &'static str,
311    pub canonical_path: &'static str,
312    pub og_image: Option<&'static str>,
313}
314
315/// Default page metadata
316impl Default for PageMeta {
317    fn default() -> Self {
318        Self {
319            title: "LOGICAFFEINE | Debug Your Thoughts",
320            description: "Humanity's last programming language. Transform plain English into compiled Rust code with Z3-powered verification. Debug your thoughts with mathematical certainty.",
321            canonical_path: "/",
322            og_image: Some("/assets/OG-photo.png"),
323        }
324    }
325}
326
327/// Page-specific metadata definitions
328pub mod pages {
329    use super::PageMeta;
330
331    pub const LANDING: PageMeta = PageMeta {
332        title: "LOGICAFFEINE | Code we can all read",
333        description: "LOGOS is an English programming language with logic and mathematics built in. Bring your own coding agent: give it the instructions, then read every line it writes.",
334        canonical_path: "/",
335        og_image: Some("/assets/OG-photo.png"),
336    };
337
338    pub const LEARN: PageMeta = PageMeta {
339        title: "Learn First-Order Logic | LOGICAFFEINE",
340        description: "Master First-Order Logic through interactive exercises. From syllogisms to modal logic, learn to reason precisely.",
341        canonical_path: "/learn",
342        og_image: Some("/assets/OG-photo.png"),
343    };
344
345    pub const STUDIO: PageMeta = PageMeta {
346        title: "Studio | LOGICAFFEINE",
347        description: "Interactive playground for experimenting with First-Order Logic translations. Try examples and see results in real-time.",
348        canonical_path: "/studio",
349        og_image: Some("/assets/OG-photo.png"),
350    };
351
352    pub const GUIDE: PageMeta = PageMeta {
353        title: "Documentation | LOGICAFFEINE",
354        description:
355            "Comprehensive guide to LOGICAFFEINE syntax, features, and First-Order Logic concepts.",
356        canonical_path: "/guide",
357        og_image: Some("/assets/OG-photo.png"),
358    };
359
360    pub const CONTACT: PageMeta = PageMeta {
361        title: "Contact | LOGICAFFEINE",
362        description: "Reach the team behind LOGOS, the open source English programming language under the MIT License: questions, bugs, working together.",
363        canonical_path: "/contact",
364        og_image: Some("/assets/OG-photo.png"),
365    };
366
367    pub const CRATES: PageMeta = PageMeta {
368        title: "Crates Documentation | LOGICAFFEINE",
369        description: "The Rust crates behind LOGOS: the English-to-logic front end, the compiler and its execution tiers, the proof kernel, and the tools.",
370        canonical_path: "/crates",
371        og_image: Some("/assets/OG-photo.png"),
372    };
373
374    pub const ROADMAP: PageMeta = PageMeta {
375        title: "Roadmap | LOGICAFFEINE",
376        description:
377            "See what's coming next for LOGICAFFEINE. Track our progress and upcoming features.",
378        canonical_path: "/roadmap",
379        og_image: Some("/assets/OG-photo.png"),
380    };
381
382    pub const NEWS: PageMeta = PageMeta {
383        title: "News | LOGICAFFEINE",
384        description: "Latest updates, release notes, and announcements from LOGICAFFEINE.",
385        canonical_path: "/news",
386        og_image: Some("/assets/OG-photo.png"),
387    };
388
389    pub const PRIVACY: PageMeta = PageMeta {
390        title: "Privacy Policy | LOGICAFFEINE",
391        description: "How LOGICAFFEINE collects, uses, and protects your personal information. Read our full privacy policy.",
392        canonical_path: "/privacy",
393        og_image: Some("/assets/OG-photo.png"),
394    };
395
396    pub const TERMS: PageMeta = PageMeta {
397        title: "Terms of Service | LOGICAFFEINE",
398        description: "Terms and conditions for using LOGICAFFEINE and its MIT-licensed software.",
399        canonical_path: "/terms",
400        og_image: Some("/assets/OG-photo.png"),
401    };
402
403    pub const REGISTRY: PageMeta = PageMeta {
404        title: "Package Registry | LOGICAFFEINE",
405        description:
406            "Browse and discover community-contributed logic modules and packages for LOGICAFFEINE.",
407        canonical_path: "/registry",
408        og_image: Some("/assets/OG-photo.png"),
409    };
410
411    pub const BENCHMARKS: PageMeta = PageMeta {
412        title: "Benchmarks | LOGICAFFEINE",
413        description: "LOGOS performance benchmarks across 13 languages. Near-native speed from English source code.",
414        canonical_path: "/benchmarks",
415        og_image: Some("/assets/OG-photo.png"),
416    };
417}
418
419/// Per-page head tags: title, description, Open Graph, Twitter Card, canonical.
420///
421/// Server renders (the SSG prerender) emit real `<meta>`/`<link>` elements via
422/// `document::*`, so every prerendered page carries its own share-card and
423/// canonical tags for crawlers and link unfurlers. The wasm client instead
424/// creates-or-patches the live head (the tags may come from a prerendered page
425/// OR be absent on an SPA-fallback route), keeping the DOM tag-set identical
426/// without ever duplicating an element.
427#[component]
428pub fn PageHead(
429    title: String,
430    description: String,
431    canonical_path: String,
432    #[props(default = String::from("/assets/OG-photo.png"))] og_image: String,
433) -> Element {
434    let canonical_url = format!("{}{}", BASE_URL, canonical_path);
435    let image_url = format!("{}{}", BASE_URL, og_image);
436    // The default card is the resized 1200x630 OG photo; a page supplying its own
437    // image also owns that image's dimensions, so the size tags are skipped.
438    let default_card = og_image == "/assets/OG-photo.png";
439
440    #[cfg(target_arch = "wasm32")]
441    {
442        update_head_meta(&title, &description, &canonical_url, &image_url);
443    }
444
445    rsx! {
446        document::Title { "{title}" }
447        if cfg!(not(target_arch = "wasm32")) {
448            document::Meta { name: "title", content: title.clone() }
449            document::Meta { name: "description", content: description.clone() }
450            document::Meta { property: "og:type", content: "website" }
451            document::Meta { property: "og:url", content: canonical_url.clone() }
452            document::Meta { property: "og:title", content: title.clone() }
453            document::Meta { property: "og:description", content: description.clone() }
454            document::Meta { property: "og:image", content: image_url.clone() }
455            if default_card {
456                document::Meta { property: "og:image:width", content: "1200" }
457                document::Meta { property: "og:image:height", content: "630" }
458                document::Meta { property: "og:image:alt", content: "LOGICAFFEINE | Debug Your Thoughts" }
459            }
460            document::Meta { name: "twitter:url", content: canonical_url.clone() }
461            document::Meta { name: "twitter:title", content: title.clone() }
462            document::Meta { name: "twitter:description", content: description.clone() }
463            document::Meta { name: "twitter:image", content: image_url.clone() }
464            document::Link { rel: "canonical", href: canonical_url.clone() }
465        }
466    }
467}
468
469/// Create-or-patch `<meta>` and `<link>` tags in the live `<head>`.
470///
471/// Prerendered pages already carry these elements (patched in place); a route
472/// served by the SPA fallback shell has none, so missing elements are created.
473#[cfg(target_arch = "wasm32")]
474fn update_head_meta(title: &str, description: &str, canonical_url: &str, image_url: &str) {
475    let Some(window) = web_sys::window() else {
476        return;
477    };
478    let Some(doc) = window.document() else { return };
479    let Some(head) = doc.head() else { return };
480
481    let set = |selector: &str, key_attr: &str, key: &str, attr: &str, value: &str| {
482        if let Ok(Some(el)) = doc.query_selector(selector) {
483            let _ = el.set_attribute(attr, value);
484        } else if let Ok(el) = doc.create_element(if key_attr == "rel" { "link" } else { "meta" }) {
485            let _ = el.set_attribute(key_attr, key);
486            let _ = el.set_attribute(attr, value);
487            let _ = head.append_child(&el);
488        }
489    };
490    let set_name = |name: &str, value: &str| {
491        set(
492            &format!("meta[name='{name}']"),
493            "name",
494            name,
495            "content",
496            value,
497        );
498    };
499    let set_property = |property: &str, value: &str| {
500        set(
501            &format!("meta[property='{property}']"),
502            "property",
503            property,
504            "content",
505            value,
506        );
507    };
508
509    // Primary
510    set_name("description", description);
511    set_name("title", title);
512    // Open Graph
513    set_property("og:type", "website");
514    set_property("og:url", canonical_url);
515    set_property("og:title", title);
516    set_property("og:description", description);
517    set_property("og:image", image_url);
518    // Twitter
519    set_name("twitter:url", canonical_url);
520    set_name("twitter:title", title);
521    set_name("twitter:description", description);
522    set_name("twitter:image", image_url);
523    // Canonical
524    set(
525        "link[rel='canonical']",
526        "rel",
527        "canonical",
528        "href",
529        canonical_url,
530    );
531}
532
533/// Render JSON-LD script tag for a schema
534#[component]
535pub fn JsonLd(schema: String) -> Element {
536    rsx! {
537        script {
538            r#type: "application/ld+json",
539            dangerous_inner_html: "{schema}"
540        }
541    }
542}
543
544/// Render multiple JSON-LD schemas
545#[component]
546pub fn JsonLdMultiple(schemas: Vec<String>) -> Element {
547    rsx! {
548        for schema in schemas.iter() {
549            script {
550                r#type: "application/ld+json",
551                dangerous_inner_html: "{schema}"
552            }
553        }
554    }
555}