initial publish version

This commit is contained in:
Daniel Bulant 2025-07-08 21:40:12 +02:00
parent 30dbf37ada
commit 7ab4b42cf2
No known key found for this signature in database
7 changed files with 221 additions and 69 deletions

58
packages/vite/README.md Normal file
View file

@ -0,0 +1,58 @@
# MDSvexRs
Vite/sveltekit plugin that uses `mdsvexrs-wasm` to convert markdown to svelte files in an optimized way.
This plugin generates raw html (using `{@html}` tags) that result in faster build times (less js generated, less js for esbuild to process), faster changes (setting innerHtml is faster than using JSDOM) and slightly faster loading times.
Main use case was allowing ~2k LoC markdown with embedded svelte to build with less than 20GB of RAM (yes, that's how much esbuild used).
## Use
Install this package
```
pnpm install mdsvexrs
```
And add it to preprocess under `svelte.config.js`:
```js
import { mdsvexrs } from 'mdsvexrs'
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: ...,
extensions: ['.svelte', '.md'],
preprocess: [
mdsvexrs({ layout: "$lib/layout.svelte" })
]
};
```
Note that a layout *is* required and requires a static path - use `$lib` prefix and put your layout under `src/lib`. Layout is a svelte file
that accepts route data and markdown frontmatter as inputs.
As a minimal layout, just render children:
```svelte
<!-- src/lib/layout.svelte -->
<slot />
```
Frontmatter is passed as props, and props passed to markdown component (such as sveltekit `data`) are also passed as props to layout.
Note that markdown scripts are assumed to be 'old' svelte and not runes. They use `$$props` to pass props to layout, which won't work in runes mode (i.e. if you use `$state` or similar in .md `<script>`).
## Added features
Inline code highlighting - use it either via appending `{:lang}` inside inline code or by setting `defaultLang` in frontmatter.
## Differences from MDSvex
- Layout is required
- Most svelte syntax is not supported - this library uses HTML oriented markdown parser, which is then passed unescaped to svelte.
- some easy common fixes are simply quoting argument values if they contain spaces, or moving template logic into separate components and just referencing them
- custom html tags need to be enumerated in config (`customTags: ['a']` in `mdsvexrs({ ... })`). They are still imported from layout.
- they are uppercased during import and used as such, so the above will result in `<A href...></A>`.
- note that overusing custom tags does come with a performance penalty, especially with very common tags like `p` or `code`.
- multiple layouts are not supported

View file

@ -1,6 +1,7 @@
export interface Options { export interface Options {
layout: string; layout: string;
customTags?: string[];
} }
interface Plugin { interface Plugin {

View file

@ -1,13 +1,26 @@
import * as wasm from "mdsvexrs-wasm" import * as wasm from "mdsvexrs-wasm"
/**
* @param {import("./").Options} options
* @returns {import("./").Plugin}
*/
export function mdsvexrs(options) { export function mdsvexrs(options) {
let opts = wasm.get_default_options()
opts.layout = options.layout
if (options.customTags) {
options.customTags.forEach(tag => {
opts.add_custom_tag(tag)
})
}
return { return {
name: 'mdsvexrs', name: 'mdsvexrs',
markup: ({ content, filename }) => { markup: ({ content, filename }) => {
if(!filename || !filename.endsWith('.md')) return if(!filename || !filename.endsWith('.md')) return
const code = wasm.render(content, opts)
return { return {
code: wasm.render(content, options.layout) code
} }
} }
} }

3
packages/wasm/README.md Normal file
View file

@ -0,0 +1,3 @@
# MDSvexRs-wasm
A library to render markdown into svelte components. Internally used by `mdsvexrs`.

View file

@ -5,36 +5,44 @@ use mdsvexrs::Context;
#[global_allocator] #[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// #[wasm_bindgen] #[wasm_bindgen]
// pub struct Options { pub struct Options {
// layout: String, layout: String,
// // path: String, custom_tags: Vec<String>,
// } // path: String,
}
// #[wasm_bindgen]
// impl Options {
// #[wasm_bindgen(getter)]
// pub fn layout(&self) -> String {
// self.layout.clone()
// }
// #[wasm_bindgen(setter)]
// pub fn set_layout(&mut self, layout: String) {
// self.layout = layout;
// }
// }
// #[wasm_bindgen]
// pub fn get_default_options() -> Options {
// Options {
// layout: String::new(),
// }
// }
#[wasm_bindgen] #[wasm_bindgen]
pub fn render(contents: &str, layout: &str) -> String { impl Options {
#[wasm_bindgen(getter)]
pub fn layout(&self) -> String {
self.layout.clone()
}
#[wasm_bindgen(setter)]
pub fn set_layout(&mut self, layout: String) {
self.layout = layout;
}
#[wasm_bindgen]
pub fn add_custom_tag(&mut self, tag: String) {
self.custom_tags.push(tag);
}
}
#[wasm_bindgen]
pub fn get_default_options() -> Options {
Options {
layout: String::new(),
custom_tags: Vec::new(),
}
}
#[wasm_bindgen]
pub fn render(contents: &str, opts: &Options) -> String {
Context::new(mdsvexrs::MdsvexrsOptions { Context::new(mdsvexrs::MdsvexrsOptions {
layout: layout.to_string(), layout: opts.layout.to_string(),
custom_tags: opts.custom_tags.to_vec(),
// path: options.path, // path: options.path,
}) })
.convert(contents) .convert(contents)

View file

@ -32,6 +32,13 @@ impl ToHtmlResult {
Self { html, svelte } Self { html, svelte }
} }
fn from_wrapped(html: (String, bool), svelte: bool) -> Self {
Self {
html: html.0,
svelte: html.1 || svelte,
}
}
fn empty() -> Self { fn empty() -> Self {
Self::new("".to_string(), false) Self::new("".to_string(), false)
} }
@ -71,8 +78,8 @@ impl ToHtml for Root {
impl ToHtml for Blockquote { impl ToHtml for Blockquote {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new( ToHtmlResult::from_wrapped(
format!("<blockquote>{}</blockquote>", children.html), ctx.wrap_in_tag("blockquote", "", children.html),
children.svelte, children.svelte,
) )
} }
@ -102,8 +109,8 @@ impl ToHtml for List {
false => "ul", false => "ul",
}; };
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new( ToHtmlResult::from_wrapped(
format!("<{}>{}</{}>", litype, children.html, litype), ctx.wrap_in_tag(litype, "", children.html),
children.svelte, children.svelte,
) )
} }
@ -133,8 +140,9 @@ impl ToHtml for Yaml {
} }
impl ToHtml for Break { impl ToHtml for Break {
fn to_html(&self, _ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
ToHtmlResult::new("<br>".to_string(), false) let (tag, changed) = ctx.resolve_tag("br");
ToHtmlResult::new(format!("<{tag} />"), changed)
} }
} }
@ -162,9 +170,9 @@ impl ToHtml for InlineCode {
meta: None, meta: None,
}) })
} else { } else {
format!("<code>{}</code>", html_encode(&self.value)) ctx.wrap_in_tag("code", "", html_encode(&self.value))
}; };
ToHtmlResult::new(output, false) ToHtmlResult::from_wrapped(output, false)
} }
} }
@ -183,7 +191,7 @@ impl ToHtml for Delete {
impl ToHtml for Emphasis { impl ToHtml for Emphasis {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<em>{}</em>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("em", "", children.html), children.svelte)
} }
} }
@ -212,7 +220,7 @@ impl ToHtml for Html {
} }
impl ToHtml for Image { impl ToHtml for Image {
fn to_html(&self, _ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let alt = &self.alt; let alt = &self.alt;
let title = self let title = self
.title .title
@ -220,9 +228,10 @@ impl ToHtml for Image {
.map(|t| format!(" title=\"{}\"", t)) .map(|t| format!(" title=\"{}\"", t))
.unwrap_or_default(); .unwrap_or_default();
let url = &self.url; let url = &self.url;
let (tag, changed) = ctx.resolve_tag("img");
ToHtmlResult::new( ToHtmlResult::new(
format!("<img src=\"{}\" alt=\"{}\"{}>", url, alt, title), format!("<{tag} src=\"{url}\" alt=\"{alt}\"{title}>"),
false, changed,
) )
} }
} }
@ -247,8 +256,8 @@ impl ToHtml for Link {
.as_ref() .as_ref()
.map(|t| format!(" title=\"{}\"", t)) .map(|t| format!(" title=\"{}\"", t))
.unwrap_or_default(); .unwrap_or_default();
ToHtmlResult::new( ToHtmlResult::from_wrapped(
format!("<a href=\"{}\"{}>{}</a>", self.url, title, children.html), ctx.wrap_in_tag("a", &format!("href=\"{}\"{}", self.url, title), children.html),
children.svelte, children.svelte,
) )
} }
@ -263,8 +272,8 @@ impl ToHtml for LinkReference {
impl ToHtml for Strong { impl ToHtml for Strong {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new( ToHtmlResult::from_wrapped(
format!("<strong>{}</strong>", children.html), ctx.wrap_in_tag("strong", "", children.html),
children.svelte, children.svelte,
) )
} }
@ -288,9 +297,9 @@ impl ToHtml for Code {
meta: self.meta.clone(), meta: self.meta.clone(),
}) })
} else { } else {
format!("<pre><code>{}</code></pre>", html_encode(&self.value)) ctx.wrap_in_tag("pre", "", ctx.wrap_in_tag("code", "", html_encode(&self.value)))
}; };
ToHtmlResult::new(highlighted, false) ToHtmlResult::from_wrapped(highlighted, false)
} }
} }
@ -335,12 +344,13 @@ impl ToHtml for Heading {
id: slug.clone(), id: slug.clone(),
pos: self.position.clone(), pos: self.position.clone(),
}); });
let (tag, changed) = ctx.resolve_tag(&format!("h{}", self.depth));
ToHtmlResult::new( ToHtmlResult::new(
format!( format!(
"\n<h{} id=\"{}\">{}</h{}>\n", "\n<{tag} id=\"{slug}\">{}</{tag}>\n",
self.depth, slug, children.html, self.depth children.html
), ),
children.svelte, children.svelte || changed,
) )
} }
} }
@ -348,34 +358,35 @@ impl ToHtml for Heading {
impl ToHtml for Table { impl ToHtml for Table {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<table>{}</table>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("table", "", children.html), children.svelte)
} }
} }
impl ToHtml for ThematicBreak { impl ToHtml for ThematicBreak {
fn to_html(&self, _ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
ToHtmlResult::new("\n<hr>\n".to_string(), false) let (tag, changed) = ctx.resolve_tag("hr");
ToHtmlResult::new(format!("\n<{tag}>\n"), changed)
} }
} }
impl ToHtml for TableRow { impl ToHtml for TableRow {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<tr>{}</tr>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("tr", "", children.html), children.svelte)
} }
} }
impl ToHtml for TableCell { impl ToHtml for TableCell {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<td>{}</td>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("td", "", children.html), children.svelte)
} }
} }
impl ToHtml for ListItem { impl ToHtml for ListItem {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<li>{}</li>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("li", "", children.html), children.svelte)
} }
} }
@ -388,7 +399,7 @@ impl ToHtml for Definition {
impl ToHtml for Paragraph { impl ToHtml for Paragraph {
fn to_html(&self, ctx: &mut Context) -> ToHtmlResult { fn to_html(&self, ctx: &mut Context) -> ToHtmlResult {
let children = self.children.to_html(ctx); let children = self.children.to_html(ctx);
ToHtmlResult::new(format!("<p>{}</p>", children.html), children.svelte) ToHtmlResult::from_wrapped(ctx.wrap_in_tag("p", "", children.html), children.svelte)
} }
} }
@ -557,6 +568,7 @@ struct HighlightRequest {
pub struct MdsvexrsOptions { pub struct MdsvexrsOptions {
pub layout: String, pub layout: String,
pub custom_tags: Vec<String>,
// pub path: String, // pub path: String,
} }
@ -583,7 +595,7 @@ impl Context {
} }
} }
fn highlight(&mut self, code: HighlightRequest) -> String { fn highlight(&mut self, code: HighlightRequest) -> (String, bool) {
let theme = &self.theme_set.themes["base16-ocean.dark"]; let theme = &self.theme_set.themes["base16-ocean.dark"];
// #[cfg(not(target_arch = "wasm32"))] // #[cfg(not(target_arch = "wasm32"))]
// let start = Instant::now(); // let start = Instant::now();
@ -593,36 +605,40 @@ impl Context {
if let Some(default) = &self.default_lang { if let Some(default) = &self.default_lang {
lang = default; lang = default;
} else { } else {
return format!("<pre><code>{}</code></pre>", html_encode(&code.code)); return self.wrap_in_tag("pre", "", self.wrap_in_tag("code", "", html_encode(&code.code)));
} }
} }
let syntax = self.syntax_set.find_syntax_by_token(lang); let syntax = self.syntax_set.find_syntax_by_token(lang);
let syntax = match syntax { let syntax = match syntax {
Some(t) => t, Some(t) => t,
None => { None => {
return format!("<pre><code lang=\"{}\">{}</code></pre>", html_encode(lang), html_encode(&code.code)); return self.wrap_in_tag("pre", "", self.wrap_in_tag("code", &format!(" lang=\"{}\"", html_encode(lang)), html_encode(&code.code)));
} }
}; };
let mut highlighter = HighlightLines::new(syntax, theme); let mut highlighter = HighlightLines::new(syntax, theme);
let mut string = String::new(); let mut string = String::new();
let (code_tag, code_changed) = self.resolve_tag("code");
let (pre_tag, pre_changed) = self.resolve_tag("pre");
match code.inline { match code.inline {
true => { true => {
let regions = highlighter let regions = highlighter
.highlight_line(&code.code, &self.syntax_set) .highlight_line(&code.code, &self.syntax_set)
.unwrap(); .unwrap();
string += &format!("<code lang=\"{}\">", html_encode(lang)); string += &format!("<{code_tag} lang=\"{}\">", html_encode(lang));
append_highlighted_html_for_styled_line( append_highlighted_html_for_styled_line(
&regions[..], &regions[..],
IncludeBackground::No, IncludeBackground::No,
&mut string, &mut string,
) )
.unwrap(); .unwrap();
string += "</code>"; string += &format!("</{code_tag}>");
(string, code_changed)
} }
false => { false => {
string += &format!("<pre><code lang=\"{}\">", html_encode(lang)); string += &format!("<{pre_tag}><{code_tag} lang=\"{}\">", html_encode(lang));
for line in LinesWithEndings::from(&code.code) { for line in LinesWithEndings::from(&code.code) {
let regions = highlighter.highlight_line(line, &self.syntax_set).unwrap(); let regions = highlighter.highlight_line(line, &self.syntax_set).unwrap();
append_highlighted_html_for_styled_line( append_highlighted_html_for_styled_line(
@ -632,14 +648,24 @@ impl Context {
) )
.unwrap(); .unwrap();
} }
string += "</code></pre>\n"; string += &format!("</{code_tag}></{pre_tag}>\n");
(string, code_changed || pre_changed)
}
}
} }
};
// #[cfg(not(target_arch = "wasm32"))] { fn resolve_tag(&self, tag: &str) -> (String, bool) {
// self.highlight_times += start.elapsed(); if self.options.custom_tags.contains(&tag.to_string()) {
// } (tag.to_ascii_uppercase(), true)
string } else {
(tag.to_string(), false)
}
}
fn wrap_in_tag(&self, tag: &str, opts: &str, content: impl Into<Wrappable>) -> (String, bool) {
let content: Wrappable = content.into();
let (tag, changed) = self.resolve_tag(tag);
(format!("<{tag} {opts}>{}</{tag}>", content.html), changed || content.svelte)
} }
fn resolve_layout(&self) -> &str { fn resolve_layout(&self) -> &str {
@ -692,7 +718,15 @@ impl Context {
) + 1; ) + 1;
let mut script = value[..end].to_string(); let mut script = value[..end].to_string();
let layout = self.resolve_layout(); let layout = self.resolve_layout();
if self.options.custom_tags.is_empty() {
script += format!("import MDXLayout from \"{}\";", layout).as_str(); script += format!("import MDXLayout from \"{}\";", layout).as_str();
} else {
let custom_tags = self.options.custom_tags
.iter()
.map(|tag| format!("{} as {}", tag, tag.to_ascii_uppercase()))
.join(", ");
script += format!("import MDXLayout, {{ {} }} from \"{}\";", custom_tags, layout).as_str();
}
script += &value[end..]; script += &value[end..];
script script
}; };
@ -722,6 +756,38 @@ impl Context {
} }
} }
struct Wrappable {
html: String,
svelte: bool,
}
impl From<ToHtmlResult> for Wrappable {
fn from(value: ToHtmlResult) -> Self {
Self {
html: value.html,
svelte: value.svelte,
}
}
}
impl From<String> for Wrappable {
fn from(value: String) -> Self {
Self {
html: value,
svelte: false,
}
}
}
impl From<(String, bool)> for Wrappable {
fn from(value: (String, bool)) -> Self {
Self {
html: value.0,
svelte: value.1,
}
}
}
pub(crate) const DEFAULT_MD_OPTIONS: markdown::ParseOptions = markdown::ParseOptions { pub(crate) const DEFAULT_MD_OPTIONS: markdown::ParseOptions = markdown::ParseOptions {
constructs: Constructs { constructs: Constructs {
attention: true, attention: true,

View file

@ -8,6 +8,8 @@ use mdsvexrs::Context;
struct Args { struct Args {
#[arg(short, long)] #[arg(short, long)]
layout: String, layout: String,
#[arg(short, long, default_value = "")]
custom_tags: Vec<String>,
// #[arg(short, long)] // #[arg(short, long)]
// path: String, // path: String,
#[arg(long)] #[arg(long)]
@ -20,6 +22,7 @@ fn main() {
let mut ctx = Context::new(mdsvexrs::MdsvexrsOptions { let mut ctx = Context::new(mdsvexrs::MdsvexrsOptions {
layout: args.layout, layout: args.layout,
custom_tags: args.custom_tags,
// path: args.path, // path: args.path,
}); });