prosereflect 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +97 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +61 -75
  6. data/README.adoc +2 -0
  7. data/docs/Gemfile +10 -0
  8. data/docs/INDEX.adoc +45 -0
  9. data/docs/_advanced/index.adoc +15 -0
  10. data/docs/_advanced/schema.adoc +112 -0
  11. data/docs/_advanced/step-map.adoc +66 -0
  12. data/docs/_advanced/steps.adoc +88 -0
  13. data/docs/_advanced/test-builder.adoc +61 -0
  14. data/docs/_advanced/transform.adoc +92 -0
  15. data/docs/_config.yml +174 -0
  16. data/docs/_features/html-input.adoc +69 -0
  17. data/docs/_features/html-output.adoc +45 -0
  18. data/docs/_features/index.adoc +15 -0
  19. data/docs/_features/marks.adoc +86 -0
  20. data/docs/_features/node-types.adoc +124 -0
  21. data/docs/_features/user-mentions.adoc +47 -0
  22. data/docs/_guides/custom-nodes.adoc +107 -0
  23. data/docs/_guides/index.adoc +13 -0
  24. data/docs/_guides/round-trip-html.adoc +91 -0
  25. data/docs/_guides/serialization.adoc +109 -0
  26. data/docs/_pages/index.adoc +67 -0
  27. data/docs/_reference/document-api.adoc +49 -0
  28. data/docs/_reference/index.adoc +14 -0
  29. data/docs/_reference/node-api.adoc +79 -0
  30. data/docs/_reference/schema-api.adoc +95 -0
  31. data/docs/_reference/transform-api.adoc +77 -0
  32. data/docs/_understanding/document-model.adoc +65 -0
  33. data/docs/_understanding/fragment.adoc +52 -0
  34. data/docs/_understanding/index.adoc +14 -0
  35. data/docs/_understanding/resolved-position.adoc +53 -0
  36. data/docs/_understanding/slice.adoc +54 -0
  37. data/docs/lychee.toml +63 -0
  38. data/lib/prosereflect/blockquote.rb +9 -0
  39. data/lib/prosereflect/bullet_list.rb +25 -19
  40. data/lib/prosereflect/code_block.rb +1 -5
  41. data/lib/prosereflect/fragment.rb +249 -0
  42. data/lib/prosereflect/horizontal_rule.rb +9 -0
  43. data/lib/prosereflect/image.rb +9 -0
  44. data/lib/prosereflect/input/html.rb +96 -0
  45. data/lib/prosereflect/node.rb +141 -3
  46. data/lib/prosereflect/ordered_list.rb +2 -0
  47. data/lib/prosereflect/output/html.rb +227 -0
  48. data/lib/prosereflect/parser.rb +9 -0
  49. data/lib/prosereflect/resolved_pos.rb +256 -0
  50. data/lib/prosereflect/schema/attribute.rb +57 -0
  51. data/lib/prosereflect/schema/content_match.rb +656 -0
  52. data/lib/prosereflect/schema/fragment.rb +166 -0
  53. data/lib/prosereflect/schema/mark.rb +121 -0
  54. data/lib/prosereflect/schema/mark_type.rb +130 -0
  55. data/lib/prosereflect/schema/node.rb +236 -0
  56. data/lib/prosereflect/schema/node_type.rb +274 -0
  57. data/lib/prosereflect/schema/schema_main.rb +190 -0
  58. data/lib/prosereflect/schema/spec.rb +92 -0
  59. data/lib/prosereflect/schema.rb +39 -0
  60. data/lib/prosereflect/text.rb +24 -0
  61. data/lib/prosereflect/transform/attr_step.rb +157 -0
  62. data/lib/prosereflect/transform/insert_step.rb +115 -0
  63. data/lib/prosereflect/transform/mapping.rb +82 -0
  64. data/lib/prosereflect/transform/mark_step.rb +269 -0
  65. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  66. data/lib/prosereflect/transform/replace_step.rb +157 -0
  67. data/lib/prosereflect/transform/slice.rb +91 -0
  68. data/lib/prosereflect/transform/step.rb +89 -0
  69. data/lib/prosereflect/transform/step_map.rb +126 -0
  70. data/lib/prosereflect/transform/structure.rb +120 -0
  71. data/lib/prosereflect/transform/transform.rb +341 -0
  72. data/lib/prosereflect/transform.rb +26 -0
  73. data/lib/prosereflect/version.rb +1 -1
  74. data/lib/prosereflect.rb +3 -0
  75. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  76. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  77. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  78. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  79. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  80. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  81. data/spec/fixtures/documents/table_doc.yaml +32 -0
  82. data/spec/fixtures/documents/transform_test.yaml +14 -0
  83. data/spec/fixtures/schema/custom_schema.rb +37 -0
  84. data/spec/fixtures/schema/test_schema.rb +46 -0
  85. data/spec/fixtures/test_builder/helpers.rb +212 -0
  86. data/spec/prosereflect/document_spec.rb +1 -1
  87. data/spec/prosereflect/fragment_spec.rb +273 -0
  88. data/spec/prosereflect/input/html_spec.rb +197 -1
  89. data/spec/prosereflect/node_spec.rb +128 -0
  90. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  91. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  92. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  93. data/spec/prosereflect/schema/conftest.rb +68 -0
  94. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  95. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  96. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  97. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  98. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  99. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  100. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  101. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  102. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  103. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  104. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  105. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  106. data/spec/prosereflect/transform/step_spec.rb +211 -0
  107. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  108. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  109. data/spec/spec_helper.rb +1 -0
  110. metadata +90 -2
