docspec_oxa_writer/lib.rs
1#![forbid(unsafe_code)]
2//! `DocSpec` event stream to `oxa.dev` JSON writer.
3//!
4//! This crate provides a streaming [`OxaWriter`] that implements [`EventSink`] to convert
5//! `DocSpec` event streams into `oxa.dev` JSON format.
6//!
7//! # Design
8//!
9//! The writer emits JSON tokens directly to the underlying `Write` as events arrive using
10//! `docspec-json` for streaming JSON output. Memory usage is constant regardless of document size.
11//!
12//! # Supported Events
13//!
14//! - `StartDocument` / `EndDocument` — document root
15//! - `StartParagraph` / `EndParagraph` — paragraph blocks
16//! - `Text` — inline text content
17//!
18//! # Example
19//!
20//! ```
21//! use docspec_core::{Event, EventSink, Result};
22//! use docspec_oxa_writer::OxaWriter;
23//!
24//! let mut buf = Vec::<u8>::new();
25//! let mut writer = OxaWriter::new(&mut buf);
26//! writer.handle_event(Event::StartDocument { id: None, language: None, metadata: None })?;
27//! writer.handle_event(Event::StartParagraph { alignment: None, id: None })?;
28//! writer.handle_event(Event::Text {
29//! content: "Hello".to_string(),
30//! })?;
31//! writer.handle_event(Event::EndParagraph)?;
32//! writer.handle_event(Event::EndDocument)?;
33//! writer.finish()?;
34//! let json = String::from_utf8(buf).map_err(|err| docspec_core::Error::Other {
35//! message: err.to_string(),
36//! })?;
37//! assert_eq!(
38//! json,
39//! r#"{"type":"Document","children":[{"type":"Paragraph","children":[{"type":"Text","value":"Hello"}]}]}"#
40//! );
41//! # Ok::<(), docspec_core::Error>(())
42//! ```
43
44use std::io::Write;
45
46use docspec_core::{Event, EventSink, Result};
47use docspec_json::{JsonEmitter, StrusonBackend};
48
49/// Streaming writer that converts a `DocSpec` event stream into `oxa.dev` JSON.
50///
51/// # Supported Events
52///
53/// - `StartDocument` / `EndDocument`
54/// - `StartParagraph` / `EndParagraph`
55/// - `Text` (content only, styles dropped)
56///
57/// All other events are silently ignored (no error, no panic).
58pub struct OxaWriter<W: Write> {
59 document_open: bool,
60 in_paragraph: bool,
61 json: JsonEmitter<StrusonBackend<W>>,
62}
63
64impl<W: Write> OxaWriter<W> {
65 /// Close the currently open paragraph, if any. No-op when no paragraph is open.
66 fn close_paragraph(&mut self) -> Result<()> {
67 if !self.in_paragraph {
68 return Ok(());
69 }
70 self.json.close_array()?;
71 self.json.close_object()?;
72 self.in_paragraph = false;
73 Ok(())
74 }
75
76 /// Create a new `OxaWriter` that writes to `writer`.
77 #[inline]
78 #[must_use]
79 pub fn new(writer: W) -> Self {
80 Self {
81 document_open: false,
82 in_paragraph: false,
83 json: JsonEmitter::new(StrusonBackend::new(writer)),
84 }
85 }
86}
87
88impl<W: Write> EventSink for OxaWriter<W> {
89 #[inline]
90 fn finish(self) -> Result<()> {
91 self.json.finish().map(|_| ())
92 }
93
94 #[inline]
95 fn handle_event(&mut self, event: Event) -> Result<()> {
96 match event {
97 Event::StartDocument { .. } => {
98 if self.document_open {
99 return Ok(());
100 }
101 self.json.open_object()?;
102 self.json.key("type").value("Document")?;
103 self.json.key("children").open_array()?;
104 self.document_open = true;
105 Ok(())
106 }
107 Event::EndDocument => {
108 if !self.document_open {
109 return Ok(());
110 }
111 self.close_paragraph()?;
112 self.json.close_array()?;
113 self.json.close_object()?;
114 self.document_open = false;
115 Ok(())
116 }
117 Event::StartParagraph { .. } => {
118 if !self.document_open || self.in_paragraph {
119 return Ok(());
120 }
121 self.json.open_object()?;
122 self.json.key("type").value("Paragraph")?;
123 self.json.key("children").open_array()?;
124 self.in_paragraph = true;
125 Ok(())
126 }
127 Event::EndParagraph => self.close_paragraph(),
128 Event::Text { content } => {
129 if !self.in_paragraph {
130 return Ok(());
131 }
132 self.json.object(|j| {
133 j.key("type").value("Text")?;
134 j.key("value").value(content.as_str())
135 })
136 }
137 Event::EndBlockQuote
138 | Event::EndCaption
139 | Event::EndDefinitionDetail
140 | Event::EndDefinitionList
141 | Event::EndDefinitionTerm
142 | Event::EndFootnote
143 | Event::EndHeading
144 | Event::EndLink
145 | Event::EndOrderedListItem
146 | Event::EndPreformatted
147 | Event::EndTable
148 | Event::EndTableCell
149 | Event::EndTableHeader
150 | Event::EndTableRow
151 | Event::EndTextStyle
152 | Event::EndUnorderedListItem
153 | Event::FootnoteRef { .. }
154 | Event::Image { .. }
155 | Event::LineBreak
156 | Event::SoftBreak
157 | Event::StartBlockQuote { .. }
158 | Event::StartCaption { .. }
159 | Event::StartDefinitionDetail { .. }
160 | Event::StartDefinitionList { .. }
161 | Event::StartDefinitionTerm { .. }
162 | Event::StartFootnote { .. }
163 | Event::StartHeading { .. }
164 | Event::StartLink { .. }
165 | Event::StartOrderedListItem { .. }
166 | Event::StartPreformatted { .. }
167 | Event::StartTable { .. }
168 | Event::StartTableCell { .. }
169 | Event::StartTableHeader { .. }
170 | Event::StartTableRow { .. }
171 | Event::StartTextStyle { .. }
172 | Event::StartUnorderedListItem { .. }
173 | Event::ThematicBreak { .. }
174 | _ => Ok(()),
175 }
176 }
177}