typlite/
lib.rs

1//! # Typlite
2
3// todo: remove me
4#![allow(missing_docs)]
5
6pub mod attributes;
7pub mod common;
8mod error;
9pub mod parser;
10pub mod tags;
11pub mod writer;
12
13use std::path::PathBuf;
14use std::str::FromStr;
15use std::sync::Arc;
16
17pub use error::*;
18
19use cmark_writer::ast::Node;
20use tinymist_project::base::ShadowApi;
21use tinymist_project::vfs::WorkspaceResolver;
22use tinymist_project::{EntryReader, LspWorld, TaskInputs};
23use tinymist_std::error::prelude::*;
24use typst::World;
25use typst::foundations::Bytes;
26use typst::html::HtmlDocument;
27use typst_syntax::VirtualPath;
28
29pub use crate::common::Format;
30use crate::parser::HtmlToAstParser;
31use crate::writer::WriterFactory;
32use typst_syntax::FileId;
33
34use crate::tinymist_std::typst::foundations::Value::Str;
35use crate::tinymist_std::typst::{LazyHash, TypstDict};
36
37/// The result type for typlite.
38pub type Result<T, Err = Error> = std::result::Result<T, Err>;
39
40pub use cmark_writer::ast;
41pub use tinymist_project::CompileOnceArgs;
42pub use tinymist_std;
43
44#[derive(Clone)]
45pub struct MarkdownDocument {
46    pub base: HtmlDocument,
47    world: Arc<LspWorld>,
48    feat: TypliteFeat,
49    ast: Option<Node>,
50}
51
52impl MarkdownDocument {
53    /// Create a new MarkdownDocument instance
54    pub fn new(base: HtmlDocument, world: Arc<LspWorld>, feat: TypliteFeat) -> Self {
55        Self {
56            base,
57            world,
58            feat,
59            ast: None,
60        }
61    }
62
63    /// Create a MarkdownDocument instance with pre-parsed AST
64    pub fn with_ast(
65        base: HtmlDocument,
66        world: Arc<LspWorld>,
67        feat: TypliteFeat,
68        ast: Node,
69    ) -> Self {
70        Self {
71            base,
72            world,
73            feat,
74            ast: Some(ast),
75        }
76    }
77
78    /// Parse HTML document to AST
79    pub fn parse(&self) -> tinymist_std::Result<Node> {
80        if let Some(ast) = &self.ast {
81            return Ok(ast.clone());
82        }
83        let parser = HtmlToAstParser::new(self.feat.clone(), &self.world);
84        parser.parse(&self.base.root).context_ut("failed to parse")
85    }
86
87    /// Convert content to markdown string
88    pub fn to_md_string(&self) -> tinymist_std::Result<ecow::EcoString> {
89        let mut output = ecow::EcoString::new();
90        let ast = self.parse()?;
91
92        let mut writer = WriterFactory::create(Format::Md);
93        writer
94            .write_eco(&ast, &mut output)
95            .context_ut("failed to write")?;
96
97        Ok(output)
98    }
99
100    /// Convert content to plain text string
101    pub fn to_text_string(&self) -> tinymist_std::Result<ecow::EcoString> {
102        let mut output = ecow::EcoString::new();
103        let ast = self.parse()?;
104
105        let mut writer = WriterFactory::create(Format::Text);
106        writer
107            .write_eco(&ast, &mut output)
108            .context_ut("failed to write")?;
109
110        Ok(output)
111    }
112
113    /// Convert the content to a LaTeX string.
114    pub fn to_tex_string(&self) -> tinymist_std::Result<ecow::EcoString> {
115        let mut output = ecow::EcoString::new();
116        let ast = self.parse()?;
117
118        let mut writer = WriterFactory::create(Format::LaTeX);
119        writer
120            .write_eco(&ast, &mut output)
121            .context_ut("failed to write")?;
122
123        Ok(output)
124    }
125
126    /// Convert the content to a DOCX document
127    #[cfg(feature = "docx")]
128    pub fn to_docx(&self) -> tinymist_std::Result<Vec<u8>> {
129        let ast = self.parse()?;
130
131        let mut writer = WriterFactory::create(Format::Docx);
132        writer.write_vec(&ast).context_ut("failed to write")
133    }
134}
135
136/// A color theme for rendering the content. The valid values can be checked in [color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme).
137#[derive(Debug, Default, Clone, Copy)]
138pub enum ColorTheme {
139    #[default]
140    Light,
141    Dark,
142}
143
144#[derive(Debug, Default, Clone)]
145pub struct TypliteFeat {
146    /// The preferred color theme.
147    pub color_theme: Option<ColorTheme>,
148    /// The path of external assets directory.
149    pub assets_path: Option<PathBuf>,
150    /// Allows GFM (GitHub Flavored Markdown) markups.
151    pub gfm: bool,
152    /// Annotate the elements for identification.
153    pub annotate_elem: bool,
154    /// Embed errors in the output instead of yielding them.
155    pub soft_error: bool,
156    /// Remove HTML tags from the output.
157    pub remove_html: bool,
158    /// The target to convert
159    pub target: Format,
160    /// Import context for code examples (e.g., "#import \"/path/to/file.typ\":
161    /// *")
162    pub import_context: Option<String>,
163    /// Specifies the package to process markup.
164    ///
165    /// ## `article` function
166    ///
167    /// The article function is used to wrap the typst content during
168    /// compilation.
169    ///
170    /// typlite exactly uses the `#article` function to process the content as
171    /// follow:
172    ///
173    /// ```typst
174    /// #import "@local/processor": article
175    /// #article(include "the-processed-content.typ")
176    /// ```
177    ///
178    /// It resembles the regular typst show rule function, like `#show:
179    /// article`.
180    pub processor: Option<String>,
181}
182
183impl TypliteFeat {
184    pub fn prepare_world(
185        &self,
186        world: &LspWorld,
187        format: Format,
188    ) -> tinymist_std::Result<LspWorld> {
189        let entry = world.entry_state();
190        let main = entry.main();
191        let current = main.context("no main file in workspace")?;
192
193        if WorkspaceResolver::is_package_file(current) {
194            bail!("package file is not supported");
195        }
196
197        let wrap_main_id = current.join("__wrap_md_main.typ");
198
199        let (main_id, main_content) = match self.processor.as_ref() {
200            None => (wrap_main_id, None),
201            Some(processor) => {
202                let main_id = current.join("__md_main.typ");
203                let content = format!(
204                    r#"#import {processor:?}: article
205#article(include "__wrap_md_main.typ")"#
206                );
207
208                (main_id, Some(Bytes::from_string(content)))
209            }
210        };
211
212        let mut dict = TypstDict::new();
213        dict.insert("x-target".into(), Str("md".into()));
214        if format == Format::Text || self.remove_html {
215            dict.insert("x-remove-html".into(), Str("true".into()));
216        }
217
218        let task_inputs = TaskInputs {
219            entry: Some(entry.select_in_workspace(main_id.vpath().as_rooted_path())),
220            inputs: Some(Arc::new(LazyHash::new(dict))),
221        };
222
223        let mut world = world.task(task_inputs).html_task().into_owned();
224
225        let markdown_id = FileId::new(
226            Some(
227                typst_syntax::package::PackageSpec::from_str("@local/_markdown:0.1.0")
228                    .context_ut("failed to import markdown package")?,
229            ),
230            VirtualPath::new("lib.typ"),
231        );
232
233        world
234            .map_shadow_by_id(
235                markdown_id.join("typst.toml"),
236                Bytes::from_string(include_str!("markdown-typst.toml")),
237            )
238            .context_ut("cannot map markdown-typst.toml")?;
239        world
240            .map_shadow_by_id(
241                markdown_id,
242                Bytes::from_string(include_str!("markdown.typ")),
243            )
244            .context_ut("cannot map markdown.typ")?;
245
246        world
247            .map_shadow_by_id(
248                wrap_main_id,
249                Bytes::from_string(format!(
250                    r#"#import "@local/_markdown:0.1.0": md-doc, example; #show: md-doc
251{}"#,
252                    world
253                        .source(current)
254                        .context_ut("failed to get main file content")?
255                        .text()
256                )),
257            )
258            .context_ut("cannot map source for main file")?;
259
260        if let Some(main_content) = main_content {
261            world
262                .map_shadow_by_id(main_id, main_content)
263                .context_ut("cannot map source for main file")?;
264        }
265
266        Ok(world)
267    }
268}
269
270/// Task builder for converting a typst document to Markdown.
271pub struct Typlite {
272    /// The universe to use for the conversion.
273    world: Arc<LspWorld>,
274    /// Features for the conversion.
275    feat: TypliteFeat,
276    /// The format to use for the conversion.
277    format: Format,
278}
279
280impl Typlite {
281    /// Creates a new Typlite instance from a [`World`].
282    pub fn new(world: Arc<LspWorld>) -> Self {
283        Self {
284            world,
285            feat: Default::default(),
286            format: Format::Md,
287        }
288    }
289
290    /// Sets conversion features
291    pub fn with_feature(mut self, feat: TypliteFeat) -> Self {
292        self.feat = feat;
293        self
294    }
295
296    pub fn with_format(mut self, format: Format) -> Self {
297        self.format = format;
298        self
299    }
300
301    /// Convert the content to a markdown string.
302    pub fn convert(self) -> tinymist_std::Result<ecow::EcoString> {
303        match self.format {
304            Format::Md => self.convert_doc(Format::Md)?.to_md_string(),
305            Format::LaTeX => self.convert_doc(Format::LaTeX)?.to_tex_string(),
306            Format::Text => self.convert_doc(Format::Text)?.to_text_string(),
307            #[cfg(feature = "docx")]
308            Format::Docx => bail!("docx format is not supported"),
309        }
310    }
311
312    /// Convert the content to a DOCX document
313    #[cfg(feature = "docx")]
314    pub fn to_docx(self) -> tinymist_std::Result<Vec<u8>> {
315        if self.format != Format::Docx {
316            bail!("format is not DOCX");
317        }
318        self.convert_doc(Format::Docx)?.to_docx()
319    }
320
321    /// Convert the content to a markdown document.
322    pub fn convert_doc(self, format: Format) -> tinymist_std::Result<MarkdownDocument> {
323        let world = Arc::new(self.feat.prepare_world(&self.world, format)?);
324        let feat = self.feat.clone();
325        Self::convert_doc_prepared(feat, format, world)
326    }
327
328    /// Convert the content to a markdown document.
329    pub fn convert_doc_prepared(
330        feat: TypliteFeat,
331        format: Format,
332        world: Arc<LspWorld>,
333    ) -> tinymist_std::Result<MarkdownDocument> {
334        // todo: ignoring warnings
335        let base = typst::compile(&world).output?;
336        let mut feat = feat;
337        feat.target = format;
338        Ok(MarkdownDocument::new(base, world.clone(), feat))
339    }
340}
341
342#[cfg(test)]
343mod tests;