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
@@ -0,0 +1,95 @@
1
+ ---
2
+ layout: default
3
+ title: Schema API
4
+ parent: Reference
5
+ nav_order: 4
6
+ ---
7
+ = Schema API
8
+
9
+ == Prosereflect::Schema
10
+
11
+ === Constructor
12
+
13
+ `Schema.new(nodes_spec:, marks_spec:, top_node: nil)`
14
+
15
+ [source,ruby]
16
+ ----
17
+ schema = Prosereflect::Schema.new(
18
+ nodes_spec: {
19
+ "doc" => { content: "block+" },
20
+ "paragraph" => { content: "inline*", group: "block" },
21
+ "text" => { group: "inline" }
22
+ },
23
+ marks_spec: { "bold" => {}, "italic" => {} }
24
+ )
25
+ ----
26
+
27
+ === Methods
28
+
29
+ [cols="1,3"]
30
+ |===
31
+ | Method | Description
32
+
33
+ | `node_type(name)` | Get NodeType by name (raises on unknown)
34
+ | `mark_type(name)` | Get MarkType by name (raises on unknown)
35
+ | `top_node_type` | The top-level NodeType (usually "doc")
36
+ | `node(type, attrs, content, marks)` | Create a validated node
37
+ | `text(text, marks)` | Create a text node
38
+ | `mark(type, attrs)` | Create a mark
39
+ | `node_from_json(json)` | Deserialize node from JSON
40
+ | `mark_from_json(json)` | Deserialize mark from JSON
41
+ |===
42
+
43
+ == Schema::NodeType
44
+
45
+ [cols="1,3"]
46
+ |===
47
+ | Method | Description
48
+
49
+ | `name` | Node type name
50
+ | `attrs` | Hash of Attribute definitions
51
+ | `schema` | Parent Schema
52
+ | `content_match` | ContentMatch for valid children
53
+ | `is_block?` | Whether a block node
54
+ | `is_inline?` | Whether an inline node
55
+ | `is_textblock?` | Whether a block with inline content
56
+ | `is_leaf?` | Whether a leaf node (no children)
57
+ | `is_atom?` | Whether atomic (leaf or atom flag)
58
+ | `text?` | Whether text type
59
+ | `in_group?(name)` | Whether in a named group
60
+ | `create(attrs, content, marks)` | Create node (no validation)
61
+ | `create_checked(attrs, content, marks)` | Create with content validation
62
+ | `create_and_fill(attrs, content, marks)` | Create with default content
63
+ | `valid_content?(fragment)` | Check content validity
64
+ | `allows_mark_type?(mark_type)` | Check if mark type is allowed
65
+ |===
66
+
67
+ == Schema::MarkType
68
+
69
+ [cols="1,3"]
70
+ |===
71
+ | Method | Description
72
+
73
+ | `name` | Mark type name
74
+ | `rank` | Ordering rank
75
+ | `schema` | Parent Schema
76
+ | `create(attrs)` | Create a Schema::Mark instance
77
+ | `excluded` | Set of excluded mark types
78
+ |===
79
+
80
+ == Schema::ContentMatch
81
+
82
+ [cols="1,3"]
83
+ |===
84
+ | Method | Description
85
+
86
+ | `valid_end` | Whether this is a valid terminal state
87
+ | `match_type(node_type)` | Match a node type, returns next state or nil
88
+ | `match_fragment(fragment)` | Match a full fragment
89
+ | `fill_before(after:, to_end:)` | Compute filler content
90
+ | `find_wrapping(target_type)` | Find wrapping for content
91
+ | `inline_content?` | Whether accepts inline content
92
+ | `default_type` | Default NodeType
93
+ | `edge_count` | Number of edges
94
+ | `edge(n)` | Edge at index
95
+ |===
@@ -0,0 +1,77 @@
1
+ ---
2
+ layout: default
3
+ title: Transform API
4
+ parent: Reference
5
+ nav_order: 3
6
+ ---
7
+ = Transform API
8
+
9
+ == Prosereflect::Transform::Transform
10
+
11
+ === Constructor
12
+
13
+ `Transform.new(doc)` -- create a transform chain for the given document.
14
+
15
+ === Step-adding Methods
16
+
17
+ [cols="1,3"]
18
+ |===
19
+ | Method | Description
20
+
21
+ | `add_mark(from, to, mark)` | Add a mark to range
22
+ | `remove_mark(from, to, mark)` | Remove a mark from range
23
+ | `insert(pos, content)` | Insert content at position
24
+ | `delete(from, to)` | Delete content in range
25
+ | `replace(from, to, slice)` | Replace range with slice
26
+ | `replace_with(from, to, *nodes)` | Replace range with nodes
27
+ | `set_node_attribute(pos, attrs)` | Set attributes on node at pos
28
+ | `set_doc_attribute(attrs)` | Set document-level attributes
29
+ | `split(pos, depth)` | Split at position
30
+ | `join(pos, depth)` | Join nodes at position
31
+ | `lift(range, target)` | Lift content out of wrapper
32
+ | `wrap(range, wrappers)` | Wrap content in new nodes
33
+ |===
34
+
35
+ === Access Methods
36
+
37
+ [cols="1,3"]
38
+ |===
39
+ | Method | Description
40
+
41
+ | `doc` | Apply steps and return resulting document
42
+ | `steps` | Array of accumulated Step objects
43
+ | `mapping` | The Mapping tracking position changes
44
+ | `maps` | Array of StepMap objects
45
+ | `size` | Number of accumulated steps
46
+ | `empty?` | Whether no steps are accumulated
47
+ |===
48
+
49
+ === Other Methods
50
+
51
+ [cols="1,3"]
52
+ |===
53
+ | Method | Description
54
+
55
+ | `apply` | Apply all steps (returns self)
56
+ | `rollback` | Undo the last step
57
+ | `clone` | Create a new Transform with same doc
58
+ | `can_apply?` | Check if all steps can be applied
59
+ |===
60
+
61
+ == Step Types
62
+
63
+ All inherit from `Prosereflect::Transform::Step`.
64
+
65
+ * `ReplaceStep` -- replace range with slice
66
+ * `ReplaceAroundStep` -- replace around a gap
67
+ * `AddMarkStep` -- add mark to range
68
+ * `RemoveMarkStep` -- remove mark from range
69
+ * `AttrStep` -- set node attributes
70
+ * `InsertStep` -- insert content at position
71
+ * `DeleteStep` -- delete content in range
72
+
73
+ == Supporting Types
74
+
75
+ * `Slice` -- document slice with open boundaries
76
+ * `StepMap` -- position mapping for a single step
77
+ * `Mapping` -- composed position mapping across multiple steps
@@ -0,0 +1,65 @@
1
+ ---
2
+ layout: default
3
+ title: Document Model
4
+ parent: Understanding
5
+ nav_order: 1
6
+ ---
7
+ = Document Model
8
+
9
+ prosereflect models a ProseMirror document as a tree of Node objects. Every node has a `type`, optional `attrs`, optional `marks`, and optional `content` (child nodes).
10
+
11
+ == Node Hierarchy
12
+
13
+ ----
14
+ Document
15
+ ├── Paragraph
16
+ │ ├── Text (with marks)
17
+ │ └── Text
18
+ ├── Heading (level: 1-6)
19
+ │ └── Text
20
+ ├── Table
21
+ │ ├── TableRow
22
+ │ │ ├── TableHeader
23
+ │ │ └── TableCell
24
+ │ │ └── Paragraph
25
+ │ └── TableRow
26
+ ├── BulletList / OrderedList
27
+ │ └── ListItem
28
+ │ └── Paragraph
29
+ ├── Blockquote
30
+ │ └── Paragraph
31
+ ├── CodeBlockWrapper
32
+ │ └── CodeBlock
33
+ ├── Image
34
+ ├── HorizontalRule
35
+ └── ...
36
+ ----
37
+
38
+ == Node Size
39
+
40
+ Each node has a `node_size` that represents its footprint in the document position space:
41
+
42
+ * **Non-text nodes**: `1` (opening token) + sum of children's `node_size`
43
+ * **Text nodes**: `text.length + 1`
44
+
45
+ Example: A document with one paragraph containing "hi" has `node_size` of 5:
46
+
47
+ [source,ruby]
48
+ ----
49
+ doc.node_size # 1 (doc) + 1 (para open) + 3 ("hi" + 1) = 5
50
+ ----
51
+
52
+ == Positions
53
+
54
+ Positions are integer offsets into the document tree. Position 0 is before the first child of the root. Each non-text node contributes its opening token (position +1) and its content. Text nodes contribute `text.length + 1`.
55
+
56
+ [source,ruby]
57
+ ----
58
+ # doc(p("abc"))
59
+ # Position 0: before doc content
60
+ # Position 1: before paragraph content
61
+ # Position 2: before "a"
62
+ # Position 3: between "a" and "b"
63
+ # Position 4: between "b" and "c"
64
+ # Position 5: after "c" (end of text)
65
+ ----
@@ -0,0 +1,52 @@
1
+ ---
2
+ layout: default
3
+ title: Fragment
4
+ parent: Understanding
5
+ nav_order: 2
6
+ ---
7
+ = Fragment
8
+
9
+ `Prosereflect::Fragment` represents a flat sequence of nodes. It is the primary data structure for node content and slice content.
10
+
11
+ == Creating Fragments
12
+
13
+ [source,ruby]
14
+ ----
15
+ # From an array of nodes
16
+ frag = Prosereflect::Fragment.new([node1, node2])
17
+
18
+ # Empty fragment
19
+ frag = Prosereflect::Fragment.empty
20
+
21
+ # From arbitrary content
22
+ frag = Prosereflect::Fragment.from(node)
23
+ frag = Prosereflect::Fragment.from([node1, node2])
24
+ frag = Prosereflect::Fragment.from(existing_fragment) # returns as-is
25
+ ----
26
+
27
+ == Core Methods
28
+
29
+ * `size` -- total node_size of all contained nodes
30
+ * `empty?` -- whether the fragment has no nodes
31
+ * `length` / `count` -- number of nodes
32
+ * `to_a` -- convert to a plain array
33
+ * `each` -- iterate over nodes
34
+ * `[](index)` -- access node by index
35
+
36
+ == Manipulation
37
+
38
+ * `append(other)` -- concatenate two fragments (returns new Fragment)
39
+ * `cut(from, to)` -- extract a sub-fragment within position range
40
+ * `replace_child(index, replacement)` -- replace a node at index
41
+
42
+ == Traversal
43
+
44
+ * `nodes_between(from, to, &block)` -- iterate nodes within position range, callback receives `(node, position)`
45
+ * `descendants(&block)` -- iterate all descendant nodes
46
+ * `text_between(from, to)` -- extract text between positions
47
+
48
+ == Comparison
49
+
50
+ * `eq?(other)` -- structural equality check
51
+ * `find_diff_start(other)` -- find first position where two fragments differ
52
+ * `find_diff_end(other)` -- find last position where two fragments differ
@@ -0,0 +1,14 @@
1
+ ---
2
+ layout: default
3
+ title: Understanding
4
+ nav_order: 4
5
+ has_children: true
6
+ ---
7
+ = Understanding
8
+
9
+ This section explains the core concepts behind prosereflect's document model.
10
+
11
+ * x:document-model[Document Model] -- Node hierarchy and content model
12
+ * x:fragment[Fragment] -- Flat content collections
13
+ * x:resolved-position[Resolved Positions] -- Position resolution in the document tree
14
+ * x:slice[Slice] -- Document slices with open boundaries
@@ -0,0 +1,53 @@
1
+ ---
2
+ layout: default
3
+ title: Resolved Positions
4
+ parent: Understanding
5
+ nav_order: 3
6
+ ---
7
+ = Resolved Positions
8
+
9
+ `Prosereflect::ResolvedPos` represents a document position that has been resolved to a specific location in the document tree. Resolving a position gives you information about the surrounding context.
10
+
11
+ == Creating a ResolvedPos
12
+
13
+ [source,ruby]
14
+ ----
15
+ doc = Prosereflect::Parser.parse_document(data)
16
+ resolved = doc.resolve(3)
17
+ ----
18
+
19
+ == Properties
20
+
21
+ * `pos` -- the absolute document position
22
+ * `depth` -- how deep in the tree this position is (0 = root)
23
+ * `parent` -- the parent node at current depth
24
+ * `parent_offset` -- offset within the parent node (`pos - start`)
25
+
26
+ == Depth-based Access
27
+
28
+ These methods accept an optional `depth` parameter (default: current depth):
29
+
30
+ * `node(depth)` -- node at the given depth
31
+ * `index(depth)` -- index within parent at depth
32
+ * `start(depth)` -- start position of node at depth
33
+ * `end_(depth)` -- end position of node at depth
34
+
35
+ == Boundary Checks
36
+
37
+ * `block?` -- whether the parent is a block node
38
+ * `inline?` -- whether the parent is an inline node
39
+ * `text_block?` -- whether the parent is a text block
40
+ * `start_of_parent?` -- whether at start of parent
41
+ * `end_of_parent?` -- whether at end of parent
42
+ * `before?` -- whether at the start of the current node
43
+ * `after?` -- whether at the end of the current node
44
+
45
+ == Comparing Positions
46
+
47
+ * `shared_depth(other)` -- the deepest depth shared with another position
48
+ * `block_range(other)` -- create a NodeRange between two positions
49
+
50
+ == Marks
51
+
52
+ * `marks` -- marks at this position
53
+ * `marks_between(from, to, marks)` -- collect marks between positions
@@ -0,0 +1,54 @@
1
+ ---
2
+ layout: default
3
+ title: Slice
4
+ parent: Understanding
5
+ nav_order: 4
6
+ ---
7
+ = Slice
8
+
9
+ `Prosereflect::Transform::Slice` represents a contiguous portion of a document that can be inserted, deleted, or moved. Slices track "open" boundaries for proper node joining during replacements.
10
+
11
+ == Creating Slices
12
+
13
+ [source,ruby]
14
+ ----
15
+ # From a fragment
16
+ slice = Prosereflect::Transform::Slice.new(fragment)
17
+
18
+ # With open boundaries
19
+ slice = Prosereflect::Transform::Slice.new(fragment, open_start: 1, open_end: 1)
20
+
21
+ # Empty slice
22
+ slice = Prosereflect::Transform::Slice.empty
23
+ ----
24
+
25
+ == Open Boundaries
26
+
27
+ `open_start` and `open_end` indicate how many levels of the slice's first/last nodes are "open" (not included in the content). This is used when splitting or joining nodes during transforms:
28
+
29
+ * `open_start = 0` -- the first node is complete
30
+ * `open_start = 1` -- the first node is open at the top (its content extends beyond the slice)
31
+
32
+ == Properties
33
+
34
+ * `content` -- the `Fragment` containing the slice's nodes
35
+ * `open_start` -- number of open levels at the start
36
+ * `open_end` -- number of open levels at the end
37
+ * `size` -- total size including open boundaries (`content_size + open_start + open_end`)
38
+ * `content_size` -- size of just the content fragment
39
+ * `empty?` -- whether the slice has no content and no open boundaries
40
+
41
+ == Methods
42
+
43
+ * `cut(from, to)` -- return a sub-slice
44
+ * `eq?(other)` -- structural equality check
45
+
46
+ == Use in Transforms
47
+
48
+ Slices are used as the replacement content in `ReplaceStep` and `ReplaceAroundStep`:
49
+
50
+ [source,ruby]
51
+ ----
52
+ tx = Prosereflect::Transform::Transform.new(doc)
53
+ tx.replace(2, 5, Prosereflect::Transform::Slice.new(fragment))
54
+ ----
data/docs/lychee.toml ADDED
@@ -0,0 +1,63 @@
1
+ # Lychee Link Checker Configuration
2
+ # For Prosereflect Documentation
3
+ # https://github.com/lycheeverse/lychee
4
+
5
+ # Cache results to avoid re-checking same URLs
6
+ cache = true
7
+ max_cache_age = "1d"
8
+
9
+ # Check both source files and built site
10
+ include_verbatim = true
11
+
12
+ # File types to check (regex patterns)
13
+ include = [
14
+ "_site/**/*.html",
15
+ ".*\\.adoc$",
16
+ ".*\\.md$"
17
+ ]
18
+
19
+ # Excluded paths
20
+ exclude = [
21
+ ".git",
22
+ ".github",
23
+ "node_modules",
24
+ "vendor",
25
+ ".bundle",
26
+ ".sass-cache",
27
+ ".jekyll-cache",
28
+ "_site/.jekyll-cache",
29
+ "Gemfile.lock"
30
+ ]
31
+
32
+ # Link checking behavior
33
+ max_redirects = 10
34
+ max_retries = 3
35
+ timeout = 30
36
+
37
+ # Accept status codes
38
+ accept = [
39
+ "100..=103", # Informational
40
+ "200..=299", # Success
41
+ "429" # Too Many Requests (retry handled by max_retries)
42
+ ]
43
+
44
+ # User agent to identify ourselves
45
+ user_agent = "lychee/prosereflect-docs-link-checker"
46
+
47
+ # Check HTTP, HTTPS, and file:// schemes
48
+ scheme = ["https", "http", "file"]
49
+
50
+ # Handle different link types
51
+ include_mail = false # Don't check mailto: links
52
+
53
+ # Maximum concurrent requests
54
+ max_concurrency = 10
55
+
56
+ # Verbose output for debugging
57
+ verbose = "warn"
58
+
59
+ # Require HTTPS where possible
60
+ require_https = false # Don't enforce
61
+
62
+ # Index files for directory URLs
63
+ index_files = ["index.html"]
@@ -74,6 +74,15 @@ module Prosereflect
74
74
  attrs.delete("citation")
