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