feat: render examples in docs (#772)

* feat: render examples in docs

* fix: create `hover-images` on `startHover`

* dev: update snapshot
This commit is contained in:
Myriad-Dreamin 2024-11-08 15:55:47 +08:00 committed by GitHub
parent d02fa18617
commit d75fd7e74e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 334 additions and 74 deletions

View file

@ -1,10 +1,5 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
extern crate clap;
extern crate ecow;
extern crate tinymist_world;
extern crate typlite;
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
@ -60,7 +55,7 @@ fn main() -> typlite::Result<()> {
} }
fn lib() -> Arc<typlite::scopes::Scopes<Value>> { fn lib() -> Arc<typlite::scopes::Scopes<Value>> {
let mut scopes = typlite::library::library(); let mut scopes = typlite::library::docstring_lib();
// todo: how to import this function correctly? // todo: how to import this function correctly?
scopes.define("cross-link", cross_link as RawFunc); scopes.define("cross-link", cross_link as RawFunc);

View file

@ -36,7 +36,7 @@ use crate::syntax::{
}; };
use crate::upstream::{tooltip_, Tooltip}; use crate::upstream::{tooltip_, Tooltip};
use crate::{ use crate::{
lsp_to_typst, path_to_url, typst_to_lsp, LspPosition, LspRange, PositionEncoding, lsp_to_typst, path_to_url, typst_to_lsp, ColorTheme, LspPosition, LspRange, PositionEncoding,
SemanticTokenContext, TypstRange, VersionedDocument, SemanticTokenContext, TypstRange, VersionedDocument,
}; };
@ -45,6 +45,8 @@ use crate::{
pub struct Analysis { pub struct Analysis {
/// The position encoding for the workspace. /// The position encoding for the workspace.
pub position_encoding: PositionEncoding, pub position_encoding: PositionEncoding,
/// The editor's color theme.
pub color_theme: ColorTheme,
/// The periscope provider. /// The periscope provider.
pub periscope: Option<Arc<dyn PeriscopeProvider + Send + Sync>>, pub periscope: Option<Arc<dyn PeriscopeProvider + Send + Sync>>,
/// The semantic token context. /// The semantic token context.

View file

@ -3,28 +3,30 @@ use std::sync::{Arc, LazyLock};
use ecow::{eco_format, EcoString}; use ecow::{eco_format, EcoString};
use parking_lot::Mutex; use parking_lot::Mutex;
use tinymist_world::base::{EntryState, ShadowApi, TaskInputs}; use tinymist_world::base::{EntryState, ShadowApi, TaskInputs};
use tinymist_world::LspWorld;
use typlite::scopes::Scopes; use typlite::scopes::Scopes;
use typlite::value::{Value, *}; use typlite::value::Value;
use typst::foundations::Bytes; use typst::foundations::Bytes;
use typst::{ use typst::{
diag::StrResult, diag::StrResult,
syntax::{FileId, VirtualPath}, syntax::{FileId, VirtualPath},
}; };
use crate::analysis::SharedContext;
// Unfortunately, we have only 65536 possible file ids and we cannot revoke // Unfortunately, we have only 65536 possible file ids and we cannot revoke
// them. So we share a global file id for all docs conversion. // them. So we share a global file id for all docs conversion.
static DOCS_CONVERT_ID: LazyLock<Mutex<FileId>> = static DOCS_CONVERT_ID: LazyLock<Mutex<FileId>> =
LazyLock::new(|| Mutex::new(FileId::new(None, VirtualPath::new("__tinymist_docs__.typ")))); LazyLock::new(|| Mutex::new(FileId::new(None, VirtualPath::new("__tinymist_docs__.typ"))));
pub(crate) fn convert_docs(world: &LspWorld, content: &str) -> StrResult<EcoString> { pub(crate) fn convert_docs(ctx: &SharedContext, content: &str) -> StrResult<EcoString> {
static DOCS_LIB: LazyLock<Arc<Scopes<Value>>> = LazyLock::new(lib); static DOCS_LIB: LazyLock<Arc<Scopes<Value>>> =
LazyLock::new(|| Arc::new(typlite::library::docstring_lib()));
let conv_id = DOCS_CONVERT_ID.lock(); let conv_id = DOCS_CONVERT_ID.lock();
let entry = EntryState::new_rootless(conv_id.vpath().as_rooted_path().into()).unwrap(); let entry = EntryState::new_rootless(conv_id.vpath().as_rooted_path().into()).unwrap();
let entry = entry.select_in_workspace(*conv_id); let entry = entry.select_in_workspace(*conv_id);
let mut w = world.task(TaskInputs { let mut w = ctx.world.task(TaskInputs {
entry: Some(entry), entry: Some(entry),
inputs: None, inputs: None,
}); });
@ -34,29 +36,10 @@ pub(crate) fn convert_docs(world: &LspWorld, content: &str) -> StrResult<EcoStri
let conv = typlite::Typlite::new(Arc::new(w)) let conv = typlite::Typlite::new(Arc::new(w))
.with_library(DOCS_LIB.clone()) .with_library(DOCS_LIB.clone())
.with_color_theme(ctx.analysis.color_theme)
.annotate_elements(true) .annotate_elements(true)
.convert() .convert()
.map_err(|e| eco_format!("failed to convert to markdown: {e}"))?; .map_err(|e| eco_format!("failed to convert to markdown: {e}"))?;
Ok(conv.replace("```example", "```typ")) Ok(conv.replace("```example", "```typ"))
} }
pub(super) fn lib() -> Arc<Scopes<Value>> {
let mut scopes = typlite::library::library();
// todo: how to import this function correctly?
scopes.define("example", example as RawFunc);
Arc::new(scopes)
}
/// Evaluate a `example`.
pub fn example(mut args: Args) -> typlite::Result<Value> {
let body = get_pos_named!(args, body: Content).0;
let body = body.trim();
let ticks = body.chars().take_while(|t| *t == '`').collect::<String>();
let body = &body[ticks.len()..];
let body = eco_format!("{ticks}typ{body}");
Ok(Value::Content(body))
}

View file

@ -91,6 +91,8 @@ use typst::{model::Document as TypstDocument, syntax::Source};
/// The physical position in a document. /// The physical position in a document.
pub type FramePosition = typst::layout::Position; pub type FramePosition = typst::layout::Position;
pub use typlite::ColorTheme;
/// A compiled document with an self-incremented logical version. /// A compiled document with an self-incremented logical version.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VersionedDocument { pub struct VersionedDocument {

View file

@ -107,7 +107,7 @@ struct DocsChecker<'a> {
impl<'a> DocsChecker<'a> { impl<'a> DocsChecker<'a> {
pub fn check_func_docs(mut self, docs: String) -> Option<DocString> { pub fn check_func_docs(mut self, docs: String) -> Option<DocString> {
let converted = convert_docs(&self.ctx.world, &docs).ok()?; let converted = convert_docs(self.ctx, &docs).ok()?;
let converted = identify_func_docs(&converted).ok()?; let converted = identify_func_docs(&converted).ok()?;
let module = self.ctx.module_by_str(docs)?; let module = self.ctx.module_by_str(docs)?;
@ -135,7 +135,7 @@ impl<'a> DocsChecker<'a> {
} }
pub fn check_var_docs(mut self, docs: String) -> Option<DocString> { pub fn check_var_docs(mut self, docs: String) -> Option<DocString> {
let converted = convert_docs(&self.ctx.world, &docs).ok()?; let converted = convert_docs(self.ctx, &docs).ok()?;
let converted = identify_var_docs(converted).ok()?; let converted = identify_var_docs(converted).ok()?;
let module = self.ctx.module_by_str(docs)?; let module = self.ctx.module_by_str(docs)?;
@ -152,7 +152,7 @@ impl<'a> DocsChecker<'a> {
} }
pub fn check_module_docs(self, docs: String) -> Option<DocString> { pub fn check_module_docs(self, docs: String) -> Option<DocString> {
let converted = convert_docs(&self.ctx.world, &docs).ok()?; let converted = convert_docs(self.ctx, &docs).ok()?;
let converted = identify_tidy_module_docs(converted).ok()?; let converted = identify_tidy_module_docs(converted).ok()?;
Some(DocString { Some(DocString {

View file

@ -96,7 +96,6 @@ impl LanguageState {
// Create the compile handler for client consuming results. // Create the compile handler for client consuming results.
let const_config = self.const_config(); let const_config = self.const_config();
let position_encoding = const_config.position_encoding;
let periscope_args = self.compile_config().periscope_args.clone(); let periscope_args = self.compile_config().periscope_args.clone();
let handle = Arc::new(CompileHandler { let handle = Arc::new(CompileHandler {
#[cfg(feature = "preview")] #[cfg(feature = "preview")]
@ -107,7 +106,11 @@ impl LanguageState {
editor_tx: self.editor_tx.clone(), editor_tx: self.editor_tx.clone(),
stats: Default::default(), stats: Default::default(),
analysis: Arc::new(Analysis { analysis: Arc::new(Analysis {
position_encoding, position_encoding: const_config.position_encoding,
color_theme: match self.compile_config().color_theme.as_deref() {
Some("dark") => tinymist_query::ColorTheme::Dark,
_ => tinymist_query::ColorTheme::Light,
},
periscope: periscope_args.map(|args| { periscope: periscope_args.map(|args| {
let r = TypstPeriscopeProvider(PeriscopeRenderer::new(args)); let r = TypstPeriscopeProvider(PeriscopeRenderer::new(args));
Arc::new(r) as Arc<dyn PeriscopeProvider + Send + Sync> Arc::new(r) as Arc<dyn PeriscopeProvider + Send + Sync>

View file

@ -281,7 +281,7 @@ const CONFIG_ITEMS: &[&str] = &[
"systemFonts", "systemFonts",
"typstExtraArgs", "typstExtraArgs",
"compileStatus", "compileStatus",
"preferredTheme", "colorTheme",
"hoverPeriscope", "hoverPeriscope",
]; ];
// endregion Configuration Items // endregion Configuration Items
@ -474,8 +474,8 @@ pub struct CompileConfig {
pub periscope_args: Option<PeriscopeArgs>, pub periscope_args: Option<PeriscopeArgs>,
/// Typst extra arguments. /// Typst extra arguments.
pub typst_extra_args: Option<CompileExtraOpts>, pub typst_extra_args: Option<CompileExtraOpts>,
/// The preferred theme for the document. /// The preferred color theme for the document.
pub preferred_theme: Option<String>, pub color_theme: Option<String>,
/// Whether the configuration can have a default entry path. /// Whether the configuration can have a default entry path.
pub has_default_entry_path: bool, pub has_default_entry_path: bool,
/// The inputs for the language server protocol. /// The inputs for the language server protocol.
@ -508,8 +508,8 @@ impl CompileConfig {
Some("disable") | None => false, Some("disable") | None => false,
_ => bail!("compileStatus must be either 'enable' or 'disable'"), _ => bail!("compileStatus must be either 'enable' or 'disable'"),
}; };
self.preferred_theme = try_(|| Some(update.get("preferredTheme")?.as_str()?.to_owned())); self.color_theme = try_(|| Some(update.get("colorTheme")?.as_str()?.to_owned()));
log::info!("preferred theme: {:?} {:?}", self.preferred_theme, update); log::info!("color theme: {:?}", self.color_theme);
// periscope_args // periscope_args
self.periscope_args = match update.get("hoverPeriscope") { self.periscope_args = match update.get("hoverPeriscope") {
@ -521,7 +521,7 @@ impl CompileConfig {
}, },
}; };
if let Some(args) = self.periscope_args.as_mut() { if let Some(args) = self.periscope_args.as_mut() {
if args.invert_color == "auto" && self.preferred_theme.as_deref() == Some("dark") { if args.invert_color == "auto" && self.color_theme.as_deref() == Some("dark") {
"always".clone_into(&mut args.invert_color); "always".clone_into(&mut args.invert_color);
} }
} }
@ -583,7 +583,7 @@ impl CompileConfig {
"x-preview".into(), "x-preview".into(),
serde_json::to_string(&PreviewInputs { serde_json::to_string(&PreviewInputs {
version: 1, version: 1,
theme: self.preferred_theme.clone().unwrap_or_default(), theme: self.color_theme.clone().unwrap_or_default(),
}) })
.unwrap() .unwrap()
.into_value(), .into_value(),

View file

@ -5,15 +5,22 @@ pub mod library;
pub mod scopes; pub mod scopes;
pub mod value; pub mod value;
use std::fmt::Write; use core::fmt;
use std::sync::Arc; use std::sync::Arc;
use std::{fmt::Write, sync::LazyLock};
pub use error::*; pub use error::*;
use base64::Engine; use base64::Engine;
use scopes::Scopes; use scopes::Scopes;
use tinymist_world::{base::ShadowApi, EntryReader, LspWorld}; use tinymist_world::{base::ShadowApi, EntryReader, LspWorld};
use typst::{foundations::Bytes, layout::Abs, World}; use typst::foundations::IntoValue;
use typst::{
foundations::{Bytes, Dict},
layout::Abs,
utils::LazyHash,
World,
};
use value::{Args, Value}; use value::{Args, Value};
use ecow::{eco_format, EcoString}; use ecow::{eco_format, EcoString};
@ -22,11 +29,21 @@ use typst_syntax::{
FileId, Source, SyntaxKind, SyntaxNode, VirtualPath, FileId, Source, SyntaxKind, SyntaxNode, VirtualPath,
}; };
pub use typst_syntax as syntax;
/// The result type for typlite. /// The result type for typlite.
pub type Result<T, Err = Error> = std::result::Result<T, Err>; pub type Result<T, Err = Error> = std::result::Result<T, Err>;
pub use tinymist_world::CompileOnceArgs; pub use tinymist_world::CompileOnceArgs;
/// 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).
#[derive(Debug, Default, Clone, Copy)]
pub enum ColorTheme {
#[default]
Light,
Dark,
}
/// Task builder for converting a typst document to Markdown. /// Task builder for converting a typst document to Markdown.
pub struct Typlite { pub struct Typlite {
/// The universe to use for the conversion. /// The universe to use for the conversion.
@ -37,6 +54,8 @@ pub struct Typlite {
do_annotate: bool, do_annotate: bool,
/// Whether to enable GFM (GitHub Flavored Markdown) features. /// Whether to enable GFM (GitHub Flavored Markdown) features.
gfm: bool, gfm: bool,
/// The preferred color theme
theme: Option<ColorTheme>,
} }
impl Typlite { impl Typlite {
@ -50,6 +69,7 @@ impl Typlite {
library: None, library: None,
do_annotate: false, do_annotate: false,
gfm: false, gfm: false,
theme: None,
} }
} }
@ -59,6 +79,12 @@ impl Typlite {
self self
} }
/// Set the preferred color theme.
pub fn with_color_theme(mut self, theme: ColorTheme) -> Self {
self.theme = Some(theme);
self
}
/// Annotate the elements for identification. /// Annotate the elements for identification.
pub fn annotate_elements(mut self, do_annotate: bool) -> Self { pub fn annotate_elements(mut self, do_annotate: bool) -> Self {
self.do_annotate = do_annotate; self.do_annotate = do_annotate;
@ -82,6 +108,7 @@ impl Typlite {
current, current,
gfm: self.gfm, gfm: self.gfm,
do_annotate: self.do_annotate, do_annotate: self.do_annotate,
theme: self.theme,
list_depth: 0, list_depth: 0,
scopes: self scopes: self
.library .library
@ -99,6 +126,7 @@ impl Typlite {
#[derive(Clone)] #[derive(Clone)]
pub struct TypliteWorker { pub struct TypliteWorker {
current: FileId, current: FileId,
theme: Option<ColorTheme>,
gfm: bool, gfm: bool,
do_annotate: bool, do_annotate: bool,
scopes: Arc<Scopes<Value>>, scopes: Arc<Scopes<Value>>,
@ -290,37 +318,84 @@ impl TypliteWorker {
Ok(Value::Content(s)) Ok(Value::Content(s))
} }
fn render(&mut self, node: &SyntaxNode, inline: bool) -> Result<Value> { pub fn render(&mut self, node: &SyntaxNode, inline: bool) -> Result<Value> {
let dark = self.render_inner(node, true)?; let code = node.clone().into_text();
let light = self.render_inner(node, false)?; self.render_code(&code, false, "center", "", inline)
if inline {
Ok(Value::Content(eco_format!(
r#"<picture><source media="(prefers-color-scheme: dark)" srcset="data:image/svg+xml;base64,{dark}"><img style="vertical-align: -0.35em" alt="typst-block" src="data:image/svg+xml;base64,{light}"/></picture>"#
)))
} else {
Ok(Value::Content(eco_format!(
r#"<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image/svg+xml;base64,{dark}"><img alt="typst-block" src="data:image/svg+xml;base64,{light}"/></picture></p>"#
)))
}
} }
fn render_inner(&mut self, node: &SyntaxNode, is_dark: bool) -> Result<String> { pub fn render_code(
let color = if is_dark { &mut self,
r##"#set text(rgb("#c0caf5"))"## code: &str,
is_markup: bool,
align: &str,
extra_attrs: &str,
inline: bool,
) -> Result<Value> {
let theme = self.theme;
let mut render = |theme| self.render_inner(code, is_markup, theme);
let mut content = EcoString::new();
if !inline {
let _ = write!(content, r#"<p align="{align}">"#);
}
let inline_attrs = if inline {
r#" style="vertical-align: -0.35em""#
} else { } else {
"" ""
}; };
let main = Bytes::from(eco_format!( match theme {
r##"#set page(width: auto, height: auto, margin: (y: 0.45em, rest: 0em), fill: rgb("#ffffff00"));{color} Some(theme) => {
{}"##, let data = render(theme)?;
node.clone().into_text() let _ = write!(
).as_bytes().to_owned()); content,
r#"<img{inline_attrs} alt="typst-block" src="data:image/svg+xml;base64,{data}" {extra_attrs}/>"#,
);
}
None => {
let _ = write!(
content,
r#"<picture><source media="(prefers-color-scheme: dark)" srcset="data:image/svg+xml;base64,{dark}"><img{inline_attrs} alt="typst-block" src="data:image/svg+xml;base64,{light}" {extra_attrs}/></picture>"#,
dark = render(ColorTheme::Dark)?,
light = render(ColorTheme::Light)?
);
}
}
if !inline {
content.push_str("</p>");
}
Ok(Value::Content(content))
}
fn render_inner(&mut self, code: &str, is_markup: bool, theme: ColorTheme) -> Result<String> {
static DARK_THEME_INPUT: LazyLock<Arc<LazyHash<Dict>>> = LazyLock::new(|| {
Arc::new(LazyHash::new(Dict::from_iter(std::iter::once((
"x-color-theme".into(),
"dark".into_value(),
)))))
});
let code = WrapCode(code, is_markup);
// let inputs = is_dark.then(|| DARK_THEME_INPUT.clone());
let inputs = match theme {
ColorTheme::Dark => Some(DARK_THEME_INPUT.clone()),
ColorTheme::Light => None,
};
let code = eco_format!(
r##"#set page(width: auto, height: auto, margin: (y: 0.45em, rest: 0em), fill: none);
#set text(fill: rgb("#c0caf5")) if sys.inputs.at("x-color-theme", default: none) == "dark";
{code}"##
);
let main = Bytes::from(code.as_bytes().to_owned());
// let world = LiteWorld::new(main); // let world = LiteWorld::new(main);
let main_id = FileId::new(None, VirtualPath::new("__render__.typ")); let main_id = FileId::new(None, VirtualPath::new("__render__.typ"));
let entry = self.world.entry_state().select_in_workspace(main_id); let entry = self.world.entry_state().select_in_workspace(main_id);
let mut world = self.world.task(tinymist_world::base::TaskInputs { let mut world = self.world.task(tinymist_world::base::TaskInputs {
entry: Some(entry), entry: Some(entry),
inputs: None, inputs,
}); });
world.source_db.take_state(); world.source_db.take_state();
world.map_shadow_by_id(main_id, main).unwrap(); world.map_shadow_by_id(main_id, main).unwrap();
@ -341,7 +416,7 @@ impl TypliteWorker {
Ok(Value::Content(node.clone().into_text())) Ok(Value::Content(node.clone().into_text()))
} }
fn value(res: Value) -> EcoString { pub fn value(res: Value) -> EcoString {
match res { match res {
Value::None => EcoString::new(), Value::None => EcoString::new(),
Value::Content(content) => content, Value::Content(content) => content,
@ -529,5 +604,24 @@ impl TypliteWorker {
} }
} }
struct WrapCode<'a>(&'a str, bool);
impl<'a> fmt::Display for WrapCode<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let is_markup = self.1;
if is_markup {
f.write_str("#[")?;
} else {
f.write_str("#{")?;
}
f.write_str(self.0)?;
if is_markup {
f.write_str("]")
} else {
f.write_str("}")
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View file

@ -4,6 +4,9 @@ use super::*;
use ecow::eco_format; use ecow::eco_format;
use value::*; use value::*;
mod docstring;
pub use docstring::docstring_lib;
pub fn library() -> Scopes<Value> { pub fn library() -> Scopes<Value> {
let mut scopes = Scopes::new(); let mut scopes = Scopes::new();
scopes.define("link", link as RawFunc); scopes.define("link", link as RawFunc);

View file

@ -0,0 +1,58 @@
use super::*;
pub fn docstring_lib() -> Scopes<Value> {
let mut scopes = library();
scopes.define("example", example as RawFunc);
scopes
}
/// Evaluate a `example`.
pub fn example(mut args: Args) -> Result<Value> {
let body = get_pos_named!(args, body: &SyntaxNode);
let body = body
.cast::<ast::Raw>()
.ok_or_else(|| format!("expected raw, found {:?}", body.kind()))?;
let lang = body.lang().map(|l| l.get().as_str()).unwrap_or("typ");
// Handle example docs specially.
// <https://github.com/typst/typst/blob/070e3144b33e9a9e9839c138df2b0a13dde7abc7/docs/src/html.rs#L355>
let mut display = String::new();
let mut compile = String::new();
for line in body.lines() {
let line = line.get();
if let Some(suffix) = line.strip_prefix(">>>") {
compile.push_str(suffix);
compile.push('\n');
} else if let Some(suffix) = line.strip_prefix("<<< ") {
display.push_str(suffix);
display.push('\n');
} else {
display.push_str(line);
display.push('\n');
compile.push_str(line);
compile.push('\n');
}
}
let mut s = EcoString::new();
s.push_str("```");
s.push_str(lang);
s.push('\n');
s.push_str(&display);
s.push('\n');
s.push_str("```");
s.push('\n');
// todo: render examples only if supports HTML
let is_code = lang == "typc";
let rendered = args
.vm
.render_code(&compile, !is_code, "left", r#"width="500px""#, false)?;
s.push_str(&TypliteWorker::value(rendered));
Ok(Value::Content(s))
}

View file

@ -95,7 +95,7 @@ $
$ $
"###), @r###" "###), @r###"
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted"/></picture></p> <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture></p>
"###); "###);
} }

View file

@ -6,14 +6,14 @@ fn test_math_equation() {
$integral x dif x$ $integral x dif x$
"###), @r###" "###), @r###"
<picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img style="vertical-align: -0.35em" alt="typst-block" src="data:image-hash/svg+xml;base64,redacted"/></picture> <picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img style="vertical-align: -0.35em" alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture>
"###); "###);
insta::assert_snapshot!(conv(r###" insta::assert_snapshot!(conv(r###"
$ integral x dif x $ $ integral x dif x $
"###), @r###" "###), @r###"
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted"/></picture></p> <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture></p>
"###); "###);
} }

View file

@ -430,6 +430,16 @@
"disable" "disable"
] ]
}, },
"tinymist.renderDocs": {
"title": "(Experimental) Render Docs",
"description": "(Experimental) Whether to render typst elements in (hover) docs. In VS Code, when this feature is enabled, tinymist will store rendered results in the filesystem's temporary storage to show them in the hover content. Note: Please disable this feature if the editor doesn't support/handle image previewing in docs.",
"type": "string",
"default": "enable",
"enum": [
"enable",
"disable"
]
},
"tinymist.previewFeature": { "tinymist.previewFeature": {
"title": "Enable preview features", "title": "Enable preview features",
"description": "Enable or disable preview features of Typst. Note: restarting the editor is required to change this setting.", "description": "Enable or disable preview features of Typst. Note: restarting the editor is required to change this setting.",

View file

@ -6,7 +6,7 @@ export function loadTinymistConfig() {
let config: Record<string, any> = JSON.parse( let config: Record<string, any> = JSON.parse(
JSON.stringify(vscode.workspace.getConfiguration("tinymist")), JSON.stringify(vscode.workspace.getConfiguration("tinymist")),
); );
config.preferredTheme = "light"; config.colorTheme = "light";
const keys = Object.keys(config); const keys = Object.keys(config);
let values = keys.map((key) => config[key]); let values = keys.map((key) => config[key]);
@ -27,7 +27,7 @@ const STR_VARIABLES = [
"tinymist.outputPath", "tinymist.outputPath",
]; ];
const STR_ARR_VARIABLES = ["fontPaths", "tinymist.fontPaths"]; const STR_ARR_VARIABLES = ["fontPaths", "tinymist.fontPaths"];
const PREFERRED_THEME = ["preferredTheme", "tinymist.preferredTheme"]; const COLOR_THEME = ["colorTheme", "tinymist.colorTheme"];
// todo: documentation that, typstExtraArgs won't get variable extended // todo: documentation that, typstExtraArgs won't get variable extended
export function substVscodeVarsInConfig( export function substVscodeVarsInConfig(
@ -39,7 +39,7 @@ export function substVscodeVarsInConfig(
if (!k) { if (!k) {
return value; return value;
} }
if (PREFERRED_THEME.includes(k)) { if (COLOR_THEME.includes(k)) {
return determineVscodeTheme(); return determineVscodeTheme();
} }
if (STR_VARIABLES.includes(k)) { if (STR_VARIABLES.includes(k)) {

View file

@ -46,6 +46,7 @@ import { devKitFeatureActivate } from "./features/dev-kit";
import { labelFeatureActivate } from "./features/label"; import { labelFeatureActivate } from "./features/label";
import { packageFeatureActivate } from "./features/package"; import { packageFeatureActivate } from "./features/package";
import { dragAndDropActivate } from "./features/drag-and-drop"; import { dragAndDropActivate } from "./features/drag-and-drop";
import { HoverDummyStorage, HoverTmpStorage } from "./features/hover-storage";
export async function activate(context: ExtensionContext): Promise<void> { export async function activate(context: ExtensionContext): Promise<void> {
try { try {
@ -76,6 +77,7 @@ export async function doActivate(context: ExtensionContext): Promise<void> {
extensionState.features.devKit = isDevMode || config.devKit === "enable"; extensionState.features.devKit = isDevMode || config.devKit === "enable";
extensionState.features.dragAndDrop = config.dragAndDrop === "enable"; extensionState.features.dragAndDrop = config.dragAndDrop === "enable";
extensionState.features.onEnter = !!config.onEnterEvent; extensionState.features.onEnter = !!config.onEnterEvent;
extensionState.features.renderDocs = config.renderDocs === "enable";
// Initializes language client // Initializes language client
const client = initClient(context, config); const client = initClient(context, config);
setClient(client); setClient(client);
@ -131,6 +133,10 @@ function initClient(context: ExtensionContext, config: Record<string, any>) {
const trustedCommands = { const trustedCommands = {
enabledCommands: ["tinymist.openInternal", "tinymist.openExternal"], enabledCommands: ["tinymist.openInternal", "tinymist.openExternal"],
}; };
const hoverStorage = extensionState.features.renderDocs
? new HoverTmpStorage(context)
: new HoverDummyStorage();
const clientOptions: LanguageClientOptions = { const clientOptions: LanguageClientOptions = {
documentSelector: typstDocumentSelector, documentSelector: typstDocumentSelector,
initializationOptions: config, initializationOptions: config,
@ -151,12 +157,26 @@ function initClient(context: ExtensionContext, config: Record<string, any>) {
return hover; return hover;
} }
const hoverHandler = await hoverStorage.startHover();
for (const content of hover.contents) { for (const content of hover.contents) {
if (content instanceof vscode.MarkdownString) { if (content instanceof vscode.MarkdownString) {
content.isTrusted = trustedCommands; content.isTrusted = trustedCommands;
content.supportHtml = true; content.supportHtml = true;
if (context.storageUri) {
content.baseUri = Uri.joinPath(context.storageUri, "tmp/");
}
// outline all data "data:image/svg+xml;base64," to render huge image correctly
content.value = content.value.replace(
/\"data\:image\/svg\+xml\;base64,([^\"]*)\"/g,
(_, content) => hoverHandler.storeImage(content),
);
} }
} }
await hoverHandler.finish();
return hover; return hover;
}, },
}, },

View file

@ -0,0 +1,88 @@
import * as vscode from "vscode";
import * as crypto from "crypto";
import { Uri } from "vscode";
import { base64Decode } from "../util";
export interface HoverStorage {
startHover(): Promise<HoverStorageHandler>;
}
export interface HoverStorageHandler {
baseUri(): vscode.Uri | undefined;
storeImage(image: string): string;
finish(): Promise<void>;
}
export class HoverDummyStorage {
startHover() {
return Promise.resolve(new HoverStorageDummyHandler());
}
}
export class HoverTmpStorage {
constructor(readonly context: vscode.ExtensionContext) {}
async startHover() {
// This is a "workspace wide" storage for temporary hover images
if (this.context.storageUri) {
const tmpImageDir = Uri.joinPath(this.context.storageUri, "tmp/hover-images/");
try {
const previousEntries = await vscode.workspace.fs.readDirectory(tmpImageDir);
let deleted = 0;
for (const [name, type] of previousEntries) {
if (type === vscode.FileType.File) {
deleted++;
await vscode.workspace.fs.delete(Uri.joinPath(tmpImageDir, name));
}
}
if (deleted > 0) {
console.log(`Deleted ${deleted} hover images`);
}
} catch {}
try {
await vscode.workspace.fs.createDirectory(tmpImageDir);
return new HoverStorageTmpFsHandler(Uri.joinPath(this.context.storageUri, "tmp/"));
} catch {}
}
return new HoverStorageDummyHandler();
}
}
class HoverStorageTmpFsHandler {
promises: PromiseLike<void>[] = [];
constructor(readonly _baseUri: vscode.Uri) {}
baseUri() {
return this._baseUri;
}
storeImage(content: string) {
const fs = vscode.workspace.fs;
const hash = crypto.createHash("sha256").update(content).digest("hex");
const tmpImagePath = `./hover-images/${hash}.svg`;
const output = Uri.joinPath(this._baseUri, tmpImagePath);
const outputContent = base64Decode(content);
this.promises.push(fs.writeFile(output, Buffer.from(outputContent, "utf-8")));
return tmpImagePath;
}
async finish() {
await Promise.all(this.promises);
}
}
class HoverStorageDummyHandler {
baseUri() {
return undefined;
}
storeImage(_content: string) {
return "";
}
async finish() {
return;
}
}

View file

@ -9,6 +9,7 @@ interface ExtensionState {
dragAndDrop: boolean; dragAndDrop: boolean;
onEnter: boolean; onEnter: boolean;
preview: boolean; preview: boolean;
renderDocs: boolean;
}; };
mut: { mut: {
focusingFile: string | undefined; focusingFile: string | undefined;
@ -25,6 +26,7 @@ export const extensionState: ExtensionState = {
dragAndDrop: false, dragAndDrop: false,
onEnter: false, onEnter: false,
preview: false, preview: false,
renderDocs: false,
}, },
mut: { mut: {
focusingFile: undefined, focusingFile: undefined,