1pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BookMacro(pub String, pub String);
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum BookPage {
38 Text {
40 text: String,
41 #[serde(default)]
42 title: Option<String>,
43 },
44 Spotlight {
46 #[serde(with = "item_ref")]
49 item: ItemDef,
50 #[serde(default)]
51 title: Option<String>,
52 #[serde(default)]
53 text: Option<String>,
54 },
55 Crafting {
57 #[serde(rename = "recipe")]
58 recipe_id: String,
59 #[serde(default)]
60 text: Option<String>,
61 },
62 Smelting {
64 #[serde(rename = "recipe")]
65 recipe_id: String,
66 #[serde(default)]
67 text: Option<String>,
68 },
69 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 {
81 #[serde(rename = "entity")]
82 entity_type: String,
83 #[serde(default)]
84 name: Option<String>,
85 #[serde(default)]
86 text: Option<String>,
87 },
88 Relations {
90 entries: Vec<String>,
91 #[serde(default)]
92 text: Option<String>,
93 },
94 Empty,
96 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 {
110 data: String,
112 #[serde(default)]
113 title: Option<String>,
114 #[serde(default)]
115 text: Option<String>,
116 },
117 CustomText {
119 text: String,
120 #[serde(flatten)]
122 font: BookFont,
123 color: u32,
125 },
126}
127
128mod 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#[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 pub icon: Option<String>,
163 pub icon_svg: Option<String>,
165 pub sortnum: i32,
167}
168
169#[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 pub icon: Option<String>,
181 pub icon_svg: Option<String>,
183 pub secret: bool,
185 pub priority: i32,
187 pub read_by_default: bool,
189 pub advancement: Option<String>,
191}
192
193#[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(), 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 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#[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
379pub 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
464pub 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 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 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
628fn 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#[cfg(test)]
887mod wire_format_tests {
888 use super::*;
889
890 #[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}