@@ -648,9 +648,9 @@ RSpec.describe Prosereflect::Input::Html do
648
648
  "content" => [{
649
649
  "type" => "code_block",
650
650
  "attrs" => {
651
- "content" => "def example\n puts \"Hello\"\nend",
652
651
  "language" => "ruby",
653
652
  },
653
+ "content" => ["def example\n puts \"Hello\"\nend"],
654
654
  }],
655
655
  }],
656
656
  }
@@ -794,4 +794,200 @@ RSpec.describe Prosereflect::Input::Html do
794
794
  expect(document.to_h).to eq(expected)
795
795
  end
796
796
  end
797
+
798
+ describe ".parse_with_schema" do
799
+ it "parses HTML and returns a document when validation is bypassed" do
800
+ html = "<p>Hello world</p>"
801
+ allow(described_class).to receive(:validate_against_schema)
802
+ document = described_class.send(:parse_with_schema, html, nil)
803
+ expect(document).to be_a(Prosereflect::Document)
804
+ expect(document.to_h["content"].first["type"]).to eq("paragraph")
805
+ end
806
+
807
+ it "preserves document content when validation is bypassed" do
808
+ html = "<p>Schema test</p>"
809
+ allow(described_class).to receive(:validate_against_schema)
810
+ document = described_class.send(:parse_with_schema, html, nil)
811
+ para = document.to_h["content"].first
812
+ text_node = para["content"].first
813
+ expect(text_node["text"]).to eq("Schema test")
814
+ end
815
+
816
+ it "parses complex HTML with validation bypassed" do
817
+ html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text</p>"
818
+ allow(described_class).to receive(:validate_against_schema)
819
+ document = described_class.send(:parse_with_schema, html, nil)
820
+ content = document.to_h["content"]
821
+ expect(content.length).to eq(2)
822
+ expect(content[0]["type"]).to eq("heading")
823
+ expect(content[1]["type"]).to eq("paragraph")
824
+ end
825
+
826
+ it "rescues ValidationError and returns the document" do
827
+ html = "<p>Validation error test</p>"
828
+ allow(described_class).to receive(:validate_against_schema).and_raise(
829
+ Prosereflect::Input::Html::ValidationError, "Missing required content"
830
+ )
831
+ document = described_class.send(:parse_with_schema, html, nil)
832
+ expect(document).to be_a(Prosereflect::Document)
833
+ expect(document.to_h["content"].first["type"]).to eq("paragraph")
834
+ end
835
+ end
836
+
837
+ describe ".parse_with_rules" do
838
+ it "parses HTML with keep_empty option" do
839
+ html = "<p>Keep empty test</p>"
840
+ document = described_class.send(:parse_with_rules, html, rules: { keep_empty: true })
841
+ expect(document).to be_a(Prosereflect::Document)
842
+ expect(document.to_h["content"].first["type"]).to eq("paragraph")
843
+ end
844
+
845
+ it "parses HTML with empty rules" do
846
+ html = "<p>Empty rules test</p>"
847
+ document = described_class.send(:parse_with_rules, html, rules: {})
848
+ expect(document).to be_a(Prosereflect::Document)
849
+ end
850
+
851
+ it "preserves content with keep_empty false" do
852
+ html = "<p>Keep empty false</p>"
853
+ document = described_class.send(:parse_with_rules, html, rules: { keep_empty: false })
854
+ para = document.to_h["content"].first
855
+ text_node = para["content"].first
856
+ expect(text_node["text"]).to eq("Keep empty false")
857
+ end
858
+
859
+ it "accepts top_node option" do
860
+ html = "<p>Top node test</p>"
861
+ document = described_class.send(:parse_with_rules, html, rules: { top_node: "doc" })
862
+ expect(document.to_h["type"]).to eq("doc")
863
+ end
864
+ end
865
+
866
+ describe ".parse_node" do
867
+ it "parses a single HTML paragraph node" do
868
+ doc = Nokogiri::HTML("<p>Single node</p>")
869
+ html_node = doc.at_css("p")
870
+ result = described_class.send(:parse_node, html_node)
871
+ expect(result).to be_a(Prosereflect::Paragraph)
872
+ end
873
+
874
+ it "parses a text node" do
875
+ doc = Nokogiri::HTML("<p>text content</p>")
876
+ html_node = doc.at_css("p").children.first
877
+ result = described_class.send(:parse_node, html_node)
878
+ expect(result).to be_a(Prosereflect::Text)
879
+ expect(result.text).to eq("text content")
880
+ end
881
+
882
+ it "returns nil for empty text nodes with clear_null" do
883
+ doc = Nokogiri::HTML("<p> </p>")
884
+ html_node = doc.at_css("p").children.first
885
+ result = described_class.send(:parse_node, html_node, clear_null: true)
886
+ expect(result).to be_nil
887
+ end
888
+
889
+ it "returns nil for empty text nodes by default" do
890
+ doc = Nokogiri::HTML("<p> </p>")
891
+ html_node = doc.at_css("p").children.first
892
+ result = described_class.send(:parse_node, html_node)
893
+ expect(result).to be_nil
894
+ end
895
+
896
+ it "accepts node option for parent context" do
897
+ doc = Nokogiri::HTML("<p>parent context</p>")
898
+ html_node = doc.at_css("p")
899
+ parent_node = Prosereflect::Document.new
900
+ result = described_class.send(:parse_node, html_node, node: parent_node)
901
+ expect(result).to be_a(Prosereflect::Paragraph)
902
+ end
903
+
904
+ it "accepts saved_styles option" do
905
+ doc = Nokogiri::HTML("<p>styled</p>")
906
+ html_node = doc.at_css("p")
907
+ result = described_class.send(:parse_node, html_node, saved_styles: [])
908
+ expect(result).to be_a(Prosereflect::Paragraph)
909
+ end
910
+ end
911
+
912
+ describe ".preserve_whitespace?" do
913
+ it "returns true for pre elements" do
914
+ doc = Nokogiri::HTML("<pre>code</pre>")
915
+ pre_node = doc.at_css("pre")
916
+ expect(described_class.send(:preserve_whitespace?, pre_node)).to be true
917
+ end
918
+
919
+ it "returns true for textarea elements" do
920
+ doc = Nokogiri::HTML("<textarea>text</textarea>")
921
+ textarea_node = doc.at_css("textarea")
922
+ expect(described_class.send(:preserve_whitespace?, textarea_node)).to be true
923
+ end
924
+
925
+ it "returns true for elements with white-space: pre style" do
926
+ doc = Nokogiri::HTML('<div style="white-space: pre">text</div>')
927
+ div_node = doc.at_css("div")
928
+ expect(described_class.send(:preserve_whitespace?, div_node)).to be true
929
+ end
930
+
931
+ it "returns false for paragraph elements" do
932
+ doc = Nokogiri::HTML("<p>text</p>")
933
+ p_node = doc.at_css("p")
934
+ expect(described_class.send(:preserve_whitespace?, p_node)).to be false
935
+ end
936
+
937
+ it "returns false for elements without white-space style" do
938
+ doc = Nokogiri::HTML('<div style="color: red">text</div>')
939
+ div_node = doc.at_css("div")
940
+ expect(described_class.send(:preserve_whitespace?, div_node)).to be false
941
+ end
942
+
943
+ it "returns false for elements without style attribute" do
944
+ doc = Nokogiri::HTML("<div>text</div>")
945
+ div_node = doc.at_css("div")
946
+ expect(described_class.send(:preserve_whitespace?, div_node)).to be false
947
+ end
948
+
949
+ it "returns false for elements with white-space but not pre" do
950
+ doc = Nokogiri::HTML('<div style="white-space: nowrap">text</div>')
951
+ div_node = doc.at_css("div")
952
+ expect(described_class.send(:preserve_whitespace?, div_node)).to be false
953
+ end
954
+ end
955
+
956
+ describe ".normalize_whitespace" do
957
+ it "replaces multiple spaces with a single space" do
958
+ expect(described_class.send(:normalize_whitespace, "hello world")).to eq("hello world")
959
+ end
960
+
961
+ it "replaces tabs with spaces" do
962
+ expect(described_class.send(:normalize_whitespace, "hello\tworld")).to eq("hello world")
963
+ end
964
+
965
+ it "replaces newlines with spaces" do
966
+ expect(described_class.send(:normalize_whitespace, "hello\nworld")).to eq("hello world")
967
+ end
968
+
969
+ it "replaces carriage returns with spaces" do
970
+ expect(described_class.send(:normalize_whitespace, "hello\rworld")).to eq("hello world")
971
+ end
972
+
973
+ it "strips leading and trailing whitespace" do
974
+ expect(described_class.send(:normalize_whitespace, " hello ")).to eq("hello")
975
+ end
976
+
977
+ it "handles mixed whitespace" do
978
+ expect(described_class.send(:normalize_whitespace, " hello \t\n world ")).to eq("hello world")
979
+ end
980
+
981
+ it "returns empty string for whitespace-only input" do
982
+ expect(described_class.send(:normalize_whitespace, " ")).to eq("")
983
+ end
984
+
985
+ it "handles an empty string" do
986
+ expect(described_class.send(:normalize_whitespace, "")).to eq("")
987
+ end
988
+
989
+ it "does not modify a clean string" do
990
+ expect(described_class.send(:normalize_whitespace, "hello world")).to eq("hello world")
991
+ end
992
+ end
797
993
  end
