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 mod-defined page (e.g. `example: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, .. } => {
316                        *text = tr(text)
317                    }
318                    BookPage::Empty => {}
319                }
320            }
321        }
322        b
323    }
324
325    pub fn i18n(mut self, val: bool) -> Self {
326        self.i18n = val;
327        self
328    }
329
330    pub fn use_resource_pack(mut self, val: bool) -> Self {
331        self.use_resource_pack = val;
332        self
333    }
334
335    pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
336        self.macros.push(BookMacro(key.into(), value.into()));
337        self
338    }
339
340    pub fn add_category(mut self, category: BookCategory) -> Self {
341        self.categories.push(category);
342        self
343    }
344
345    pub fn add_entry(mut self, entry: BookEntry) -> Self {
346        self.entries.push(entry);
347        self
348    }
349}
350
351impl Default for Book {
352    fn default() -> Self {
353        Self::new("yog:default", "Unknown Book")
354    }
355}
356
357// ── Registry ─────────────────────────────────────────────────────────────────
358
359/// Global registry for all in-game books.
360#[derive(Debug, Default)]
361pub struct BookRegistry {
362    books: std::collections::HashMap<String, Book>,
363}
364
365impl BookRegistry {
366    pub fn register(&mut self, book: Book) {
367        self.books.insert(book.id.clone(), book);
368    }
369
370    pub fn get(&self, id: &str) -> Option<&Book> {
371        self.books.get(id)
372    }
373
374    pub fn all(&self) -> impl Iterator<Item = &Book> {
375        self.books.values()
376    }
377}
378
379// ── Builder helpers ──────────────────────────────────────────────────────────
380
381pub fn text_page(text: impl Into<String>) -> BookPage {
382    BookPage::Text {
383        text: text.into(),
384        title: None,
385    }
386}
387
388pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
389    BookPage::Text {
390        text: text.into(),
391        title: Some(title.into()),
392    }
393}
394
395pub fn spotlight_page(item: ItemDef) -> BookPage {
396    BookPage::Spotlight {
397        item,
398        title: None,
399        text: None,
400    }
401}
402
403pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
404    BookPage::Crafting {
405        recipe_id: recipe_id.into(),
406        text: None,
407    }
408}
409
410pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
411    BookPage::Crafting {
412        recipe_id: recipe_id.into(),
413        text: Some(text.into()),
414    }
415}
416
417pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
418    BookPage::Smelting {
419        recipe_id: recipe_id.into(),
420        text: None,
421    }
422}
423
424pub fn image_page(texture: impl Into<String>) -> BookPage {
425    BookPage::Image {
426        texture: texture.into(),
427        title: None,
428        text: None,
429        border: true,
430    }
431}
432
433pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
434    BookPage::Entity {
435        entity_type: entity_type.into(),
436        name: None,
437        text: None,
438    }
439}
440
441pub fn relations_page(entries: Vec<String>) -> BookPage {
442    BookPage::Relations {
443        entries,
444        text: None,
445    }
446}
447
448pub fn pattern_page(
449    op_id: impl Into<String>,
450    anchor: impl Into<String>,
451    input: impl Into<String>,
452    output: impl Into<String>,
453    text: impl Into<String>,
454) -> BookPage {
455    BookPage::Pattern {
456        op_id: op_id.into(),
457        anchor: anchor.into(),
458        input: input.into(),
459        output: output.into(),
460        text: text.into(),
461    }
462}
463
464// ── Book → yog-ui bridge ─────────────────────────────────────────────────────
465pub mod book_ui {
466    use crate::{Book, BookEntry, BookPage};
467    use yog_ui::widget::{self, Widget};
468    use yog_ui::{Align, FlexDir, UiRoot};
469
470    /// Build a `UiRoot` from a `Book`.
471    /// The UI has: left panel (categories + entries), right panel (pages),
472    /// prev/next buttons at bottom.
473    pub fn build_book_ui(
474        book: &Book,
475        selected_cat: usize,
476        selected_entry: usize,
477        current_page: usize,
478    ) -> UiRoot {
479        let mut cats: Vec<Widget> = Vec::new();
480        for (i, cat) in book.categories.iter().enumerate() {
481            let color = if i == selected_cat {
482                0xFF_FFFF55
483            } else {
484                0xFF_CCCCCC
485            };
486            cats.push(
487                widget::button(&cat.name)
488                    .color(color)
489                    .on_click(format!("cat:{}", i)),
490            );
491        }
492
493        let cat = book.categories.get(selected_cat);
494        let mut entries: Vec<Widget> = Vec::new();
495        if let Some(cat) = cat {
496            let cat_entries: Vec<&BookEntry> = book
497                .entries
498                .iter()
499                .filter(|e| e.category == cat.id)
500                .collect();
501            for (i, entry) in cat_entries.iter().enumerate() {
502                let color = if i == selected_entry {
503                    0xFF_FFFF55
504                } else {
505                    0xFF_CCCCCC
506                };
507                let label = if entry.name.len() > 14 {
508                    &entry.name[..14]
509                } else {
510                    &entry.name
511                };
512                entries.push(
513                    widget::button(label)
514                        .color(color)
515                        .on_click(format!("entry:{}", i)),
516                );
517            }
518        }
519
520        let mut pages: Vec<Widget> = Vec::new();
521        if let Some(cat) = cat {
522            let cat_entries: Vec<&BookEntry> = book
523                .entries
524                .iter()
525                .filter(|e| e.category == cat.id)
526                .collect();
527            if let Some(entry) = cat_entries.get(selected_entry)
528                && let Some(page) = entry.pages.get(current_page)
529            {
530                pages.push(render_page(page));
531            }
532        }
533
534        let nav = widget::panel(FlexDir::Row)
535            .gap(4.0)
536            .child(widget::button("<").w(28.0).on_click("prev_page"))
537            .child(
538                widget::label(format!(
539                    "{}/{}",
540                    current_page + 1,
541                    cat.map_or(0, |c| {
542                        book.entries
543                            .iter()
544                            .filter(|e| e.category == c.id)
545                            .nth(selected_entry)
546                            .map_or(0, |e| e.pages.len())
547                    })
548                ))
549                .color(0xFF_888888)
550                .flex(1.0)
551                .align(Align::Center),
552            )
553            .child(widget::button(">").w(28.0).on_click("next_page"));
554
555        UiRoot::new(
556            &book.id,
557            widget::panel(FlexDir::Row)
558                .gap(2.0)
559                .padding(2.0, 2.0, 2.0, 2.0)
560                .bg(0xFF_2A1A0E)
561                .child(
562                    widget::panel(FlexDir::Column)
563                        .w(104.0)
564                        .child(widget::label("Categories").color(0xFF_888888))
565                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
566                        .child(widget::label("Entries").color(0xFF_888888))
567                        .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
568                )
569                .child(
570                    widget::panel(FlexDir::Column)
571                        .flex(1.0)
572                        .gap(2.0)
573                        .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
574                        .child(nav),
575                ),
576        )
577    }
578
579    fn render_page(page: &BookPage) -> Widget {
580        match page {
581            BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
582            BookPage::Spotlight { item, title, text } => {
583                let mut p = widget::panel(FlexDir::Column).gap(2.0);
584                if let Some(t) = title {
585                    p = p.child(widget::label(t).color(0xFF_FFFF55));
586                }
587                p = p.child(widget::item_slot(&item.id));
588                if let Some(t) = text {
589                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
590                }
591                p
592            }
593            BookPage::Crafting { recipe_id, text } => {
594                let mut p = widget::panel(FlexDir::Column).gap(2.0);
595                p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
596                if let Some(t) = text {
597                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
598                }
599                p
600            }
601            BookPage::Smelting { recipe_id, text } => {
602                let mut p = widget::panel(FlexDir::Column).gap(2.0);
603                p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
604                if let Some(t) = text {
605                    p = p.child(widget::label(t).color(0xFF_CCCCAA));
606                }
607                p
608            }
609            BookPage::Empty => widget::spacer(),
610            _ => widget::label("(unsupported page)").color(0xFF_888888),
611        }
612    }
613
614    // Helper: add multiple children to a widget
615    trait WidgetExt {
616        fn child_many(self, children: Vec<Widget>) -> Self;
617    }
618    impl WidgetExt for Widget {
619        fn child_many(mut self, children: Vec<Widget>) -> Self {
620            for c in children {
621                self = self.child(c);
622            }
623            self
624        }
625    }
626}
627
628// ── JSON serialization ────────────────────────────────────────────────────────
629
630fn esc(s: &str) -> String {
631    let mut out = String::with_capacity(s.len());
632    for ch in s.chars() {
633        match ch {
634            '\\' => out.push_str("\\\\"),
635            '"' => out.push_str("\\\""),
636            '\n' => out.push_str("\\n"),
637            '\r' => out.push_str("\\r"),
638            '\t' => out.push_str("\\t"),
639            c if c.is_control() => {
640                out.push_str(&format!("\\u{:04x}", c as u32));
641            }
642            _ => out.push(ch),
643        }
644    }
645    out
646}
647
648impl BookPage {
649    pub fn to_json(&self) -> String {
650        match self {
651            Self::Text { text, title } => {
652                let t = title
653                    .as_deref()
654                    .map(|s| format!(r#","title":"{}""#, esc(s)))
655                    .unwrap_or_default();
656                format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
657            }
658            Self::Spotlight { item, title, text } => {
659                let t = title
660                    .as_deref()
661                    .map(|s| format!(r#","title":"{}""#, esc(s)))
662                    .unwrap_or_default();
663                let tx = text
664                    .as_deref()
665                    .map(|s| format!(r#","text":"{}""#, esc(s)))
666                    .unwrap_or_default();
667                format!(
668                    r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#,
669                    id = esc(&item.id)
670                )
671            }
672            Self::Crafting { recipe_id, text } => {
673                let tx = text
674                    .as_deref()
675                    .map(|s| format!(r#","text":"{}""#, esc(s)))
676                    .unwrap_or_default();
677                format!(
678                    r#"{{"type":"crafting","recipe":"{}"{}}}"#,
679                    esc(recipe_id),
680                    tx
681                )
682            }
683            Self::Smelting { recipe_id, text } => {
684                let tx = text
685                    .as_deref()
686                    .map(|s| format!(r#","text":"{}""#, esc(s)))
687                    .unwrap_or_default();
688                format!(
689                    r#"{{"type":"smelting","recipe":"{}"{}}}"#,
690                    esc(recipe_id),
691                    tx
692                )
693            }
694            Self::Image {
695                texture,
696                title,
697                text,
698                border,
699            } => {
700                let t = title
701                    .as_deref()
702                    .map(|s| format!(r#","title":"{}""#, esc(s)))
703                    .unwrap_or_default();
704                let tx = text
705                    .as_deref()
706                    .map(|s| format!(r#","text":"{}""#, esc(s)))
707                    .unwrap_or_default();
708                format!(
709                    r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
710                    esc(texture),
711                    border,
712                    t,
713                    tx
714                )
715            }
716            Self::Entity {
717                entity_type,
718                name,
719                text,
720            } => {
721                let n = name
722                    .as_deref()
723                    .map(|s| format!(r#","name":"{}""#, esc(s)))
724                    .unwrap_or_default();
725                let tx = text
726                    .as_deref()
727                    .map(|s| format!(r#","text":"{}""#, esc(s)))
728                    .unwrap_or_default();
729                format!(
730                    r#"{{"type":"entity","entity":"{}"{}{}}}"#,
731                    esc(entity_type),
732                    n,
733                    tx
734                )
735            }
736            Self::Relations { entries, text } => {
737                let e: String = entries
738                    .iter()
739                    .map(|s| format!(r#""{}""#, esc(s)))
740                    .collect::<Vec<_>>()
741                    .join(",");
742                let tx = text
743                    .as_deref()
744                    .map(|s| format!(r#","text":"{}""#, esc(s)))
745                    .unwrap_or_default();
746                format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
747            }
748            Self::Empty => r#"{"type":"empty"}"#.to_string(),
749            Self::Pattern {
750                op_id,
751                anchor,
752                input,
753                output,
754                text,
755            } => format!(
756                r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
757                esc(op_id),
758                esc(anchor),
759                esc(input),
760                esc(output),
761                esc(text)
762            ),
763            Self::Svg { data, title, text } => {
764                let t = title
765                    .as_deref()
766                    .map(|s| format!(r#","title":"{}""#, esc(s)))
767                    .unwrap_or_default();
768                let tx = text
769                    .as_deref()
770                    .map(|s| format!(r#","text":"{}""#, esc(s)))
771                    .unwrap_or_default();
772                format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
773            }
774            Self::CustomText { text, font, color } => format!(
775                r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
776                esc(text),
777                esc(&font.font_id),
778                font.size_px,
779                color
780            ),
781        }
782    }
783}
784
785impl BookEntry {
786    pub fn to_json(&self) -> String {
787        let pages: String = self
788            .pages
789            .iter()
790            .map(|p| p.to_json())
791            .collect::<Vec<_>>()
792            .join(",");
793        let icon = self
794            .icon
795            .as_deref()
796            .map(|s| format!(r#","icon":"{}""#, esc(s)))
797            .unwrap_or_default();
798        let adv = self
799            .advancement
800            .as_deref()
801            .map(|s| format!(r#","advancement":"{}""#, esc(s)))
802            .unwrap_or_default();
803        format!(
804            r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
805            esc(&self.id),
806            esc(&self.name),
807            esc(&self.category),
808            pages,
809            self.secret,
810            self.priority,
811            self.read_by_default,
812            icon,
813            adv
814        )
815    }
816}
817
818impl BookCategory {
819    pub fn to_json(&self) -> String {
820        let desc = self
821            .description
822            .as_deref()
823            .map(|s| format!(r#","description":"{}""#, esc(s)))
824            .unwrap_or_default();
825        let icon = self
826            .icon
827            .as_deref()
828            .map(|s| format!(r#","icon":"{}""#, esc(s)))
829            .unwrap_or_default();
830        format!(
831            r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
832            esc(&self.id),
833            esc(&self.name),
834            self.sortnum,
835            desc,
836            icon
837        )
838    }
839}
840
841impl Book {
842    pub fn to_json(&self) -> String {
843        let cats: String = self
844            .categories
845            .iter()
846            .map(|c| c.to_json())
847            .collect::<Vec<_>>()
848            .join(",");
849        let entries: String = self
850            .entries
851            .iter()
852            .map(|e| e.to_json())
853            .collect::<Vec<_>>()
854            .join(",");
855        let author = self
856            .author
857            .as_deref()
858            .map(|s| format!(r#","author":"{}""#, esc(s)))
859            .unwrap_or_default();
860        let tab = self
861            .creative_tab
862            .as_deref()
863            .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
864            .unwrap_or_default();
865        format!(
866            r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
867            esc(&self.id),
868            esc(&self.name),
869            esc(&self.nameplate_color),
870            esc(&self.landing_text),
871            esc(&self.book_texture),
872            esc(&self.filler_texture),
873            esc(&self.model),
874            self.show_progress,
875            self.i18n,
876            self.use_resource_pack,
877            cats,
878            entries,
879            author,
880            tab
881        )
882    }
883}
884// ── Tests ─────────────────────────────────────────────────────────────────────
885
886#[cfg(test)]
887mod wire_format_tests {
888    use super::*;
889
890    /// The hand-written `to_json` wire format must stay parseable by the
891    /// serde `Deserialize` impls (runtime side of the mod ↔ runtime boundary).
892    #[test]
893    fn book_to_json_roundtrips_through_serde() {
894        let book = Book::new("yog:test", "Test Book")
895            .author("Tester")
896            .landing_text("hello")
897            .add_category(BookCategory {
898                id: "c1".into(),
899                name: "Cat".into(),
900                description: Some("d".into()),
901                icon: Some("yog:item/ruby".into()),
902                icon_svg: None,
903                sortnum: 0,
904            })
905            .add_entry(BookEntry {
906                id: "e1".into(),
907                name: "Entry".into(),
908                category: "c1".into(),
909                pages: vec![
910                    text_page("plain"),
911                    spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
912                    crafting_page("yog:r1"),
913                    smelting_page("yog:r2"),
914                    BookPage::Empty,
915                ],
916                icon: Some("yog:ruby".into()),
917                icon_svg: None,
918                secret: false,
919                priority: 0,
920                read_by_default: false,
921                advancement: None,
922            });
923
924        let json = book.to_json();
925        let parsed: Book = serde_json::from_str(&json)
926            .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
927        assert_eq!(parsed.id, "yog:test");
928        assert_eq!(parsed.entries.len(), 1);
929        assert_eq!(parsed.entries[0].pages.len(), 5);
930        match &parsed.entries[0].pages[1] {
931            BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
932            p => panic!("expected spotlight, got {p:?}"),
933        }
934        match &parsed.entries[0].pages[2] {
935            BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
936            p => panic!("expected crafting, got {p:?}"),
937        }
938    }
939}
940
941#[cfg(test)]
942mod localize_tests {
943    use super::*;
944
945    #[test]
946    fn localized_translates_every_text() {
947        let book = Book::new("m:b", "m.book.name")
948            .landing_text("m.book.landing")
949            .add_category(BookCategory {
950                id: "basics".into(),
951                name: "m.cat".into(),
952                ..Default::default()
953            })
954            .add_entry(BookEntry {
955                id: "e".into(),
956                name: "m.entry".into(),
957                category: "basics".into(),
958                pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
959                ..Default::default()
960            });
961        let out = book.localized(|k| {
962            if k.starts_with("m.") {
963                k.to_uppercase()
964            } else {
965                k.to_string()
966            }
967        });
968        assert_eq!(out.name, "M.BOOK.NAME");
969        assert_eq!(out.landing_text, "M.BOOK.LANDING");
970        assert_eq!(out.categories[0].name, "M.CAT");
971        assert_eq!(out.entries[0].name, "M.ENTRY");
972        match &out.entries[0].pages[0] {
973            BookPage::Text { text, title } => {
974                assert_eq!(text, "M.TEXT");
975                assert_eq!(title.as_deref(), Some("M.TITLE"));
976            }
977            p => panic!("{p:?}"),
978        }
979        assert!(
980            matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
981        );
982        assert_eq!(out.entries[0].id, "e");
983    }
984}