Skip to main content

yog_book/
lib.rs

1//! yog-book — in-game book/documentation framework for Yog mods.
2//!
3//! Provides Patchouli-like book data model plus a full GPU renderer on top of
4//! yog-ui and yog-gfx, with SVG icon support and custom TTF/OTF fonts.
5
6pub mod font;
7pub mod renderer;
8pub mod state;
9pub mod svg;
10pub mod theme;
11
12use serde::{Deserialize, Serialize};
13use yog_registry::ItemDef;
14
15pub use font::{BookFont, BookFontRegistry};
16pub use renderer::BookRenderer;
17pub use state::BookViewState;
18pub use theme::BookTheme;
19
20// ── Macros ───────────────────────────────────────────────────────────────────
21
22/// A macro substitution (e.g. `$(thing)` → red color span).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BookMacro(pub String, pub String);
25
26// ── Page types ───────────────────────────────────────────────────────────────
27
28/// A single page variant inside a book entry.
29///
30/// The wire format (mod ↔ runtime JSON boundary, produced by
31/// [`BookPage::to_json`]) is internally tagged with snake_case names —
32/// `{"type": "spotlight", "item": "yog:ruby", ...}` — like Patchouli's
33/// per-page "type" field. The serde attributes below keep `Deserialize`
34/// in exact agreement with `to_json`.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum BookPage {
38    /// Plain formatted text, optionally with a section title (for non-first pages).
39    Text {
40        text: String,
41        #[serde(default)]
42        title: Option<String>,
43    },
44    /// Display an item outlined (tooltip on hover).
45    Spotlight {
46        /// On the wire this is the item id string ("yog:ruby"); rich
47        /// `ItemDef` maps are accepted too.
48        #[serde(with = "item_ref")]
49        item: ItemDef,
50        #[serde(default)]
51        title: Option<String>,
52        #[serde(default)]
53        text: Option<String>,
54    },
55    /// Crafting recipe display (autorenders 3×3 grid).
56    Crafting {
57        #[serde(rename = "recipe")]
58        recipe_id: String,
59        #[serde(default)]
60        text: Option<String>,
61    },
62    /// Smelting recipe display.
63    Smelting {
64        #[serde(rename = "recipe")]
65        recipe_id: String,
66        #[serde(default)]
67        text: Option<String>,
68    },
69    /// Image overlay page.
70    Image {
71        texture: String,
72        #[serde(default)]
73        title: Option<String>,
74        #[serde(default)]
75        text: Option<String>,
76        #[serde(default)]
77        border: bool,
78    },
79    /// Entity display page (renders a living entity in a box).
80    Entity {
81        #[serde(rename = "entity")]
82        entity_type: String,
83        #[serde(default)]
84        name: Option<String>,
85        #[serde(default)]
86        text: Option<String>,
87    },
88    /// Link to another entry (like Patchouli's relations).
89    Relations {
90        entries: Vec<String>,
91        #[serde(default)]
92        text: Option<String>,
93    },
94    /// Empty separator.
95    Empty,
96    /// Custom pattern page for Hexcasting-style mods (like `hexcasting:pattern`).
97    Pattern {
98        op_id: String,
99        #[serde(default)]
100        anchor: String,
101        #[serde(default)]
102        input: String,
103        #[serde(default)]
104        output: String,
105        #[serde(default)]
106        text: String,
107    },
108    /// SVG image page — rasterized at render time via `resvg`.
109    Svg {
110        /// Raw SVG source string.
111        data: String,
112        #[serde(default)]
113        title: Option<String>,
114        #[serde(default)]
115        text: Option<String>,
116    },
117    /// Text rendered with a custom TTF/OTF font.
118    CustomText {
119        text: String,
120        /// Flattened on the wire: `"font_id": …, "size_px": …`.
121        #[serde(flatten)]
122        font: BookFont,
123        /// ARGB color (0xAARRGGBB).
124        color: u32,
125    },
126}
127
128/// (De)serialize `ItemDef` as a plain item-id string ("yog:ruby"), accepting
129/// a full `ItemDef` map on input for richer definitions.
130mod item_ref {
131    use serde::{Deserialize, Deserializer, Serializer};
132    use yog_registry::ItemDef;
133
134    pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
135        s.serialize_str(&item.id)
136    }
137
138    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
139        #[derive(Deserialize)]
140        #[serde(untagged)]
141        enum Repr {
142            Id(String),
143            Def(ItemDef),
144        }
145        Ok(match Repr::deserialize(d)? {
146            Repr::Id(id) => ItemDef::new(id),
147            Repr::Def(def) => def,
148        })
149    }
150}
151
152// ── Category ─────────────────────────────────────────────────────────────────
153
154/// Represents a book category tab (e.g. "Basics", "Patterns").
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156#[serde(default)]
157pub struct BookCategory {
158    pub id: String,
159    pub name: String,
160    pub description: Option<String>,
161    /// MC texture path for the category icon (e.g. `"minecraft:textures/item/book.png"`).
162    pub icon: Option<String>,
163    /// Raw SVG string for the category icon (takes priority over `icon`).
164    pub icon_svg: Option<String>,
165    /// Sort priority (lower = first).
166    pub sortnum: i32,
167}
168
169// ── Entry ────────────────────────────────────────────────────────────────────
170
171/// One entry in a book (like a "page" in the TOC sidebar).
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173#[serde(default)]
174pub struct BookEntry {
175    pub id: String,
176    pub name: String,
177    pub category: String,
178    pub pages: Vec<BookPage>,
179    /// Entry icon (item id or texture path).
180    pub icon: Option<String>,
181    /// Raw SVG icon string (takes priority over `icon`).
182    pub icon_svg: Option<String>,
183    /// If true, hides from the book (used for unlocks).
184    pub secret: bool,
185    /// Sort priority (lower = first).
186    pub priority: i32,
187    /// If true, read by default when opening the book.
188    pub read_by_default: bool,
189    /// Advancement required to unlock.
190    pub advancement: Option<String>,
191}
192
193// ── Book ─────────────────────────────────────────────────────────────────────
194
195/// The top-level book definition — replaces `patchouli_books/<id>/book.json`.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(default)]
198pub struct Book {
199    pub id: String,
200    pub name: String,
201    pub nameplate_color: String,
202    pub landing_text: String,
203    pub author: Option<String>,
204    pub book_texture: String,
205    pub filler_texture: String,
206    pub model: String,
207    pub categories: Vec<BookCategory>,
208    pub entries: Vec<BookEntry>,
209    pub macros: Vec<BookMacro>,
210    pub use_resource_pack: bool,
211    pub show_progress: bool,
212    pub i18n: bool,
213    pub creative_tab: Option<String>,
214}
215
216impl Book {
217    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
218        Self {
219            id: id.into(),
220            name: name.into(),
221            nameplate_color: "FFDD98".into(), // Patchouli default nameplateColor
222            landing_text: String::new(),
223            author: None,
224            book_texture: "yog:gui/book".into(),
225            filler_texture: "yog:gui/book_filler".into(),
226            model: "minecraft:book".into(),
227            categories: Vec::new(),
228            entries: Vec::new(),
229            macros: Vec::new(),
230            use_resource_pack: false,
231            show_progress: true,
232            i18n: false,
233            creative_tab: None,
234        }
235    }
236
237    pub fn author(mut self, author: impl Into<String>) -> Self {
238        self.author = Some(author.into());
239        self
240    }
241
242    pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
243        self.book_texture = tex.into();
244        self
245    }
246
247    pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
248        self.filler_texture = tex.into();
249        self
250    }
251
252    pub fn nameplate(mut self, color: impl Into<String>) -> Self {
253        self.nameplate_color = color.into();
254        self
255    }
256
257    pub fn landing_text(mut self, text: impl Into<String>) -> Self {
258        self.landing_text = text.into();
259        self
260    }
261
262    pub fn model(mut self, model: impl Into<String>) -> Self {
263        self.model = model.into();
264        self
265    }
266
267    pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
268        self.creative_tab = Some(tab.into());
269        self
270    }
271
272    pub fn show_progress(mut self, show: bool) -> Self {
273        self.show_progress = show;
274        self
275    }
276
277    /// This book with its texts run through `tr` — names, descriptions,
278    /// landing text and every page's title/text. With [`Self::i18n`] on,
279    /// the texts are translation keys and the renderer shows
280    /// `localized(<current language>)` (see [`BookRenderer::set_translator`]).
281    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
282        let opt = |o: &mut Option<String>| {
283            if let Some(t) = o {
284                *t = tr(t);
285            }
286        };
287        let mut b = self.clone();
288        b.name = tr(&b.name);
289        b.landing_text = tr(&b.landing_text);
290        for c in &mut b.categories {
291            c.name = tr(&c.name);
292            opt(&mut c.description);
293        }
294        for e in &mut b.entries {
295            e.name = tr(&e.name);
296            for p in &mut e.pages {
297                match p {
298                    BookPage::Text { text, title } => {
299                        *text = tr(text);
300                        opt(title);
301                    }
302                    BookPage::Spotlight { title, text, .. }
303                    | BookPage::Image { title, text, .. }
304                    | BookPage::Svg { title, text, .. } => {
305                        opt(title);
306                        opt(text);
307                    }
308                    BookPage::Entity { name, text, .. } => {
309                        opt(name);
310                        opt(text);
311                    }
312                    BookPage::Crafting { text, .. }
313                    | BookPage::Smelting { text, .. }
314                    | BookPage::Relations { text, .. } => opt(text),
315                    BookPage::Pattern { text, .. } | BookPage::CustomText { text, .. } => *text = tr(text),
316                    BookPage::Empty => {}
317                }
318            }
319        }
320        b
321    }
322
323    pub fn i18n(mut self, val: bool) -> Self {
324        self.i18n = val;
325        self
326    }
327
328    pub fn use_resource_pack(mut self, val: bool) -> Self {
329        self.use_resource_pack = val;
330        self
331    }
332
333    pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
334        self.macros.push(BookMacro(key.into(), value.into()));
335        self
336    }
337
338    pub fn add_category(mut self, category: BookCategory) -> Self {
339        self.categories.push(category);
340        self
341    }
342
343    pub fn add_entry(mut self, entry: BookEntry) -> Self {
344        self.entries.push(entry);
345        self
346    }
347}
348
349impl Default for Book {
350    fn default() -> Self {
351        Self::new("yog:default", "Unknown Book")
352    }
353}
354
355// ── Registry ─────────────────────────────────────────────────────────────────
356
357/// Global registry for all in-game books.
358#[derive(Debug, Default)]
359pub struct BookRegistry {
360    books: std::collections::HashMap<String, Book>,
361}
362
363impl BookRegistry {
364    pub fn register(&mut self, book: Book) {
365        self.books.insert(book.id.clone(), book);
366    }
367
368    pub fn get(&self, id: &str) -> Option<&Book> {
369        self.books.get(id)
370    }
371
372    pub fn all(&self) -> impl Iterator<Item = &Book> {
373        self.books.values()
374    }
375}
376
377// ── Builder helpers ──────────────────────────────────────────────────────────
378
379pub fn text_page(text: impl Into<String>) -> BookPage {
380    BookPage::Text {
381        text: text.into(),
382        title: None,
383    }
384}
385
386pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
387    BookPage::Text {
388        text: text.into(),
389        title: Some(title.into()),
390    }
391}
392
393pub fn spotlight_page(item: ItemDef) -> BookPage {
394    BookPage::Spotlight {
395        item,
396        title: None,
397        text: None,
398    }
399}
400
401pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
402    BookPage::Crafting {
403        recipe_id: recipe_id.into(),
404        text: None,
405    }
406}
407
408pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
409    BookPage::Crafting {
410        recipe_id: recipe_id.into(),
411        text: Some(text.into()),
412    }
413}
414
415pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
416    BookPage::Smelting {
417        recipe_id: recipe_id.into(),
418        text: None,
419    }
420}
421
422pub fn image_page(texture: impl Into<String>) -> BookPage {
423    BookPage::Image {
424        texture: texture.into(),
425        title: None,
426        text: None,
427        border: true,
428    }
429}
430
431pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
432    BookPage::Entity {
433        entity_type: entity_type.into(),
434        name: None,
435        text: None,
436    }
437}
438
439pub fn relations_page(entries: Vec<String>) -> BookPage {
440    BookPage::Relations {
441        entries,
442        text: None,
443    }
444}
445
446pub fn pattern_page(
447    op_id: impl Into<String>,
448    anchor: impl Into<String>,
449    input: impl Into<String>,
450    output: impl Into<String>,
451    text: impl Into<String>,
452) -> BookPage {
453    BookPage::Pattern {
454        op_id: op_id.into(),
455        anchor: anchor.into(),
456        input: input.into(),
457        output: output.into(),
458        text: text.into(),
459    }
460}
461
462// ── Book → yog-ui bridge ─────────────────────────────────────────────────────
463pub mod book_ui {
464    use crate::{Book, BookEntry, BookPage};
465    use yog_ui::widget::{self, Widget};
466    use yog_ui::{Align, FlexDir, UiRoot};
467
468    /// Build a `UiRoot` from a `Book`.
469    /// The UI has: left panel (categories + entries), right panel (pages),
470    /// prev/next buttons at bottom.
471    pub fn build_book_ui(
472        book: &Book,
473        selected_cat: usize,
474        selected_entry: usize,
475        current_page: usize,
476    ) -> UiRoot {
477        let mut cats: Vec<Widget> = Vec::new();
478        for (i, cat) in book.categories.iter().enumerate() {
479            let color = if i == selected_cat {
480                0xFF_FFFF55
481            } else {
482                0xFF_CCCCCC
483            };
484            cats.push(
485                widget::button(&cat.name)
486                    .color(color)
487                    .on_click(format!("cat:{}", i)),
488            );
489        }
490
491        let cat = book.categories.get(selected_cat);
492        let mut entries: Vec<Widget> = Vec::new();
493        if let Some(cat) = cat {
494            let cat_entries: Vec<&BookEntry> = book
495                .entries
496                .iter()
497                .filter(|e| e.category == cat.id)
498                .collect();
499            for (i, entry) in cat_entries.iter().enumerate() {
500                let color = if i == selected_entry {
501                    0xFF_FFFF55
502                } else {
503                    0xFF_CCCCCC
504                };
505                let label = if entry.name.len() > 14 {
506                    &entry.name[..14]
507                } else {
508                    &entry.name
509                };
510                entries.push(
511                    widget::button(label)
512                        .color(color)
513                        .on_click(format!("entry:{}", i)),
514                );
515            }
516        }
517
518        let mut pages: Vec<Widget> = Vec::new();
519        if let Some(cat) = cat {
520            let cat_entries: Vec<&BookEntry> = book
521                .entries
522                .iter()
523                .filter(|e| e.category == cat.id)
524                .collect();
525            if let Some(entry) = cat_entries.get(selected_entry) {
526                if let Some(page) = entry.pages.get(current_page) {
527                    pages.push(render_page(page));
528                }
529            }
530        }
531
532        let nav = widget::panel(FlexDir::Row)
533            .gap(4.0)
534            .child(widget::button("<").w(28.0).on_click("prev_page"))
535            .child(
536                widget::label(format!(
537                    "{}/{}",
538                    current_page + 1,
539                    cat.map_or(0, |c| {
540                        book.entries
541                            .iter()
542                            .filter(|e| e.category == c.id)
543                            .nth(selected_entry)
544                            .map_or(0, |e| e.pages.len())
545                    })
546                ))
547                .color(0xFF_888888)
548                .flex(1.0)
549                .align(Align::Center),
550            )
551            .child(widget::button(">").w(28.0).on_click("next_page"));
552
553        UiRoot::new(
554            &book.id,
555            widget::panel(FlexDir::Row)
556                .gap(2.0)
557                .padding(2.0, 2.0, 2.0, 2.0)
558                .bg(0xFF_2A1A0E)
559                .child(
560                    widget::panel(FlexDir::Column)
561                        .w(104.0)
562                        .child(widget::label("Categories").color(0xFF_888888))
563                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
564                        .child(widget::label("Entries").color(0xFF_888888))
565                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
566                )
567                .child(
568                    widget::panel(FlexDir::Column)
569                        .flex(1.0)
570                        .gap(2.0)
571                        .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
572                        .child(nav),
573                ),
574        )
575    }
576
577    fn render_page(page: &BookPage) -> Widget {
578        match page {
579            BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
580            BookPage::Spotlight { item, title, text } => {
581                let mut p = widget::panel(FlexDir::Column).gap(2.0);
582                if let Some(t) = title {
583                    p = p.child(widget::label(t).color(0xFF_FFFF55));
584                }
585                p = p.child(widget::item_slot(&item.id));
586                if let Some(t) = text {
587                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
588                }
589                p
590            }
591            BookPage::Crafting { recipe_id, text } => {
592                let mut p = widget::panel(FlexDir::Column).gap(2.0);
593                p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
594                if let Some(t) = text {
595                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
596                }
597                p
598            }
599            BookPage::Smelting { recipe_id, text } => {
600                let mut p = widget::panel(FlexDir::Column).gap(2.0);
601                p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
602                if let Some(t) = text {
603                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
604                }
605                p
606            }
607            BookPage::Empty => widget::spacer(),
608            _ => widget::label("(unsupported page)").color(0xFF_888888),
609        }
610    }
611
612    // Helper: add multiple children to a widget
613    trait WidgetExt {
614        fn child_many(self, children: Vec<Widget>) -> Self;
615    }
616    impl WidgetExt for Widget {
617        fn child_many(mut self, children: Vec<Widget>) -> Self {
618            for c in children {
619                self = self.child(c);
620            }
621            self
622        }
623    }
624}
625
626// ── JSON serialization ────────────────────────────────────────────────────────
627
628fn esc(s: &str) -> String {
629    let mut out = String::with_capacity(s.len());
630    for ch in s.chars() {
631        match ch {
632            '\\' => out.push_str("\\\\"),
633            '"' => out.push_str("\\\""),
634            '\n' => out.push_str("\\n"),
635            '\r' => out.push_str("\\r"),
636            '\t' => out.push_str("\\t"),
637            c if c.is_control() => {
638                out.push_str(&format!("\\u{:04x}", c as u32));
639            }
640            _ => out.push(ch),
641        }
642    }
643    out
644}
645
646impl BookPage {
647    pub fn to_json(&self) -> String {
648        match self {
649            Self::Text { text, title } => {
650                let t = title
651                    .as_deref()
652                    .map(|s| format!(r#","title":"{}""#, esc(s)))
653                    .unwrap_or_default();
654                format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
655            }
656            Self::Spotlight { item, title, text } => {
657                let t = title
658                    .as_deref()
659                    .map(|s| format!(r#","title":"{}""#, esc(s)))
660                    .unwrap_or_default();
661                let tx = text
662                    .as_deref()
663                    .map(|s| format!(r#","text":"{}""#, esc(s)))
664                    .unwrap_or_default();
665                format!(
666                    r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#,
667                    id = esc(&item.id)
668                )
669            }
670            Self::Crafting { recipe_id, text } => {
671                let tx = text
672                    .as_deref()
673                    .map(|s| format!(r#","text":"{}""#, esc(s)))
674                    .unwrap_or_default();
675                format!(
676                    r#"{{"type":"crafting","recipe":"{}"{}}}"#,
677                    esc(recipe_id),
678                    tx
679                )
680            }
681            Self::Smelting { recipe_id, text } => {
682                let tx = text
683                    .as_deref()
684                    .map(|s| format!(r#","text":"{}""#, esc(s)))
685                    .unwrap_or_default();
686                format!(
687                    r#"{{"type":"smelting","recipe":"{}"{}}}"#,
688                    esc(recipe_id),
689                    tx
690                )
691            }
692            Self::Image {
693                texture,
694                title,
695                text,
696                border,
697            } => {
698                let t = title
699                    .as_deref()
700                    .map(|s| format!(r#","title":"{}""#, esc(s)))
701                    .unwrap_or_default();
702                let tx = text
703                    .as_deref()
704                    .map(|s| format!(r#","text":"{}""#, esc(s)))
705                    .unwrap_or_default();
706                format!(
707                    r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
708                    esc(texture),
709                    border,
710                    t,
711                    tx
712                )
713            }
714            Self::Entity {
715                entity_type,
716                name,
717                text,
718            } => {
719                let n = name
720                    .as_deref()
721                    .map(|s| format!(r#","name":"{}""#, esc(s)))
722                    .unwrap_or_default();
723                let tx = text
724                    .as_deref()
725                    .map(|s| format!(r#","text":"{}""#, esc(s)))
726                    .unwrap_or_default();
727                format!(
728                    r#"{{"type":"entity","entity":"{}"{}{}}}"#,
729                    esc(entity_type),
730                    n,
731                    tx
732                )
733            }
734            Self::Relations { entries, text } => {
735                let e: String = entries
736                    .iter()
737                    .map(|s| format!(r#""{}""#, esc(s)))
738                    .collect::<Vec<_>>()
739                    .join(",");
740                let tx = text
741                    .as_deref()
742                    .map(|s| format!(r#","text":"{}""#, esc(s)))
743                    .unwrap_or_default();
744                format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
745            }
746            Self::Empty => r#"{"type":"empty"}"#.to_string(),
747            Self::Pattern {
748                op_id,
749                anchor,
750                input,
751                output,
752                text,
753            } => format!(
754                r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
755                esc(op_id),
756                esc(anchor),
757                esc(input),
758                esc(output),
759                esc(text)
760            ),
761            Self::Svg { data, title, text } => {
762                let t = title
763                    .as_deref()
764                    .map(|s| format!(r#","title":"{}""#, esc(s)))
765                    .unwrap_or_default();
766                let tx = text
767                    .as_deref()
768                    .map(|s| format!(r#","text":"{}""#, esc(s)))
769                    .unwrap_or_default();
770                format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
771            }
772            Self::CustomText { text, font, color } => format!(
773                r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
774                esc(text),
775                esc(&font.font_id),
776                font.size_px,
777                color
778            ),
779        }
780    }
781}
782
783impl BookEntry {
784    pub fn to_json(&self) -> String {
785        let pages: String = self
786            .pages
787            .iter()
788            .map(|p| p.to_json())
789            .collect::<Vec<_>>()
790            .join(",");
791        let icon = self
792            .icon
793            .as_deref()
794            .map(|s| format!(r#","icon":"{}""#, esc(s)))
795            .unwrap_or_default();
796        let adv = self
797            .advancement
798            .as_deref()
799            .map(|s| format!(r#","advancement":"{}""#, esc(s)))
800            .unwrap_or_default();
801        format!(
802            r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
803            esc(&self.id),
804            esc(&self.name),
805            esc(&self.category),
806            pages,
807            self.secret,
808            self.priority,
809            self.read_by_default,
810            icon,
811            adv
812        )
813    }
814}
815
816impl BookCategory {
817    pub fn to_json(&self) -> String {
818        let desc = self
819            .description
820            .as_deref()
821            .map(|s| format!(r#","description":"{}""#, esc(s)))
822            .unwrap_or_default();
823        let icon = self
824            .icon
825            .as_deref()
826            .map(|s| format!(r#","icon":"{}""#, esc(s)))
827            .unwrap_or_default();
828        format!(
829            r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
830            esc(&self.id),
831            esc(&self.name),
832            self.sortnum,
833            desc,
834            icon
835        )
836    }
837}
838
839impl Book {
840    pub fn to_json(&self) -> String {
841        let cats: String = self
842            .categories
843            .iter()
844            .map(|c| c.to_json())
845            .collect::<Vec<_>>()
846            .join(",");
847        let entries: String = self
848            .entries
849            .iter()
850            .map(|e| e.to_json())
851            .collect::<Vec<_>>()
852            .join(",");
853        let author = self
854            .author
855            .as_deref()
856            .map(|s| format!(r#","author":"{}""#, esc(s)))
857            .unwrap_or_default();
858        let tab = self
859            .creative_tab
860            .as_deref()
861            .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
862            .unwrap_or_default();
863        format!(
864            r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
865            esc(&self.id),
866            esc(&self.name),
867            esc(&self.nameplate_color),
868            esc(&self.landing_text),
869            esc(&self.book_texture),
870            esc(&self.filler_texture),
871            esc(&self.model),
872            self.show_progress,
873            self.i18n,
874            self.use_resource_pack,
875            cats,
876            entries,
877            author,
878            tab
879        )
880    }
881}
882// ── Tests ─────────────────────────────────────────────────────────────────────
883
884#[cfg(test)]
885mod wire_format_tests {
886    use super::*;
887
888    /// The hand-written `to_json` wire format must stay parseable by the
889    /// serde `Deserialize` impls (runtime side of the mod ↔ runtime boundary).
890    #[test]
891    fn book_to_json_roundtrips_through_serde() {
892        let book = Book::new("yog:test", "Test Book")
893            .author("Tester")
894            .landing_text("hello")
895            .add_category(BookCategory {
896                id: "c1".into(),
897                name: "Cat".into(),
898                description: Some("d".into()),
899                icon: Some("yog:item/ruby".into()),
900                icon_svg: None,
901                sortnum: 0,
902            })
903            .add_entry(BookEntry {
904                id: "e1".into(),
905                name: "Entry".into(),
906                category: "c1".into(),
907                pages: vec![
908                    text_page("plain"),
909                    spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
910                    crafting_page("yog:r1"),
911                    smelting_page("yog:r2"),
912                    BookPage::Empty,
913                ],
914                icon: Some("yog:ruby".into()),
915                icon_svg: None,
916                secret: false,
917                priority: 0,
918                read_by_default: false,
919                advancement: None,
920            });
921
922        let json = book.to_json();
923        let parsed: Book = serde_json::from_str(&json)
924            .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
925        assert_eq!(parsed.id, "yog:test");
926        assert_eq!(parsed.entries.len(), 1);
927        assert_eq!(parsed.entries[0].pages.len(), 5);
928        match &parsed.entries[0].pages[1] {
929            BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
930            p => panic!("expected spotlight, got {p:?}"),
931        }
932        match &parsed.entries[0].pages[2] {
933            BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
934            p => panic!("expected crafting, got {p:?}"),
935        }
936    }
937}
938
939#[cfg(test)]
940mod localize_tests {
941    use super::*;
942
943    #[test]
944    fn localized_translates_every_text() {
945        let book = Book::new("m:b", "m.book.name")
946            .landing_text("m.book.landing")
947            .add_category(BookCategory { id: "basics".into(), name: "m.cat".into(), ..Default::default() })
948            .add_entry(BookEntry {
949                id: "e".into(),
950                name: "m.entry".into(),
951                category: "basics".into(),
952                pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
953                ..Default::default()
954            });
955        let out = book.localized(|k| if k.starts_with("m.") { k.to_uppercase() } else { k.to_string() });
956        assert_eq!(out.name, "M.BOOK.NAME");
957        assert_eq!(out.landing_text, "M.BOOK.LANDING");
958        assert_eq!(out.categories[0].name, "M.CAT");
959        assert_eq!(out.entries[0].name, "M.ENTRY");
960        match &out.entries[0].pages[0] {
961            BookPage::Text { text, title } => {
962                assert_eq!(text, "M.TEXT");
963                assert_eq!(title.as_deref(), Some("M.TITLE"));
964            }
965            p => panic!("{p:?}"),
966        }
967        assert!(matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text"));
968        assert_eq!(out.entries[0].id, "e");
969    }
970}