@@ -423,4 +423,132 @@ RSpec.describe Prosereflect::Node do
423
423
  expect(node.to_h).to eq(expected)
424
424
  end
425
425
  end
426
+
427
+ describe "#node_size" do
428
+ it "returns 1 for empty node" do
429
+ node = described_class.create("empty")
430
+ expect(node.node_size).to eq(1)
431
+ end
432
+
433
+ it "includes text children" do
434
+ node = described_class.create("parent")
435
+ node.add_child(Prosereflect::Text.create("hello"))
436
+ # 1 (parent) + 6 (text "hello") = 7
437
+ expect(node.node_size).to eq(7)
438
+ end
439
+
440
+ it "sums multiple children" do
441
+ node = described_class.create("parent")
442
+ node.add_child(Prosereflect::Text.create("ab"))
443
+ node.add_child(Prosereflect::Text.create("cd"))
444
+ # 1 (parent) + 3 ("ab") + 3 ("cd") = 7
445
+ expect(node.node_size).to eq(7)
446
+ end
447
+
448
+ it "handles deeply nested content" do
449
+ doc = Prosereflect::Document.create
450
+ para = Prosereflect::Paragraph.create
451
+ para.add_child(Prosereflect::Text.create("hi"))
452
+ doc.add_child(para)
453
+ # 1 (doc) + 1 (para) + 3 (text "hi") = 5
454
+ expect(doc.node_size).to eq(5)
455
+ end
456
+ end
457
+
458
+ describe "#text?" do
459
+ it "returns false for regular nodes" do
460
+ expect(described_class.create("node").text?).to be false
461
+ expect(Prosereflect::Paragraph.create.text?).to be false
462
+ expect(Prosereflect::Document.create.text?).to be false
463
+ end
464
+
465
+ it "returns true for Text nodes" do
466
+ expect(Prosereflect::Text.create("hello").text?).to be true
467
+ end
468
+ end
469
+
470
+ describe "#cut" do
471
+ it "returns self for full range" do
472
+ node = described_class.create("node")
473
+ expect(node.cut(0, 1)).to eq(node)
474
+ end
475
+
476
+ it "returns self for default range" do
477
+ node = described_class.create("node")
478
+ expect(node.cut).to eq(node)
479
+ end
480
+
481
+ it "returns copy with subset of content" do
482
+ node = described_class.create("parent")
483
+ node.add_child(Prosereflect::Text.create("first"))
484
+ node.add_child(Prosereflect::Text.create("second"))
485
+ cut_node = node.cut(0, 1 + 7) # 1 parent + first text (7)
486
+ expect(cut_node).not_to eq(node)
487
+ end
488
+ end
489
+
490
+ describe "#nodes_between" do
491
+ it "yields children in range" do
492
+ node = described_class.create("parent")
493
+ t1 = Prosereflect::Text.create("ab")
494
+ t2 = Prosereflect::Text.create("cd")
495
+ node.add_child(t1)
496
+ node.add_child(t2)
497
+
498
+ visited = []
499
+ node.nodes_between(0, 6) { |n, _pos, _i| visited << n }
500
+ expect(visited).to include(t1)
501
+ end
502
+
503
+ it "does not yield for empty range" do
504
+ node = described_class.create("parent")
505
+ visited = []
506
+ node.nodes_between(0, 0) { |n| visited << n }
507
+ expect(visited).to be_empty
508
+ end
509
+ end
510
+
511
+ describe "#descendants" do
512
+ it "iterates over all descendants" do
513
+ doc = Prosereflect::Document.create
514
+ para = Prosereflect::Paragraph.create
515
+ text = Prosereflect::Text.create("hello")
516
+ para.add_child(text)
517
+ doc.add_child(para)
518
+
519
+ visited = []
520
+ doc.descendants { |n, _pos, _i| visited << n }
521
+ expect(visited).to include(para)
522
+ end
523
+ end
524
+
525
+ describe "#eq?" do
526
+ it "returns true for structurally equal nodes" do
527
+ n1 = described_class.create("node")
528
+ n2 = described_class.create("node")
529
+ expect(n1.eq?(n2)).to be true
530
+ end
531
+
532
+ it "returns false for different types" do
533
+ n1 = described_class.create("a")
534
+ n2 = described_class.create("b")
535
+ expect(n1.eq?(n2)).to be false
536
+ end
537
+ end
538
+
539
+ describe "#copy" do
540
+ it "creates a shallow copy with same type and attrs" do
541
+ node = described_class.create("node", "key" => "val")
542
+ copy = node.copy
543
+ expect(copy.to_h).to eq(node.to_h)
544
+ expect(copy).not_to equal(node)
545
+ end
546
+
547
+ it "creates copy with new content" do
548
+ node = described_class.create("parent")
549
+ copy = node.copy([Prosereflect::Text.create("new")])
550
+ expect(copy.content.length).to eq(1)
551
+ expect(node.content).to be_empty
552
+ end
553
+ end
426
554
  end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "prosereflect/output/html"