75
75
  end
76
76
 
77
+ def to_h
78
+ hash = super
79
+ if hash["attrs"]&.key?("citation") && hash["attrs"]["citation"].nil?
80
+ hash["attrs"].delete("citation")
81
+ hash.delete("attrs") if hash["attrs"].empty?
82
+ end
83
+ hash
84
+ end
85
+
77
86
  def add_paragraph(text)
78
87
  paragraph = Paragraph.new
79
88
  paragraph.add_text(text)
@@ -19,22 +19,24 @@ module Prosereflect
19
19
 
20
20
  def initialize(attributes = {})
21
21
  attributes[:content] ||= []
22
- attributes[:attrs] ||= { "bullet_style" => nil }
22
+ # Only apply default if attrs key is completely absent
23
+ unless attributes.key?(:attrs) || attributes.key?("attrs")
24
+ attributes[:attrs] = { "bullet_style" => nil }
25
+ end
23
26
  super
24
27
  end
25
28
 
26
- def self.create(attrs = nil)
27
- new(attrs: attrs)
28
- end
29
-
30
- def bullet_style=(value)
31
- @bullet_style = value
32
- self.attrs ||= {}
33
- attrs["bullet_style"] = value
34
- end
35
-
36
- def bullet_style
37
- @bullet_style || attrs&.[]("bullet_style")
29
+ # Use *args to distinguish between create (no args) and create(nil)
30
+ # create with no args -> defaults applied
31
+ # create(nil) from parser -> no defaults, attrs explicitly nil
32
+ def self.create(*args)
33
+ if args.empty?
34
+ # No attrs provided - let initialize apply defaults
35
+ new(type: PM_TYPE)
36
+ else
37
+ attrs = args[0]
38
+ new({ type: PM_TYPE, attrs: attrs })
39
+ end
38
40
  end
39
41
 
40
42
  def add_item(text)
@@ -73,12 +75,16 @@ module Prosereflect
73
75
  end.join("\n")
74
76
  end
75
77
 
76
- # Override to_h to exclude empty attrs
77
- def to_h
78
- hash = super
79
- hash["attrs"] ||= {}
80
- hash["attrs"]["bullet_style"] = bullet_style
81
- hash
78
+ def bullet_style=(value)
79
+ @bullet_style = value
80
+ return if value.nil?
81
+
82
+ self.attrs ||= {}
83
+ attrs["bullet_style"] = value
84
+ end
85
+
86
+ def bullet_style
87
+ @bullet_style || attrs&.[]("bullet_style")
82
88
  end
83
89
  end
84
90
  end
@@ -82,12 +82,8 @@ module Prosereflect
82
82
 
83
83
  def to_h
84
84
  hash = super
85
- hash["attrs"] = {
86
- "content" => content,
87
- "language" => language,
88
- }
85
+ hash["attrs"] = { "language" => language }
89
86
  hash["attrs"]["line_numbers"] = line_numbers if line_numbers
90
- hash.delete("content")
91
87
  hash
92
88
  end
93
89