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 state;
7pub mod theme;
8pub mod font;
9pub mod svg;
10pub mod renderer;
11
12use serde::{Deserialize, Serialize};
13use yog_registry::ItemDef;
14
15pub use state::BookViewState;
16pub use theme::BookTheme;
17pub use font::{BookFont, BookFontRegistry};
18pub use renderer::BookRenderer;
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", alias = "recipe_id")]
58        recipe_id: String,
59        #[serde(default)]
60        text: Option<String>,
61    },
62    /// Smelting recipe display.
63    Smelting {
64        #[serde(rename = "recipe", alias = "recipe_id")]
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", alias = "entity_type")]
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:textures/gui/book.png".into(),
225            filler_texture: "yog:textures/gui/book_filler.png".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 { text: text.into(), title: None }
335}
336
337pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
338    BookPage::Text { text: text.into(), title: Some(title.into()) }
339}
340
341pub fn spotlight_page(item: ItemDef) -> BookPage {
342    BookPage::Spotlight { item, title: None, text: None }
343}
344
345pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
346    BookPage::Crafting { recipe_id: recipe_id.into(), text: None }
347}
348
349pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
350    BookPage::Crafting { recipe_id: recipe_id.into(), text: Some(text.into()) }
351}
352
353pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
354    BookPage::Smelting { recipe_id: recipe_id.into(), text: None }
355}
356
357pub fn image_page(texture: impl Into<String>) -> BookPage {
358    BookPage::Image { texture: texture.into(), title: None, text: None, border: true }
359}
360
361pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
362    BookPage::Entity { entity_type: entity_type.into(), name: None, text: None }
363}
364
365pub fn relations_page(entries: Vec<String>) -> BookPage {
366    BookPage::Relations { entries, text: None }
367}
368
369pub fn pattern_page(op_id: impl Into<String>, anchor: impl Into<String>, input: impl Into<String>, output: impl Into<String>, text: impl Into<String>) -> BookPage {
370    BookPage::Pattern {
371        op_id: op_id.into(),
372        anchor: anchor.into(),
373        input: input.into(),
374        output: output.into(),
375        text: text.into(),
376    }
377}
378
379// ── Book → yog-ui bridge ─────────────────────────────────────────────────────
380pub mod book_ui {
381    use crate::{Book, BookEntry, BookPage};
382    use yog_ui::widget::{self, Widget};
383    use yog_ui::{Align, FlexDir, UiRoot};
384
385    /// Build a `UiRoot` from a `Book`.
386    /// The UI has: left panel (categories + entries), right panel (pages),
387    /// prev/next buttons at bottom.
388    pub fn build_book_ui(book: &Book, selected_cat: usize, selected_entry: usize, current_page: usize) -> UiRoot {
389        let mut cats: Vec<Widget> = Vec::new();
390        for (i, cat) in book.categories.iter().enumerate() {
391            let color = if i == selected_cat { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
392            cats.push(widget::button(&cat.name)
393                .color(color)
394                .on_click(format!("cat:{}", i)));
395        }
396
397        let cat = book.categories.get(selected_cat);
398        let mut entries: Vec<Widget> = Vec::new();
399        if let Some(cat) = cat {
400            let cat_entries: Vec<&BookEntry> = book.entries.iter()
401                .filter(|e| e.category == cat.id).collect();
402            for (i, entry) in cat_entries.iter().enumerate() {
403                let color = if i == selected_entry { 0xFF_FFFF55 } else { 0xFF_CCCCCC };
404                let label = if entry.name.len() > 14 { &entry.name[..14] } else { &entry.name };
405                entries.push(widget::button(label)
406                    .color(color)
407                    .on_click(format!("entry:{}", i)));
408            }
409        }
410
411        let mut pages: Vec<Widget> = Vec::new();
412        if let Some(cat) = cat {
413            let cat_entries: Vec<&BookEntry> = book.entries.iter()
414                .filter(|e| e.category == cat.id).collect();
415            if let Some(entry) = cat_entries.get(selected_entry) {
416                if let Some(page) = entry.pages.get(current_page) {
417                    pages.push(render_page(page));
418                }
419            }
420        }
421
422        let nav = widget::panel(FlexDir::Row).gap(4.0)
423            .child(widget::button("<").w(28.0).on_click("prev_page"))
424            .child(widget::label(&format!("{}/{}", current_page + 1,
425                cat.map_or(0, |c| {
426                    book.entries.iter().filter(|e| e.category == c.id).nth(selected_entry)
427                        .map_or(0, |e| e.pages.len())
428                }))).color(0xFF_888888).flex(1.0).align(Align::Center))
429            .child(widget::button(">").w(28.0).on_click("next_page"));
430
431        UiRoot::new(&book.id,
432            widget::panel(FlexDir::Row).gap(2.0)
433                .padding(2.0, 2.0, 2.0, 2.0).bg(0xFF_2A1A0E)
434                .child(
435                    widget::panel(FlexDir::Column).w(104.0)
436                        .child(widget::label("Categories").color(0xFF_888888))
437                        .child(widget::panel(FlexDir::Column).gap(1.0)
438                            .child_many(cats))
439                        .child(widget::label("Entries").color(0xFF_888888))
440                        .child(widget::panel(FlexDir::Column).gap(1.0)
441                            .child_many(entries))
442                )
443                .child(
444                    widget::panel(FlexDir::Column).flex(1.0).gap(2.0)
445                        .child(widget::panel(FlexDir::Column).flex(1.0)
446                            .child_many(pages))
447                        .child(nav)
448                )
449        )
450    }
451
452    fn render_page(page: &BookPage) -> Widget {
453        match page {
454            BookPage::Text { text, .. } =>
455                widget::label(text).color(0xFF_CCCCAA),
456            BookPage::Spotlight { item, title, text } => {
457                let mut p = widget::panel(FlexDir::Column).gap(2.0);
458                if let Some(t) = title { p = p.child(widget::label(t).color(0xFF_FFFF55)); }
459                p = p.child(widget::item_slot(&item.id));
460                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
461                p
462            }
463            BookPage::Crafting { recipe_id, text } => {
464                let mut p = widget::panel(FlexDir::Column).gap(2.0);
465                p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
466                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
467                p
468            }
469            BookPage::Smelting { recipe_id, text } => {
470                let mut p = widget::panel(FlexDir::Column).gap(2.0);
471                p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
472                if let Some(t) = text { p = p.child(widget::label(t).color(0xFF_CCCCAA)); }
473                p
474            }
475            BookPage::Empty => widget::spacer(),
476            _ => widget::label("(unsupported page)").color(0xFF_888888),
477        }
478    }
479
480    // Helper: add multiple children to a widget
481    trait WidgetExt {
482        fn child_many(self, children: Vec<Widget>) -> Self;
483    }
484    impl WidgetExt for Widget {
485        fn child_many(mut self, children: Vec<Widget>) -> Self {
486            for c in children { self = self.child(c); }
487            self
488        }
489    }
490}
491
492// ── JSON serialization ────────────────────────────────────────────────────────
493
494fn esc(s: &str) -> String {
495    let mut out = String::with_capacity(s.len());
496    for ch in s.chars() {
497        match ch {
498            '\\' => out.push_str("\\\\"),
499            '"'  => out.push_str("\\\""),
500            '\n' => out.push_str("\\n"),
501            '\r' => out.push_str("\\r"),
502            '\t' => out.push_str("\\t"),
503            c if c.is_control() => {
504                out.push_str(&format!("\\u{:04x}", c as u32));
505            }
506            _ => out.push(ch),
507        }
508    }
509    out
510}
511
512impl BookPage {
513    pub fn to_json(&self) -> String {
514        match self {
515            Self::Text { text, title } => {
516                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
517                format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
518            }
519            Self::Spotlight { item, title, text } => {
520                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
521                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
522                format!(r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#, id = esc(&item.id))
523            }
524            Self::Crafting { recipe_id, text } => {
525                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
526                format!(r#"{{"type":"crafting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
527            }
528            Self::Smelting { recipe_id, text } => {
529                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
530                format!(r#"{{"type":"smelting","recipe":"{}"{}}}"#, esc(recipe_id), tx)
531            }
532            Self::Image { texture, title, text, border } => {
533                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
534                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
535                format!(r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
536                    esc(texture), border, t, tx)
537            }
538            Self::Entity { entity_type, name, text } => {
539                let n = name.as_deref().map(|s| format!(r#","name":"{}""#, esc(s))).unwrap_or_default();
540                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
541                format!(r#"{{"type":"entity","entity":"{}"{}{}}}"#, esc(entity_type), n, tx)
542            }
543            Self::Relations { entries, text } => {
544                let e: String = entries.iter().map(|s| format!(r#""{}""#, esc(s))).collect::<Vec<_>>().join(",");
545                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
546                format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
547            }
548            Self::Empty => r#"{"type":"empty"}"#.to_string(),
549            Self::Pattern { op_id, anchor, input, output, text } =>
550                format!(r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
551                    esc(op_id), esc(anchor), esc(input), esc(output), esc(text)),
552            Self::Svg { data, title, text } => {
553                let t = title.as_deref().map(|s| format!(r#","title":"{}""#, esc(s))).unwrap_or_default();
554                let tx = text.as_deref().map(|s| format!(r#","text":"{}""#, esc(s))).unwrap_or_default();
555                format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
556            }
557            Self::CustomText { text, font, color } =>
558                format!(r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
559                    esc(text), esc(&font.font_id), font.size_px, color),
560        }
561    }
562}
563
564impl BookEntry {
565    pub fn to_json(&self) -> String {
566        let pages: String = self.pages.iter().map(|p| p.to_json()).collect::<Vec<_>>().join(",");
567        let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
568        let adv = self.advancement.as_deref().map(|s| format!(r#","advancement":"{}""#, esc(s))).unwrap_or_default();
569        format!(
570            r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
571            esc(&self.id), esc(&self.name), esc(&self.category), pages,
572            self.secret, self.priority, self.read_by_default, icon, adv
573        )
574    }
575}
576
577impl BookCategory {
578    pub fn to_json(&self) -> String {
579        let desc = self.description.as_deref().map(|s| format!(r#","description":"{}""#, esc(s))).unwrap_or_default();
580        let icon = self.icon.as_deref().map(|s| format!(r#","icon":"{}""#, esc(s))).unwrap_or_default();
581        format!(
582            r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
583            esc(&self.id), esc(&self.name), self.sortnum, desc, icon
584        )
585    }
586}
587
588impl Book {
589    pub fn to_json(&self) -> String {
590        let cats: String = self.categories.iter().map(|c| c.to_json()).collect::<Vec<_>>().join(",");
591        let entries: String = self.entries.iter().map(|e| e.to_json()).collect::<Vec<_>>().join(",");
592        let author = self.author.as_deref().map(|s| format!(r#","author":"{}""#, esc(s))).unwrap_or_default();
593        let tab = self.creative_tab.as_deref().map(|s| format!(r#","creative_tab":"{}""#, esc(s))).unwrap_or_default();
594        format!(
595            r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
596            esc(&self.id), esc(&self.name), esc(&self.nameplate_color), esc(&self.landing_text),
597            esc(&self.book_texture), esc(&self.filler_texture), esc(&self.model),
598            self.show_progress, self.i18n, self.use_resource_pack,
599            cats, entries, author, tab
600        )
601    }
602}
603// ── Tests ─────────────────────────────────────────────────────────────────────
604
605#[cfg(test)]
606mod wire_format_tests {
607    use super::*;
608
609    /// The hand-written `to_json` wire format must stay parseable by the
610    /// serde `Deserialize` impls (runtime side of the mod ↔ runtime boundary).
611    #[test]
612    fn book_to_json_roundtrips_through_serde() {
613        let book = Book::new("yog:test", "Test Book")
614            .author("Tester")
615            .landing_text("hello")
616            .add_category(BookCategory {
617                id: "c1".into(), name: "Cat".into(),
618                description: Some("d".into()),
619                icon: Some("yog:item/ruby".into()), icon_svg: None, sortnum: 0,
620            })
621            .add_entry(BookEntry {
622                id: "e1".into(), name: "Entry".into(), category: "c1".into(),
623                pages: vec![
624                    text_page("plain"),
625                    spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
626                    crafting_page("yog:r1"),
627                    smelting_page("yog:r2"),
628                    BookPage::Empty,
629                ],
630                icon: Some("yog:ruby".into()), icon_svg: None,
631                secret: false, priority: 0, read_by_default: false, advancement: None,
632            });
633
634        let json = book.to_json();
635        let parsed: Book = serde_json::from_str(&json)
636            .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
637        assert_eq!(parsed.id, "yog:test");
638        assert_eq!(parsed.entries.len(), 1);
639        assert_eq!(parsed.entries[0].pages.len(), 5);
640        match &parsed.entries[0].pages[1] {
641            BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
642            p => panic!("expected spotlight, got {p:?}"),
643        }
644        match &parsed.entries[0].pages[2] {
645            BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
646            p => panic!("expected crafting, got {p:?}"),
647        }
648    }
649}