1use std::{
2 cell::RefCell,
3 collections::{HashMap, HashSet},
4 rc::Rc,
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::strings::strip_html_tags;
14
15pub const EXERCISE_BLOCK: &str = "moocfi/exercise";
17
18const DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES: &[&str] = &[
21 EXERCISE_BLOCK,
22 "moocfi/exercise-task",
23 "moocfi/exercises-in-chapter",
24 "moocfi/pages-in-chapter",
25 "moocfi/chapter-progress",
26];
27
28pub const COURSE_OBJECTIVE_PLACEHOLDERS: [(&str, &str); 3] = [
31 (
32 "Objective #1",
33 "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...",
34 ),
35 (
36 "Objective #2",
37 "There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain...",
38 ),
39 (
40 "Objective #3",
41 "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a tempor risus. Morbi at sapien.",
42 ),
43];
44
45fn centred_text_style() -> Value {
47 serde_json::json!({"typography": {"textAlign": "center"}})
48}
49
50pub use crate::attributes;
51use crate::prelude::*;
52
53#[macro_export]
54macro_rules! attributes {
55 () => {{
56 serde_json::Map::<String, serde_json::Value>::new()
57 }};
58 ($($name: tt: $value: expr_2021),+ $(,)*) => {{
59 let mut map = serde_json::Map::<String, serde_json::Value>::new();
60 $(map.insert($name.into(), serde_json::json!($value));)*
61 map
62 }};
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ToSchema)]
66
67pub struct GutenbergBlock {
68 #[serde(rename = "clientId")]
69 pub client_id: Uuid,
70 pub name: String,
71 #[serde(rename = "isValid")]
72 pub is_valid: bool,
73 pub attributes: Map<String, Value>,
74 #[serde(rename = "innerBlocks")]
75 #[schema(no_recursion)]
76 pub inner_blocks: Vec<GutenbergBlock>,
77}
78
79impl GutenbergBlock {
80 pub fn paragraph(paragraph: &str) -> Self {
81 Self::block_with_name_and_attributes(
82 "core/paragraph",
83 attributes! {
84 "content": paragraph.to_string(),
85 "dropCap": false
86 },
87 )
88 }
89
90 pub fn empty_block_from_name(name: String) -> Self {
91 GutenbergBlock {
92 client_id: Uuid::new_v4(),
93 name,
94 is_valid: true,
95 attributes: Map::new(),
96 inner_blocks: vec![],
97 }
98 }
99 pub fn block_with_name_and_attributes(name: &str, attributes: Map<String, Value>) -> Self {
100 GutenbergBlock {
101 client_id: Uuid::new_v4(),
102 name: name.to_string(),
103 is_valid: true,
104 attributes,
105 inner_blocks: vec![],
106 }
107 }
108 pub fn block_with_name_attributes_and_inner_blocks(
109 name: &str,
110 attributes: Map<String, Value>,
111 inner_blocks: Vec<GutenbergBlock>,
112 ) -> Self {
113 GutenbergBlock {
114 client_id: Uuid::new_v4(),
115 name: name.to_string(),
116 is_valid: true,
117 attributes,
118 inner_blocks,
119 }
120 }
121 pub fn hero_section(title: &str, sub_title: &str) -> Self {
122 GutenbergBlock::block_with_name_and_attributes(
123 "moocfi/hero-section",
124 attributes! {
125 "title": title,
126 "subtitle": sub_title
127 },
128 )
129 }
130 pub fn landing_page_hero_section(title: &str, sub_title: &str) -> Self {
131 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
132 "moocfi/landing-page-hero-section",
133 attributes! {"title": title},
134 vec![GutenbergBlock::block_with_name_and_attributes(
135 "core/paragraph",
136 attributes! {
137 "content": sub_title,
138 "dropCap": false,
139 "placeholder": "Insert short description of course...",
140 "style": centred_text_style()
141 },
142 )],
143 )
144 }
145
146 pub fn course_objective_section() -> Self {
149 let columns = COURSE_OBJECTIVE_PLACEHOLDERS
150 .iter()
151 .enumerate()
152 .map(|(index, (heading, description))| {
153 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
154 "core/column",
155 attributes! {},
156 vec![
157 GutenbergBlock::block_with_name_and_attributes(
158 "core/heading",
159 attributes! {
160 "level": 3,
161 "content": heading,
162 "anchor": format!("objective-{}", index + 1),
163 "style": centred_text_style(),
164 },
165 ),
166 GutenbergBlock::block_with_name_and_attributes(
167 "core/paragraph",
168 attributes! {
169 "dropCap": false,
170 "content": description,
171 "style": centred_text_style(),
172 },
173 ),
174 ],
175 )
176 })
177 .collect();
178 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
179 "moocfi/course-objective-section",
180 attributes! {
181 "title": "In this course you'll..."
182 },
183 vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
184 "core/columns",
185 attributes! {
186 "isStackedOnMobile": true
187 },
188 columns,
189 )],
190 )
191 }
192
193 pub fn landing_page_copy_text(heading: &str, content: &str) -> Self {
194 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
195 "moocfi/landing-page-copy-text",
196 attributes! {},
197 vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
198 "core/columns",
199 attributes! {
200 "isStackedOnMobile": true
201 },
202 vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
203 "core/column",
204 attributes! {},
205 vec![
206 GutenbergBlock::block_with_name_and_attributes(
207 "core/heading",
208 attributes! {
209 "content": heading,
210 "level": 2,
211 "placeholder": heading,
212 "anchor": heading.to_lowercase().replace(' ', "-"),
213 },
214 ),
215 GutenbergBlock::block_with_name_and_attributes(
216 "core/paragraph",
217 attributes! {
218 "content": content,
219 "dropCap": false
220 },
221 ),
222 ],
223 )],
224 )],
225 )
226 }
227
228 pub fn with_id(self, id: Uuid) -> Self {
229 Self {
230 client_id: id,
231 ..self
232 }
233 }
234
235 pub fn exercise_id(&self) -> Option<Uuid> {
238 if self.name != EXERCISE_BLOCK {
239 return None;
240 }
241 self.attributes
242 .get("id")
243 .and_then(Value::as_str)
244 .and_then(|id| Uuid::parse_str(id).ok())
245 }
246}
247
248pub fn walk<'a>(blocks: &'a [GutenbergBlock], visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize])) {
251 fn go<'a>(
252 blocks: &'a [GutenbergBlock],
253 prefix: &mut Vec<usize>,
254 visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize]),
255 ) {
256 for (index, block) in blocks.iter().enumerate() {
257 prefix.push(index);
258 visit(block, prefix);
259 go(&block.inner_blocks, prefix, visit);
260 prefix.pop();
261 }
262 }
263 go(blocks, &mut Vec::new(), visit);
264}
265
266pub fn walk_mut(blocks: &mut [GutenbergBlock], visit: &mut dyn FnMut(&mut GutenbergBlock)) {
268 for block in blocks.iter_mut() {
269 visit(block);
270 walk_mut(&mut block.inner_blocks, visit);
271 }
272}
273
274pub fn all_client_ids(blocks: &[GutenbergBlock]) -> Vec<Uuid> {
276 let mut ids = Vec::new();
277 walk(blocks, &mut |block, _| ids.push(block.client_id));
278 ids
279}
280
281pub fn blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> Vec<&GutenbergBlock> {
284 let mut found = Vec::new();
285 walk(input, &mut |block, _| {
286 if DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES.contains(&block.name.as_str()) {
287 found.push(block);
288 }
289 });
290 found
291}
292
293pub fn contains_blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> bool {
296 !blocks_not_allowed_in_top_level_pages(input).is_empty()
297}
298
299pub fn remap_ids_in_content(
300 content: &serde_json::Value,
301 chaged_ids: HashMap<Uuid, Uuid>,
302) -> UtilResult<serde_json::Value> {
303 let mut content_str = serde_json::to_string(content)?;
306 for (k, v) in chaged_ids.into_iter() {
307 content_str = content_str.replace(&k.to_string(), &v.to_string());
308 }
309 Ok(serde_json::from_str(&content_str)?)
310}
311
312pub fn remove_sensitive_attributes(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
314 input
315 .into_iter()
316 .map(|mut block| {
317 if block.name == "moocfi/exercise-task" {
318 block.attributes = Map::new();
319 }
320 block.inner_blocks = remove_sensitive_attributes(block.inner_blocks);
321 block
322 })
323 .collect()
324}
325
326pub fn filter_lock_chapter_blocks(
330 input: Vec<GutenbergBlock>,
331 is_locked: bool,
332) -> Vec<GutenbergBlock> {
333 input
334 .into_iter()
335 .map(|mut block| {
336 if block.name == "moocfi/lock-chapter" {
337 if !is_locked {
338 block.inner_blocks = vec![];
340 } else {
341 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
343 }
344 } else {
345 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
347 }
348 block
349 })
350 .collect()
351}
352
353pub fn replace_duplicate_client_ids(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
355 let seen_ids = Rc::new(RefCell::new(HashSet::new()));
356
357 replace_duplicate_client_ids_inner(input, seen_ids)
358}
359
360fn replace_duplicate_client_ids_inner(
361 mut input: Vec<GutenbergBlock>,
362 seen_ids: Rc<RefCell<HashSet<Uuid>>>,
363) -> Vec<GutenbergBlock> {
364 for block in input.iter_mut() {
365 let mut seen_ids_borrow = seen_ids.borrow_mut();
366 if seen_ids_borrow.contains(&block.client_id) {
367 block.client_id = Uuid::new_v4();
368 } else {
369 seen_ids_borrow.insert(block.client_id);
370 }
371 drop(seen_ids_borrow); block.inner_blocks =
374 replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
375 }
376 input
377}
378
379pub fn suffix_duplicate_heading_anchors(mut input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
387 let mut taken = HashSet::new();
388 collect_heading_anchors(&input, &mut taken);
389 let mut seen = HashSet::new();
390 suffix_duplicate_heading_anchors_inner(&mut input, &mut seen, &mut taken);
391 input
392}
393
394fn heading_anchor(block: &GutenbergBlock) -> Option<String> {
395 if block.name != "core/heading" {
396 return None;
397 }
398 let anchor = block.attributes.get("anchor")?.as_str()?.trim();
399 (!anchor.is_empty()).then(|| anchor.to_string())
400}
401
402fn collect_heading_anchors(blocks: &[GutenbergBlock], anchors: &mut HashSet<String>) {
403 for block in blocks {
404 if let Some(anchor) = heading_anchor(block) {
405 anchors.insert(anchor);
406 }
407 collect_heading_anchors(&block.inner_blocks, anchors);
408 }
409}
410
411fn suffix_duplicate_heading_anchors_inner(
412 blocks: &mut [GutenbergBlock],
413 seen: &mut HashSet<String>,
414 taken: &mut HashSet<String>,
415) {
416 for block in blocks.iter_mut() {
417 if let Some(anchor) = heading_anchor(block)
418 && !seen.insert(anchor.clone())
419 {
420 let mut suffix = 2;
421 let mut suffixed = format!("{anchor}-{suffix}");
422 while taken.contains(&suffixed) {
423 suffix += 1;
424 suffixed = format!("{anchor}-{suffix}");
425 }
426 taken.insert(suffixed.clone());
427 block
428 .attributes
429 .insert("anchor".to_string(), Value::String(suffixed));
430 }
431 suffix_duplicate_heading_anchors_inner(&mut block.inner_blocks, seen, taken);
432 }
433}
434
435pub fn validate_unique_client_ids(input: Vec<GutenbergBlock>) -> UtilResult<Vec<GutenbergBlock>> {
438 let seen_ids = Rc::new(RefCell::new(HashSet::new()));
439
440 validate_unique_client_ids_inner(input, seen_ids)
441}
442
443fn validate_unique_client_ids_inner(
444 input: Vec<GutenbergBlock>,
445 seen_ids: Rc<RefCell<HashSet<Uuid>>>,
446) -> UtilResult<Vec<GutenbergBlock>> {
447 for block in input.iter() {
448 let mut seen_ids_borrow = seen_ids.borrow_mut();
449 if seen_ids_borrow.contains(&block.client_id) {
450 return Err(UtilError::new(
451 UtilErrorType::Other,
452 format!("Duplicate client ID found: {}", block.client_id),
453 None,
454 ));
455 } else {
456 seen_ids_borrow.insert(block.client_id);
457 }
458 drop(seen_ids_borrow); validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
461 }
462 Ok(input)
463}
464
465fn blocks_text_content(blocks: &[GutenbergBlock], texts: &mut Vec<String>) {
471 for block in blocks {
472 if let Some(content) = block.attributes.get("content").and_then(Value::as_str) {
473 let text = strip_html_tags(content);
474 let text = text.trim();
475 if !text.is_empty() {
476 texts.push(text.to_string());
477 }
478 }
479 blocks_text_content(&block.inner_blocks, texts);
480 }
481}
482
483pub fn get_learning_objectives(blocks: &[GutenbergBlock]) -> Option<String> {
490 let mut objectives = Vec::new();
491 collect_learning_objectives(blocks, &mut objectives);
492 (!objectives.is_empty()).then(|| objectives.join("\n"))
493}
494
495fn collect_learning_objectives(blocks: &[GutenbergBlock], objectives: &mut Vec<String>) {
496 for block in blocks {
497 if block.name == "moocfi/learning-objectives"
498 || block.name == "moocfi/course-objective-section"
499 {
500 blocks_text_content(std::slice::from_ref(block), objectives);
501 } else {
502 collect_learning_objectives(&block.inner_blocks, objectives);
503 }
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn objectives_block(items: &[&str]) -> GutenbergBlock {
513 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
514 "moocfi/learning-objectives",
515 attributes! {"title": "Learning objectives"},
516 vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
517 "core/list",
518 attributes! {},
519 items
520 .iter()
521 .map(|item| {
522 GutenbergBlock::block_with_name_and_attributes(
523 "core/list-item",
524 attributes! {"content": *item},
525 )
526 })
527 .collect(),
528 )],
529 )
530 }
531
532 #[test]
535 fn learning_objectives_come_back_as_one_plain_text_line_each() {
536 let page = vec![
537 GutenbergBlock::paragraph("Intro"),
538 GutenbergBlock::block_with_name_attributes_and_inner_blocks(
539 "core/columns",
540 attributes! {},
541 vec![objectives_block(&[
542 "Ymmärrät mitä <strong>muuttuja</strong> tarkoittaa",
543 "Osaat <em>tulostaa</em> tekstiä",
544 ])],
545 ),
546 ];
547
548 assert_eq!(
549 get_learning_objectives(&page).as_deref(),
550 Some("Ymmärrät mitä muuttuja tarkoittaa\nOsaat tulostaa tekstiä")
551 );
552 }
553
554 #[test]
557 fn a_page_without_objectives_has_none_whether_or_not_the_block_is_there() {
558 assert_eq!(
559 get_learning_objectives(&[GutenbergBlock::paragraph("Just prose")]),
560 None
561 );
562 assert_eq!(get_learning_objectives(&[objectives_block(&[])]), None);
563 }
564
565 #[test]
566 fn replace_duplicate_client_ids_makes_all_ids_unique_flat_and_nested() {
567 let dup_id = Uuid::new_v4();
568 let nested_dup_id = Uuid::new_v4();
569
570 let block_a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
571 let block_b_child_1 =
572 GutenbergBlock::empty_block_from_name("b1".into()).with_id(nested_dup_id);
573 let block_b_child_2 =
574 GutenbergBlock::empty_block_from_name("b2".into()).with_id(nested_dup_id);
575 let block_b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
576 "b",
577 attributes! {},
578 vec![block_b_child_1, block_b_child_2],
579 )
580 .with_id(dup_id);
581
582 let input = vec![block_a, block_b];
583 let output = replace_duplicate_client_ids(input);
584
585 let ids = all_client_ids(&output);
586 let unique: HashSet<Uuid> = ids.iter().cloned().collect();
587 assert_eq!(
588 unique.len(),
589 ids.len(),
590 "all ids should be unique after replacement"
591 );
592 }
593
594 #[test]
595 fn validate_unique_client_ids_ok_on_unique() {
596 let a = GutenbergBlock::empty_block_from_name("a".into());
597 let b = GutenbergBlock::empty_block_from_name("b".into());
598 let c = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
599 "c",
600 attributes! {},
601 vec![GutenbergBlock::empty_block_from_name("c1".into())],
602 );
603 let input = vec![a, b, c];
604 let result = validate_unique_client_ids(input);
605 assert!(result.is_ok());
606 }
607
608 #[test]
609 fn validate_unique_client_ids_err_on_duplicate_nested() {
610 let dup_id = Uuid::new_v4();
611 let a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
612 let b_child = GutenbergBlock::empty_block_from_name("b1".into()).with_id(dup_id);
613 let b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
614 "b",
615 attributes! {},
616 vec![b_child],
617 );
618 let input = vec![a, b];
619 let result = validate_unique_client_ids(input);
620 assert!(result.is_err());
621 }
622
623 #[test]
624 fn filter_lock_chapter_blocks_removes_inner_blocks_when_not_locked() {
625 let inner_block = GutenbergBlock::block_with_name_and_attributes(
626 "core/paragraph",
627 attributes! {
628 "content": "This should be hidden"
629 },
630 );
631 let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
632 "moocfi/lock-chapter",
633 attributes! {},
634 vec![inner_block.clone()],
635 );
636 let regular_block = GutenbergBlock::block_with_name_and_attributes(
637 "core/heading",
638 attributes! {
639 "content": "Regular heading"
640 },
641 );
642
643 let input = vec![lock_block, regular_block];
644 let result = filter_lock_chapter_blocks(input, false);
645
646 assert_eq!(result.len(), 2);
648 assert_eq!(result[0].name, "moocfi/lock-chapter");
649 assert_eq!(result[0].inner_blocks.len(), 0);
650 assert_eq!(result[1].name, "core/heading");
652 }
653
654 #[test]
655 fn filter_lock_chapter_blocks_preserves_inner_blocks_when_locked() {
656 let inner_block = GutenbergBlock::block_with_name_and_attributes(
657 "core/paragraph",
658 attributes! {
659 "content": "This should be visible"
660 },
661 );
662 let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
663 "moocfi/lock-chapter",
664 attributes! {},
665 vec![inner_block.clone()],
666 );
667
668 let input = vec![lock_block];
669 let result = filter_lock_chapter_blocks(input, true);
670
671 assert_eq!(result.len(), 1);
673 assert_eq!(result[0].name, "moocfi/lock-chapter");
674 assert_eq!(result[0].inner_blocks.len(), 1);
675 assert_eq!(result[0].inner_blocks[0].name, "core/paragraph");
676 }
677
678 #[test]
679 fn filter_lock_chapter_blocks_handles_nested_blocks() {
680 let nested_inner = GutenbergBlock::block_with_name_and_attributes(
681 "core/paragraph",
682 attributes! {
683 "content": "Nested content"
684 },
685 );
686 let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
687 "moocfi/lock-chapter",
688 attributes! {},
689 vec![nested_inner],
690 );
691 let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
692 "moocfi/lock-chapter",
693 attributes! {},
694 vec![nested_lock],
695 );
696
697 let input = vec![outer_lock];
698 let result = filter_lock_chapter_blocks(input, false);
699
700 assert_eq!(result.len(), 1);
702 assert_eq!(result[0].name, "moocfi/lock-chapter");
703 assert_eq!(result[0].inner_blocks.len(), 0);
704 }
705
706 #[test]
707 fn filter_lock_chapter_blocks_handles_nested_blocks_when_locked() {
708 let nested_inner = GutenbergBlock::block_with_name_and_attributes(
709 "core/paragraph",
710 attributes! {
711 "content": "Nested content"
712 },
713 );
714 let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
715 "moocfi/lock-chapter",
716 attributes! {},
717 vec![nested_inner.clone()],
718 );
719 let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
720 "moocfi/lock-chapter",
721 attributes! {},
722 vec![nested_lock],
723 );
724
725 let input = vec![outer_lock];
726 let result = filter_lock_chapter_blocks(input, true);
727
728 assert_eq!(result.len(), 1);
730 assert_eq!(result[0].name, "moocfi/lock-chapter");
731 assert_eq!(result[0].inner_blocks.len(), 1);
732 assert_eq!(result[0].inner_blocks[0].name, "moocfi/lock-chapter");
733 assert_eq!(result[0].inner_blocks[0].inner_blocks.len(), 1);
734 assert_eq!(
735 result[0].inner_blocks[0].inner_blocks[0].name,
736 "core/paragraph"
737 );
738 }
739
740 #[test]
741 fn filter_lock_chapter_blocks_does_not_affect_non_lock_blocks() {
742 let paragraph = GutenbergBlock::block_with_name_and_attributes(
743 "core/paragraph",
744 attributes! {
745 "content": "Regular paragraph"
746 },
747 );
748 let heading = GutenbergBlock::block_with_name_and_attributes(
749 "core/heading",
750 attributes! {
751 "content": "Regular heading"
752 },
753 );
754 let list_item = GutenbergBlock::block_with_name_and_attributes(
755 "core/list-item",
756 attributes! {
757 "content": "List item"
758 },
759 );
760 let list = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
761 "core/list",
762 attributes! {},
763 vec![list_item],
764 );
765
766 let input = vec![paragraph, heading, list];
767 let result = filter_lock_chapter_blocks(input, false);
768
769 assert_eq!(result.len(), 3);
771 assert_eq!(result[0].name, "core/paragraph");
772 assert_eq!(result[1].name, "core/heading");
773 assert_eq!(result[2].name, "core/list");
774 assert_eq!(result[2].inner_blocks.len(), 1);
775 }
776
777 fn heading(anchor: Option<&str>) -> GutenbergBlock {
778 let mut attributes = attributes! { "content": "Summary", "level": 2 };
779 if let Some(anchor) = anchor {
780 attributes.insert("anchor".into(), Value::String(anchor.to_string()));
781 }
782 GutenbergBlock::block_with_name_and_attributes("core/heading", attributes)
783 }
784
785 fn anchors(blocks: &[GutenbergBlock]) -> Vec<Option<String>> {
786 let mut anchors = Vec::new();
787 for block in blocks {
788 anchors.push(
789 block
790 .attributes
791 .get("anchor")
792 .and_then(Value::as_str)
793 .map(str::to_string),
794 );
795 anchors.extend(self::anchors(&block.inner_blocks));
796 }
797 anchors
798 }
799
800 #[test]
801 fn suffix_duplicate_heading_anchors_keeps_the_first_and_numbers_the_rest() {
802 let output = suffix_duplicate_heading_anchors(vec![
803 heading(Some("summary")),
804 heading(Some("summary")),
805 heading(Some("summary")),
806 ]);
807 assert_eq!(
808 anchors(&output),
809 vec![
810 Some("summary".to_string()),
811 Some("summary-2".to_string()),
812 Some("summary-3".to_string()),
813 ]
814 );
815 }
816
817 #[test]
818 fn suffix_duplicate_heading_anchors_skips_suffixes_already_in_use() {
819 let output = suffix_duplicate_heading_anchors(vec![
820 heading(Some("summary")),
821 heading(Some("summary")),
822 heading(Some("summary-2")),
823 ]);
824 assert_eq!(
825 anchors(&output),
826 vec![
827 Some("summary".to_string()),
828 Some("summary-3".to_string()),
829 Some("summary-2".to_string()),
830 ]
831 );
832 }
833
834 #[test]
835 fn suffix_duplicate_heading_anchors_walks_inner_blocks() {
836 let columns = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
837 "core/columns",
838 attributes! {},
839 vec![heading(Some("summary"))],
840 );
841 let output = suffix_duplicate_heading_anchors(vec![heading(Some("summary")), columns]);
842 assert_eq!(
843 anchors(&output),
844 vec![
845 Some("summary".to_string()),
846 None,
847 Some("summary-2".to_string()),
848 ]
849 );
850 }
851
852 #[test]
853 fn suffix_duplicate_heading_anchors_compares_trimmed_anchors() {
854 let output = suffix_duplicate_heading_anchors(vec![
855 heading(Some("summary")),
856 heading(Some(" summary ")),
857 ]);
858 assert_eq!(
859 anchors(&output),
860 vec![Some("summary".to_string()), Some("summary-2".to_string())]
861 );
862 }
863
864 #[test]
865 fn suffix_duplicate_heading_anchors_leaves_unique_blank_and_non_heading_anchors_alone() {
866 let paragraph = GutenbergBlock::block_with_name_and_attributes(
867 "core/paragraph",
868 attributes! { "anchor": "summary" },
869 );
870 let input = vec![
871 heading(Some("summary")),
872 heading(Some("intro")),
873 heading(Some(" ")),
874 heading(Some(" ")),
875 heading(None),
876 paragraph,
877 ];
878 let output = suffix_duplicate_heading_anchors(input.clone());
879 assert_eq!(output, input);
880 }
881}