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