5
+
6
+ RSpec.describe Prosereflect::Output::DOMSerializer do # rubocop:disable RSpec/SpecFilePathFormat
7
+ let(:serializer) { described_class.new(nil) }
8
+
9
+ describe "#preserve_whitespace?" do
10
+ it "returns true for code_block nodes" do
11
+ code_block = Prosereflect::CodeBlock.new
12
+ expect(serializer.send(:preserve_whitespace?, code_block)).to be true
13
+ end
14
+
15
+ it "returns true for code_block_wrapper nodes" do
16
+ wrapper = Prosereflect::CodeBlockWrapper.new
17
+ expect(serializer.send(:preserve_whitespace?, wrapper)).to be true
18
+ end
19
+
20
+ it "returns true for pre type nodes" do
21
+ node = Prosereflect::Node.new("pre")
22
+ expect(serializer.send(:preserve_whitespace?, node)).to be true
23
+ end
24
+
25
+ it "returns true for nodes with white-space: pre in style attrs" do
26
+ node = Prosereflect::Node.new("paragraph")
27
+ node.attrs = { "style" => "white-space: pre; color: red" }
28
+ expect(serializer.send(:preserve_whitespace?, node)).to be true
29
+ end
30
+
31
+ it "returns false for paragraph nodes" do
32
+ paragraph = Prosereflect::Paragraph.new
33
+ expect(serializer.send(:preserve_whitespace?, paragraph)).to be false
34
+ end
35
+
36
+ it "returns false for heading nodes" do
37
+ heading = Prosereflect::Heading.new
38
+ heading.level = 1
39
+ expect(serializer.send(:preserve_whitespace?, heading)).to be false
40
+ end
41
+
42
+ it "returns false for nodes without white-space: pre style" do
43
+ node = Prosereflect::Node.new("paragraph")
44
+ node.attrs = { "style" => "color: red" }
45
+ expect(serializer.send(:preserve_whitespace?, node)).to be false
46
+ end
47
+
48
+ it "returns false for nodes with non-string style attrs" do
49
+ node = Prosereflect::Node.new("paragraph")
50
+ node.attrs = { "style" => 123 }
51
+ expect(serializer.send(:preserve_whitespace?, node)).to be false
52
+ end
53
+
54
+ it "returns false when node does not respond to type" do
55
+ expect(serializer.send(:preserve_whitespace?, "not a node")).to be false
56
+ end
57
+
58
+ it "returns false when node does not respond to attrs" do
59
+ node = Prosereflect::Node.new("text")
60
+ allow(node).to receive(:respond_to?).and_call_original
61
+ expect(serializer.send(:preserve_whitespace?, node)).to be false
62
+ end
63
+ end
64
+
65
+ describe "#whitespace_mode" do
66
+ it "returns :preserve for code_block nodes" do
67
+ code_block = Prosereflect::CodeBlock.new
68
+ expect(serializer.send(:whitespace_mode, code_block)).to eq(:preserve)
69
+ end
70
+
71
+ it "returns :preserve for code_block_wrapper nodes" do
72
+ wrapper = Prosereflect::CodeBlockWrapper.new
73
+ expect(serializer.send(:whitespace_mode, wrapper)).to eq(:preserve)
74
+ end
75
+
76
+ it "returns :collapse for paragraph nodes" do
77
+ paragraph = Prosereflect::Paragraph.new
78
+ expect(serializer.send(:whitespace_mode, paragraph)).to eq(:collapse)
79
+ end
80
+
81
+ it "returns :collapse for heading nodes" do
82
+ heading = Prosereflect::Heading.new
83
+ heading.level = 2
84
+ expect(serializer.send(:whitespace_mode, heading)).to eq(:collapse)
85
+ end
86
+
87
+ it "returns :preserve for nodes with white-space: pre style" do
88
+ node = Prosereflect::Node.new("paragraph")
89
+ node.attrs = { "style" => "white-space: pre" }
90
+ expect(serializer.send(:whitespace_mode, node)).to eq(:preserve)
91
+ end
92
+ end
93
+
94
+ describe "#collapse_whitespace" do
95
+ it "collapses multiple spaces into one" do
96
+ expect(serializer.send(:collapse_whitespace, "hello world")).to eq("hello world")
97
+ end
98
+
99
+ it "collapses tabs into a single space" do
100
+ expect(serializer.send(:collapse_whitespace, "hello\tworld")).to eq("hello world")
101
+ end
102
+
103
+ it "collapses mixed tabs and spaces into a single space" do
104
+ expect(serializer.send(:collapse_whitespace, "hello \t world")).to eq("hello world")
105
+ end
106
+
107
+ it "collapses leading spaces" do
108
+ expect(serializer.send(:collapse_whitespace, " hello")).to eq(" hello")
109
+ end
110
+
111
+ it "collapses trailing spaces" do
112
+ expect(serializer.send(:collapse_whitespace, "hello ")).to eq("hello ")
113
+ end
114
+
115
+ it "collapses both leading and trailing spaces" do
116
+ expect(serializer.send(:collapse_whitespace, " hello world ")).to eq(" hello world ")
117
+ end
118
+
119
+ it "handles a single space string" do
120
+ expect(serializer.send(:collapse_whitespace, " ")).to eq(" ")
121
+ end
122
+
123
+ it "handles an empty string" do
124
+ expect(serializer.send(:collapse_whitespace, "")).to eq("")
125
+ end
126
+
127
+ it "does not modify a string with no extra whitespace" do
128
+ expect(serializer.send(:collapse_whitespace, "hello world")).to eq("hello world")
129
+ end
130
+ end
131
+
132
+ describe "#normalize_whitespace" do
133
+ it "replaces tabs with spaces" do
134
+ expect(serializer.send(:normalize_whitespace, "hello\tworld")).to eq("hello world")
135
+ end
136
+
137
+ it "replaces newlines with spaces" do
138
+ expect(serializer.send(:normalize_whitespace, "hello\nworld")).to eq("hello world")
139
+ end
140
+
141
+ it "replaces carriage returns with spaces" do
142
+ expect(serializer.send(:normalize_whitespace, "hello\rworld")).to eq("hello world")
143
+ end
144
+
145
+ it "replaces mixed whitespace with a single space" do
146
+ expect(serializer.send(:normalize_whitespace, "hello \t\n\r world")).to eq("hello world")
147
+ end
148
+
149
+ it "collapses multiple spaces into one" do
150
+ expect(serializer.send(:normalize_whitespace, "hello world")).to eq("hello world")
151
+ end
152
+
153
+ it "handles a string with only whitespace" do
154
+ expect(serializer.send(:normalize_whitespace, "\t\n\r")).to eq(" ")
155
+ end
156
+
157
+ it "handles an empty string" do
158
+ expect(serializer.send(:normalize_whitespace, "")).to eq("")
159
+ end
160
+
161
+ it "does not modify a string with no extra whitespace" do
162
+ expect(serializer.send(:normalize_whitespace, "hello world")).to eq("hello world")
163
+ end
164
+ end
165
+
166
+ describe "#process_text_whitespace" do
167
+ context "when node preserves whitespace" do
168
+ it "returns text unchanged for code_block nodes" do
169
+ code_block = Prosereflect::CodeBlock.new
170
+ text = " hello\n world "
171
+ expect(serializer.send(:process_text_whitespace, text, code_block)).to eq(" hello\n world ")
172
+ end
173
+
174
+ it "returns text unchanged for code_block_wrapper nodes" do
175
+ wrapper = Prosereflect::CodeBlockWrapper.new
176
+ text = " hello\n world "
177
+ expect(serializer.send(:process_text_whitespace, text, wrapper)).to eq(" hello\n world ")
178
+ end
179
+
180
+ it "returns text unchanged for nodes with white-space: pre style" do
181
+ node = Prosereflect::Node.new("paragraph")
182
+ node.attrs = { "style" => "white-space: pre" }
183
+ text = " hello\n world "
184
+ expect(serializer.send(:process_text_whitespace, text, node)).to eq(" hello\n world ")
185
+ end
186
+ end
187
+
188
+ context "when node collapses whitespace" do
189
+ it "collapses whitespace for paragraph nodes" do
190
+ paragraph = Prosereflect::Paragraph.new
191
+ text = "hello world"
192
+ expect(serializer.send(:process_text_whitespace, text, paragraph)).to eq("hello world")
193
+ end
194
+
195
+ it "collapses whitespace for heading nodes" do
196
+ heading = Prosereflect::Heading.new
197
+ heading.level = 1
198
+ text = "hello world"
199
+ expect(serializer.send(:process_text_whitespace, text, heading)).to eq("hello world")
200
+ end
201
+
202
+ it "collapses tabs and spaces for regular nodes" do
203
+ paragraph = Prosereflect::Paragraph.new
204
+ text = " hello \t world "
205
+ expect(serializer.send(:process_text_whitespace, text, paragraph)).to eq(" hello world ")
206
+ end
207
+ end
208
+ end
209
+
210
+ describe "DOMSerializer serialization" do
211
+ it "serializes a single node via #serialize_node" do
212
+ paragraph = Prosereflect::Paragraph.new
213
+ paragraph.add_text("Hello")
214
+
215
+ result = serializer.serialize_node(paragraph)
216
+ expect(result).to include("Hello")
217
+ end
218
+
219
+ it "returns text unchanged via #render_text when no schema is set" do
220
+ text = "bold text"
221
+ bold_mark = Prosereflect::Mark::Bold.new
222
+
223
+ result = serializer.render_text(text, [bold_mark])
224
+ # Without a schema, build_mark_serializers returns {},
225
+ # so apply_mark returns content unmodified
226
+ expect(result).to eq("bold text")
227
+ end
228
+
229
+ it "returns text unchanged via #render_text with multiple marks and no schema" do
230
+ text = "bold italic"
231
+ bold_mark = Prosereflect::Mark::Bold.new
232
+ italic_mark = Prosereflect::Mark::Italic.new
233
+
234
+ result = serializer.render_text(text, [bold_mark, italic_mark])
235
+ expect(result).to eq("bold italic")
236
+ end
237
+
238
+ it "renders text without marks via #render_text" do
239
+ result = serializer.render_text("plain text", [])
240
+ expect(result).to eq("plain text")
241
+ end
242
+
243
+ it "renders text with nil marks via #render_text" do
244
+ result = serializer.render_text("plain text", nil)
245
+ expect(result).to eq("plain text")
246
+ end
247
+ end
248
+ end