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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +97 -0
- data/.gitignore +4 -0
- data/.rubocop_todo.yml +61 -75
- data/README.adoc +2 -0
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +45 -0
- data/docs/_advanced/index.adoc +15 -0
- data/docs/_advanced/schema.adoc +112 -0
- data/docs/_advanced/step-map.adoc +66 -0
- data/docs/_advanced/steps.adoc +88 -0
- data/docs/_advanced/test-builder.adoc +61 -0
- data/docs/_advanced/transform.adoc +92 -0
- data/docs/_config.yml +174 -0
- data/docs/_features/html-input.adoc +69 -0
- data/docs/_features/html-output.adoc +45 -0
- data/docs/_features/index.adoc +15 -0
- data/docs/_features/marks.adoc +86 -0
- data/docs/_features/node-types.adoc +124 -0
- data/docs/_features/user-mentions.adoc +47 -0
- data/docs/_guides/custom-nodes.adoc +107 -0
- data/docs/_guides/index.adoc +13 -0
- data/docs/_guides/round-trip-html.adoc +91 -0
- data/docs/_guides/serialization.adoc +109 -0
- data/docs/_pages/index.adoc +67 -0
- data/docs/_reference/document-api.adoc +49 -0
- data/docs/_reference/index.adoc +14 -0
- data/docs/_reference/node-api.adoc +79 -0
- data/docs/_reference/schema-api.adoc +95 -0
- data/docs/_reference/transform-api.adoc +77 -0
- data/docs/_understanding/document-model.adoc +65 -0
- data/docs/_understanding/fragment.adoc +52 -0
- data/docs/_understanding/index.adoc +14 -0
- data/docs/_understanding/resolved-position.adoc +53 -0
- data/docs/_understanding/slice.adoc +54 -0
- data/docs/lychee.toml +63 -0
- data/lib/prosereflect/blockquote.rb +9 -0
- data/lib/prosereflect/bullet_list.rb +25 -19
- data/lib/prosereflect/code_block.rb +1 -5
- data/lib/prosereflect/fragment.rb +249 -0
- data/lib/prosereflect/horizontal_rule.rb +9 -0
- data/lib/prosereflect/image.rb +9 -0
- data/lib/prosereflect/input/html.rb +96 -0
- data/lib/prosereflect/node.rb +141 -3
- data/lib/prosereflect/ordered_list.rb +2 -0
- data/lib/prosereflect/output/html.rb +227 -0
- data/lib/prosereflect/parser.rb +9 -0
- data/lib/prosereflect/resolved_pos.rb +256 -0
- data/lib/prosereflect/schema/attribute.rb +57 -0
- data/lib/prosereflect/schema/content_match.rb +656 -0
- data/lib/prosereflect/schema/fragment.rb +166 -0
- data/lib/prosereflect/schema/mark.rb +121 -0
- data/lib/prosereflect/schema/mark_type.rb +130 -0
- data/lib/prosereflect/schema/node.rb +236 -0
- data/lib/prosereflect/schema/node_type.rb +274 -0
- data/lib/prosereflect/schema/schema_main.rb +190 -0
- data/lib/prosereflect/schema/spec.rb +92 -0
- data/lib/prosereflect/schema.rb +39 -0
- data/lib/prosereflect/text.rb +24 -0
- data/lib/prosereflect/transform/attr_step.rb +157 -0
- data/lib/prosereflect/transform/insert_step.rb +115 -0
- data/lib/prosereflect/transform/mapping.rb +82 -0
- data/lib/prosereflect/transform/mark_step.rb +269 -0
- data/lib/prosereflect/transform/replace_around_step.rb +181 -0
- data/lib/prosereflect/transform/replace_step.rb +157 -0
- data/lib/prosereflect/transform/slice.rb +91 -0
- data/lib/prosereflect/transform/step.rb +89 -0
- data/lib/prosereflect/transform/step_map.rb +126 -0
- data/lib/prosereflect/transform/structure.rb +120 -0
- data/lib/prosereflect/transform/transform.rb +341 -0
- data/lib/prosereflect/transform.rb +26 -0
- data/lib/prosereflect/version.rb +1 -1
- data/lib/prosereflect.rb +3 -0
- data/spec/fixtures/documents/formatted_text.yaml +14 -0
- data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
- data/spec/fixtures/documents/lists_doc.yaml +32 -0
- data/spec/fixtures/documents/mixed_content.yaml +40 -0
- data/spec/fixtures/documents/nested_doc.yaml +20 -0
- data/spec/fixtures/documents/simple_doc.yaml +6 -0
- data/spec/fixtures/documents/table_doc.yaml +32 -0
- data/spec/fixtures/documents/transform_test.yaml +14 -0
- data/spec/fixtures/schema/custom_schema.rb +37 -0
- data/spec/fixtures/schema/test_schema.rb +46 -0
- data/spec/fixtures/test_builder/helpers.rb +212 -0
- data/spec/prosereflect/document_spec.rb +1 -1
- data/spec/prosereflect/fragment_spec.rb +273 -0
- data/spec/prosereflect/input/html_spec.rb +197 -1
- data/spec/prosereflect/node_spec.rb +128 -0
- data/spec/prosereflect/output/whitespace_spec.rb +248 -0
- data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
- data/spec/prosereflect/resolved_pos_spec.rb +74 -0
- data/spec/prosereflect/schema/conftest.rb +68 -0
- data/spec/prosereflect/schema/content_match_spec.rb +237 -0
- data/spec/prosereflect/schema/mark_spec.rb +274 -0
- data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
- data/spec/prosereflect/schema/node_type_spec.rb +142 -0
- data/spec/prosereflect/schema/schema_spec.rb +194 -0
- data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
- data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
- data/spec/prosereflect/transform/mapping_spec.rb +226 -0
- data/spec/prosereflect/transform/replace_spec.rb +832 -0
- data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
- data/spec/prosereflect/transform/slice_spec.rb +48 -0
- data/spec/prosereflect/transform/step_map_spec.rb +70 -0
- data/spec/prosereflect/transform/step_spec.rb +211 -0
- data/spec/prosereflect/transform/structure_spec.rb +98 -0
- data/spec/prosereflect/transform/transform_spec.rb +238 -0
- data/spec/spec_helper.rb +1 -0
- 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
|