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