From ac36c30cff797b514f6079acd088aaa493fc020e Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:18:57 -0400 Subject: [PATCH 1/3] Move the Python bindings into a py/ sub-crate The repository becomes a Cargo workspace. src/ stays the published mdhtml-crate library and knows nothing about Python; py/ is mdhtml-py, the PyO3 glue that was src/python.rs, built by maturin through manifest-path and never published to crates.io. The published crate loses its optional pyo3 dependency, its python and extension-module features, and its cdylib crate type. The version lives once in [workspace.package] and both crates inherit it, so the wheel and the crate keep the same number. Six items the glue needs beyond the documented API are exported from lib.rs by name (render_inlines, plain, code_block_open, CODE_BLOCK_CLOSE, trailing_attr_span, highlight_md); the modules that hold them stay private. uv's cache keys cover py/, and CI publishes the crate with an explicit -p so it never tries to publish a member. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- .github/workflows/ci.yml | 2 +- Cargo.toml | 20 +++++++++++------ DEV.md | 6 +++++ py/Cargo.toml | 16 ++++++++++++++ src/python.rs => py/src/lib.rs | 40 +++++++++++++++++----------------- pyproject.toml | 3 ++- src/lib.rs | 9 ++++---- src/render.rs | 4 ++-- 8 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 py/Cargo.toml rename src/python.rs => py/src/lib.rs (95%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12882cf..17e7008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: rust-lang/crates-io-auth-action@v1 id: auth - - run: cargo publish + - run: cargo publish -p mdhtml-crate env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/Cargo.toml b/Cargo.toml index ef0b6f8..f571323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ +[workspace] +members = ["py"] + +[workspace.package] +version = "0.1.39" + [package] name = "mdhtml-crate" -version = "0.1.39" +version.workspace = true edition = "2024" rust-version = "1.91" license = "Apache-2.0" @@ -14,7 +20,6 @@ categories = ["parser-implementations", "text-processing"] [lib] name = "mdhtml" path = "src/lib.rs" -crate-type = ["cdylib", "rlib"] [profile.release] lto = false @@ -23,6 +28,9 @@ codegen-units = 16 [profile.release.package.mdhtml-crate] incremental = true +[profile.release.package.mdhtml-py] +incremental = true + [profile.dist] inherits = "release" lto = true @@ -33,16 +41,14 @@ strip = true [profile.dist.package.mdhtml-crate] incremental = false +[profile.dist.package.mdhtml-py] +incremental = false + [dependencies] base64 = "0.23.0" fast5ever = { git = "https://github.com/AnswerDotAI/fast5ever", version = "0.1.5" } html-escape = ">=0.2" -pyo3 = { version = ">=0.28", optional = true } unicode-properties = "0.1" -[features] -python = ["dep:pyo3"] -extension-module = ["python", "pyo3/extension-module"] - [lints.clippy] too_many_arguments = "allow" diff --git a/DEV.md b/DEV.md index a706c50..e58a00b 100644 --- a/DEV.md +++ b/DEV.md @@ -31,6 +31,12 @@ chkstyle python/mdhtml tests The Python tests in `tests/` exercise the built native extension and the fast5ever boundary. Rust integration tests also verify structured diagnostics and parse → canonical Markdown → parse preservation at the rendered MDHTML-tree boundary. +## Layout + +The repository is a Cargo workspace with one published crate and one binding crate per consumer. `src/` is the `mdhtml-crate` library: the parser, the `Document` model, the renderers, and the exporters, with no knowledge of any host language. `py/` is `mdhtml-py`, the PyO3 glue that `python/mdhtml/` imports as `mdhtml._native`; maturin builds it through `manifest-path` in `pyproject.toml`, and it is never published to crates.io. The version lives once, in `[workspace.package]` of the root `Cargo.toml`, and every member inherits it. + +A binding crate can only reach the library's public surface, so anything a binding needs is exported from `src/lib.rs`. The Python glue needs six items beyond the documented API (`render_inlines`, `plain`, `code_block_open`, `CODE_BLOCK_CLOSE`, `trailing_attr_span`, `highlight_md`), exported by name so the modules that hold them stay private. + ## Shared MDHTML core MDHTML is the normative cross-format IR. `Document` is its typed Rust construction model; attributes, structured diagnostics, UTF-8-safe lines, bounded scans, and semantic serializers live in this crate so additional source importers can reuse them directly. Source-specific syntax structures remain private and transient. diff --git a/py/Cargo.toml b/py/Cargo.toml new file mode 100644 index 0000000..6366aff --- /dev/null +++ b/py/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mdhtml-py" +version.workspace = true +edition = "2024" +publish = false + +[lib] +name = "mdhtml_native" +crate-type = ["cdylib"] + +[dependencies] +mdhtml-crate = { path = ".." } +pyo3 = ">=0.28" + +[features] +extension-module = ["pyo3/extension-module"] diff --git a/src/python.rs b/py/src/lib.rs similarity index 95% rename from src/python.rs rename to py/src/lib.rs index 1768d91..0a02713 100644 --- a/src/python.rs +++ b/py/src/lib.rs @@ -6,11 +6,11 @@ use pyo3::pybacked::PyBackedStr; use pyo3::types::{PyDict, PyTuple}; use std::collections::{HashMap, HashSet}; -use crate::ast::{Attr, Block, Document, Inline}; -use crate::inline::EditNode; -use crate::render::{CODE_BLOCK_CLOSE, code_block_open, plain, render_document, render_inlines}; -use crate::resolve; -use crate::{MathMode, Options, TemplateDelimiter, TemplateForm}; +use mdhtml::EditNode; +use mdhtml::ast::{Attr, Block, Document, Inline}; +use mdhtml::resolve; +use mdhtml::{CODE_BLOCK_CLOSE, code_block_open, plain, render as render_document, render_inlines}; +use mdhtml::{MathMode, Options, TemplateDelimiter, TemplateForm}; type TemplateArg = (String, String, String, Option<(String, String)>, String, Option<(String, String, String)>); @@ -46,7 +46,7 @@ fn md2mdhtml( Options { math: parse_math_mode(math)?, bare_autolinks, implicit_figures, templates: parse_templates(templates)?, frontmatter, ..Options::default() }; if let Some(depth) = max_block_depth { options.max_block_depth = depth; } if let Some(depth) = max_link_paren_depth { options.max_link_paren_depth = depth; } - let mut doc = guard("parsing markdown", || py.detach(|| crate::parse(markdown, &options)))?; + let mut doc = guard("parsing markdown", || py.detach(|| mdhtml::parse(markdown, &options)))?; if let Some(callbacks) = callbacks { apply_callbacks(&mut doc, &callbacks)? } let warnings = std::mem::take(&mut doc.diagnostics).into_iter().map(|diagnostic| diagnostic.to_string()).collect(); let meta = std::mem::take(&mut doc.meta); @@ -56,20 +56,20 @@ fn md2mdhtml( #[pyfunction] /// Render canonical MDHTML as deterministic `md` dialect source. -fn mdhtml2md(py: Python<'_>, mdhtml: &str) -> PyResult { guard("rendering MDHTML as Markdown", || py.detach(|| crate::mdhtml2md(mdhtml))) } +fn mdhtml2md(py: Python<'_>, mdhtml: &str) -> PyResult { guard("rendering MDHTML as Markdown", || py.detach(|| mdhtml::mdhtml2md(mdhtml))) } #[pyfunction] #[pyo3(signature = (markdown, width=None))] fn wrap_md(py: Python<'_>, markdown: &str, width: Option) -> PyResult { if width == Some(0) { return Err(PyValueError::new_err("width must be positive")); } - guard("wrapping Markdown", || py.detach(|| crate::wrap_md(markdown, width))) + guard("wrapping Markdown", || py.detach(|| mdhtml::wrap_md(markdown, width))) } #[pyfunction] #[pyo3(signature = (markdown, target_words=700))] fn md_chunks(py: Python<'_>, markdown: &str, target_words: usize) -> PyResult> { if target_words == 0 { return Err(PyValueError::new_err("target_words must be positive")); } - guard("chunking Markdown", || py.detach(|| crate::md_chunks(markdown, target_words))) + guard("chunking Markdown", || py.detach(|| mdhtml::md_chunks(markdown, target_words))) .map(|chunks| chunks.into_iter().map(|chunk| (chunk.md, chunk.start.as_str())).collect()) } @@ -77,7 +77,7 @@ fn md_chunks(py: Python<'_>, markdown: &str, target_words: usize) -> PyResult, markdown: &str, target_words: usize, length_scale: usize) -> PyResult> { if target_words == 0 || length_scale == 0 { return Err(PyValueError::new_err("target_words and length_scale must be positive")); } - guard("chunking Markdown", || py.detach(|| crate::md_chunks_greedy(markdown, target_words, length_scale))) + guard("chunking Markdown", || py.detach(|| mdhtml::md_chunks_greedy(markdown, target_words, length_scale))) .map(|chunks| chunks.into_iter().map(|chunk| (chunk.md, chunk.start.as_str())).collect()) } @@ -85,7 +85,7 @@ fn md_chunks_greedy(py: Python<'_>, markdown: &str, target_words: usize, length_ #[pyo3(signature = (markdown, target_words=700))] fn md_chunks_structural(py: Python<'_>, markdown: &str, target_words: usize) -> PyResult> { if target_words == 0 { return Err(PyValueError::new_err("target_words must be positive")); } - guard("chunking Markdown", || py.detach(|| crate::md_chunks_structural(markdown, target_words))) + guard("chunking Markdown", || py.detach(|| mdhtml::md_chunks_structural(markdown, target_words))) .map(|chunks| chunks.into_iter().map(|chunk| (chunk.md, chunk.start.as_str())).collect()) } @@ -97,7 +97,7 @@ fn md_chunks_structural_batch(py: Python<'_>, markdown: Vec, target py.detach(move || { markdown .into_iter() - .map(|source| crate::md_chunks_structural(&source, target_words).into_iter().map(|chunk| (chunk.md, chunk.start.as_str())).collect()) + .map(|source| mdhtml::md_chunks_structural(&source, target_words).into_iter().map(|chunk| (chunk.md, chunk.start.as_str())).collect()) .collect() }) }) @@ -105,7 +105,7 @@ fn md_chunks_structural_batch(py: Python<'_>, markdown: Vec, target #[pyfunction] fn wiki2mdhtml(py: Python<'_>, wikitext: &str) -> PyResult<(String, Vec)> { - let mut doc = guard("parsing wikitext", || py.detach(|| crate::parse_wikitext(wikitext)))?; + let mut doc = guard("parsing wikitext", || py.detach(|| mdhtml::parse_wikitext(wikitext)))?; let warnings = std::mem::take(&mut doc.diagnostics).into_iter().map(|diagnostic| diagnostic.to_string()).collect(); Ok((guard("rendering wikitext", || py.detach(|| render_document(&doc)))?, warnings)) } @@ -123,7 +123,7 @@ fn guard(what: &str, f: impl FnOnce() -> T) -> PyResult { fn blocks(py: Python<'_>, markdown: &str, math: &str, implicit_figures: bool, templates: Option>, nested: bool) -> PyResult>> { let options = Options { math: parse_math_mode(math)?, implicit_figures, nested_spans: nested, templates: parse_templates(templates)?, ..Options::default() }; - let spans = guard("parsing markdown", || py.detach(|| crate::block_spans(markdown, &options)))?; + let spans = guard("parsing markdown", || py.detach(|| mdhtml::block_spans(markdown, &options)))?; spans .into_iter() .map(|span| { @@ -158,7 +158,7 @@ fn blocks(py: Python<'_>, markdown: &str, math: &str, implicit_figures: bool, te #[pyo3(signature = (markdown, *, math = "brackets", templates = None))] fn anchors(py: Python<'_>, markdown: &str, math: &str, templates: Option>) -> PyResult>> { let options = Options { math: parse_math_mode(math)?, templates: parse_templates(templates)?, ..Options::default() }; - let doc = guard("parsing markdown", || crate::parse(markdown, &options))?; + let doc = guard("parsing markdown", || mdhtml::parse(markdown, &options))?; resolve::doc_anchors(&doc) .into_iter() .map(|(id, kind, text)| { @@ -177,12 +177,12 @@ fn target_kind(name: &str) -> Option<&'static str> { resolve::target_kind(name) /// Byte range of the trailing attribute group of `line`, or None: the group /// `strip_trailing_attr` would consume, as the dialect's own grammar judges it. #[pyfunction] -fn trailing_attr_span(line: &str) -> Option<(usize, usize)> { crate::attrs::trailing_attr_span(line) } +fn trailing_attr_span(line: &str) -> Option<(usize, usize)> { mdhtml::trailing_attr_span(line) } #[pyfunction] #[pyo3(signature = (markdown, *, math = "brackets", templates = None))] fn edit_nodes(py: Python<'_>, markdown: &str, math: &str, templates: Option>) -> PyResult>> { let options = Options { math: parse_math_mode(math)?, templates: parse_templates(templates)?, ..Options::default() }; - let nodes = guard("parsing markdown edit nodes", || crate::block::parse_edit_nodes(markdown, &options))?; + let nodes = guard("parsing markdown edit nodes", || mdhtml::edit_nodes(markdown, &options))?; nodes .into_iter() .map(|node| { @@ -309,7 +309,7 @@ fn dialect_css(preview: bool) -> String { resolve::dialect_css(preview) } /// scopes documented in `highlight.rs`. #[pyfunction] #[pyo3(signature = (src, class_prefix="hl-"))] -fn highlight_md(src: &str, class_prefix: &str) -> String { crate::highlight::highlight_md(src, class_prefix) } +fn highlight_md(src: &str, class_prefix: &str) -> String { mdhtml::highlight_md(src, class_prefix) } /// Heading numbering per a `{lvlText: numFmt}` scheme (Word semantics): /// `bump` at each heading, then read its display or full-context number. @@ -901,7 +901,7 @@ fn export_html( auto_ids: bool, gh_ids: bool, ) -> PyResult<(String, Vec)> { - use crate::export_html::{HlMode, HtmlExportOptions, NumberHeadings, RefsMode}; + use mdhtml::export_html::{HlMode, HtmlExportOptions, NumberHeadings, RefsMode}; let number_headings = match number_headings { None => None, Some(o) if o.is_none() => None, @@ -946,7 +946,7 @@ fn export_html( auto_ids, gh_ids, }; - let result = if hl_lang.is_none() && code_wrap.is_none() && hl_fn.is_none() { py.detach(|| crate::export_html::export_html(src, &opts)) } else { crate::export_html::export_html(src, &opts) }; + let result = if hl_lang.is_none() && code_wrap.is_none() && hl_fn.is_none() { py.detach(|| mdhtml::export_html::export_html(src, &opts)) } else { mdhtml::export_html::export_html(src, &opts) }; if let Some(e) = hook_err.into_inner().unwrap() { return Err(e); } result.map_err(vr) } diff --git a/pyproject.toml b/pyproject.toml index c9cd06a..1eb12be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dev = ["fastship>=0.0.14", "maturin~=1.0", "pytest", "execnb>=0.3.2", "fastpylig mdhtml = "mdhtml" [tool.maturin] +manifest-path = "py/Cargo.toml" features = ["extension-module"] python-source = "python" module-name = "mdhtml._native" @@ -48,7 +49,7 @@ addopts = "-m 'not slow'" timeout = 60 [tool.uv] -cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.rs" }, { file = "src/**/*.css" }, { file = "Cargo.toml" }, { file = ".git/fastws-cargo-key" }] +cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.rs" }, { file = "src/**/*.css" }, { file = "py/**/*.rs" }, { file = "Cargo.toml" }, { file = "py/Cargo.toml" }, { file = ".git/fastws-cargo-key" }] [tool.fastship] branch = "main" diff --git a/src/lib.rs b/src/lib.rs index 09f7131..41e5165 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,33 +16,34 @@ mod highlight; mod inline; mod line; pub mod markdown; -#[cfg(feature = "python")] -mod python; mod render; pub mod resolve; pub mod scan; pub mod template; pub mod wikitext; -mod write_md; mod wrap; +mod write_md; pub use ast::{ Align, Attr, Block, DefinitionItem, DefinitionTerm, Document, Footnote, HtmlToken, Inline, ListItem, Operation, OperationArg, TableCell, TableCellData, TableRow, TableRowData, }; +pub use attrs::trailing_attr_span; pub use block::BlockSpan; pub use chunk::{ ChunkStart, MdChunk, MdChunkRange, document_chunk_ranges_structural, document_chunks_structural, md_chunks, md_chunks_greedy, md_chunks_structural, }; pub use diagnostic::{Diagnostic, Severity}; pub use fast5ever; +pub use highlight::highlight_md; pub use inline::{EditNode, XrefSeg}; pub use line::{LineOffset, SourceLocation, SourceSpan}; pub use markdown::{dom2md, mdhtml2md}; +pub use render::{CODE_BLOCK_CLOSE, code_block_open, plain, render_inlines}; pub use template::TokenKind; pub use wikitext::{parse as parse_wikitext, wiki2md, wiki2mdhtml}; -pub use write_md::render_md; pub use wrap::wrap_md; +pub use write_md::render_md; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MathMode { diff --git a/src/render.rs b/src/render.rs index 8f521b1..644367b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -11,7 +11,7 @@ pub(crate) fn render_document(doc: &Document) -> String { } #[allow(dead_code)] -pub(crate) fn render_inlines(items: &[Inline]) -> String { +pub fn render_inlines(items: &[Inline]) -> String { let doc = Document::default(); let mut r = Renderer::new(&doc); let mut out = String::new(); @@ -550,7 +550,7 @@ pub fn code_block_open(attr: &Attr, lang: Option<&str>) -> String { out } -pub(crate) fn plain(items: &[Inline]) -> String { +pub fn plain(items: &[Inline]) -> String { let mut out = String::new(); for item in items { match item { From f457864a1ff89065018e34f81fdeb4e7c0e9549f Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:21:43 -0400 Subject: [PATCH 2/3] Add a wasm/ sub-crate and the @answerdotai/mdhtml npm package wasm/ is mdhtml-wasm, the wasm-bindgen glue for the browser, built the same way py/ is built for Python: it depends on the library crate by path and exports the functions JavaScript may call, starting with md2mdhtml. wasm/package.json wraps the build output as the npm package @answerdotai/mdhtml, private until its first publish. Its version field is a copy of the workspace version, listed under [tool.fastship].version-files so ship-bump keeps it in step. `npm run build` in wasm/ compiles with a new `wasm` profile (dist at opt-level z, which brings the .wasm from 420 KB to 283 KB) and runs wasm-bindgen into the ignored wasm/pkg/. The bindgen crate is pinned exactly because the CLI must match it. CI gains a compile-only wasm job; there is no publish step yet. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- .github/workflows/ci.yml | 10 ++++++++++ .gitignore | 1 + Cargo.toml | 6 +++++- DEV.md | 6 +++++- pyproject.toml | 1 + wasm/Cargo.toml | 12 ++++++++++++ wasm/package.json | 15 +++++++++++++++ wasm/src/lib.rs | 10 ++++++++++ 8 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 wasm/Cargo.toml create mode 100644 wasm/package.json create mode 100644 wasm/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17e7008..78e40ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,16 @@ jobs: - run: pip install -e '.[dev]' - run: pytest -q + wasm: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - run: cargo build -p mdhtml-wasm --target wasm32-unknown-unknown --profile wasm + build: needs: test strategy: diff --git a/.gitignore b/.gitignore index 5c5458b..f51f2bc 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ docs/_build/ # PyBuilder target/ +wasm/pkg/ # Jupyter Notebook .ipynb_checkpoints diff --git a/Cargo.toml b/Cargo.toml index f571323..f7465df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["py"] +members = ["py", "wasm"] [workspace.package] version = "0.1.39" @@ -44,6 +44,10 @@ incremental = false [profile.dist.package.mdhtml-py] incremental = false +[profile.wasm] +inherits = "dist" +opt-level = "z" + [dependencies] base64 = "0.23.0" fast5ever = { git = "https://github.com/AnswerDotAI/fast5ever", version = "0.1.5" } diff --git a/DEV.md b/DEV.md index e58a00b..fc43690 100644 --- a/DEV.md +++ b/DEV.md @@ -33,7 +33,11 @@ The Python tests in `tests/` exercise the built native extension and the fast5ev ## Layout -The repository is a Cargo workspace with one published crate and one binding crate per consumer. `src/` is the `mdhtml-crate` library: the parser, the `Document` model, the renderers, and the exporters, with no knowledge of any host language. `py/` is `mdhtml-py`, the PyO3 glue that `python/mdhtml/` imports as `mdhtml._native`; maturin builds it through `manifest-path` in `pyproject.toml`, and it is never published to crates.io. The version lives once, in `[workspace.package]` of the root `Cargo.toml`, and every member inherits it. +The repository is a Cargo workspace with one published crate and one binding crate per consumer. `src/` is the `mdhtml-crate` library: the parser, the `Document` model, the renderers, and the exporters, with no knowledge of any host language. The binding crates are never published to crates.io. The version lives once, in `[workspace.package]` of the root `Cargo.toml`, and every member inherits it. + +`py/` is `mdhtml-py`, the PyO3 glue that `python/mdhtml/` imports as `mdhtml._native`. maturin builds it through `manifest-path` in `pyproject.toml`. + +`wasm/` is `mdhtml-wasm`, the `wasm-bindgen` glue for the browser, and `wasm/package.json` is the npm package `@answerdotai/mdhtml` around it. The package is private until its first publish, and its `version` field is a copy that `ship-bump` keeps in step through `[tool.fastship].version-files`. Building needs the wasm target and the bindgen CLI once (`rustup target add wasm32-unknown-unknown`, then `cargo install wasm-bindgen-cli --version` with the exact version pinned in `wasm/Cargo.toml`, because the CLI and the crate must match), then `npm run build` in `wasm/` compiles with the `wasm` profile (`dist` at `opt-level = "z"`, for size) and writes the ignored `wasm/pkg/`: the `.wasm`, the JavaScript glue, and type declarations. That is the `maturin develop` of the JavaScript side. In the browser, the output of `md2mdhtml` goes straight into the DOM, and the browser's own parser does the tree construction that fast5ever does for Python. A binding crate can only reach the library's public surface, so anything a binding needs is exported from `src/lib.rs`. The Python glue needs six items beyond the documented API (`render_inlines`, `plain`, `code_block_open`, `CODE_BLOCK_CLOSE`, `trailing_attr_span`, `highlight_md`), exported by name so the modules that hold them stay private. diff --git a/pyproject.toml b/pyproject.toml index 1eb12be..ae7c14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,3 +53,4 @@ cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.rs" }, { file = "s [tool.fastship] branch = "main" +version-files = ["wasm/package.json"] diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 0000000..54d7297 --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mdhtml-wasm" +version.workspace = true +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +mdhtml-crate = { path = ".." } +wasm-bindgen = "=0.2.128" diff --git a/wasm/package.json b/wasm/package.json new file mode 100644 index 0000000..ab35009 --- /dev/null +++ b/wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "@answerdotai/mdhtml", + "version": "0.1.39", + "description": "mdhtml's Markdown parser for the browser, compiled to WebAssembly", + "license": "Apache-2.0", + "repository": "github:AnswerDotAI/mdhtml", + "private": true, + "type": "module", + "main": "pkg/mdhtml_wasm.js", + "types": "pkg/mdhtml_wasm.d.ts", + "files": ["pkg"], + "scripts": { + "build": "cargo build -p mdhtml-wasm --target wasm32-unknown-unknown --profile wasm && wasm-bindgen --target web --out-dir pkg ../target/wasm32-unknown-unknown/wasm/mdhtml_wasm.wasm" + } +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 0000000..939e986 --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,10 @@ +//! JavaScript bindings for `mdhtml`. Each function here is one the browser +//! may call. `wasm-bindgen` generates the string marshalling around it. + +use mdhtml::{Options, parse, render}; +use wasm_bindgen::prelude::*; + +/// Render Markdown to an MDHTML fragment with the default options. The browser +/// parses the fragment, which is the tree-construction step fast5ever does in Python. +#[wasm_bindgen] +pub fn md2mdhtml(src: &str) -> String { render(&parse(src, &Options::default())) } From 83b5088b334e21e59719bb4f1cc890abfc986ff9 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Fri, 4 Sep 2026 23:37:24 -0400 Subject: [PATCH 3/3] Apply review fixes to the sub-crate layout The clippy allow for too_many_arguments moves to [workspace.lints] so it still covers the Python glue it was written for, and edition, rust-version, license, and repository join the version in [workspace.package] so every crate inherits one source. The stale #[allow(dead_code)] on render_inlines goes. DEV.md's commands run with --workspace, its release note names [workspace.package].version, and the wasm build paragraph is split around its command block. The CI wasm job no longer waits on the Python tests and now runs wasm-bindgen too, so a CLI and crate mismatch fails in CI rather than only locally. The npm ignores gain node_modules/ and wasm/package-lock.json, which would otherwise hold a second version copy. The fastship dev pin rises to 0.1.5, the version that keeps wasm/package.json in step on bump. Claude-Session: https://claude.ai/code/session_01KCKXyYZu5R6fnPbTx3pMmo --- .github/workflows/ci.yml | 5 +++-- .gitignore | 4 ++++ Cargo.toml | 19 +++++++++++++------ DEV.md | 18 ++++++++++++++---- py/Cargo.toml | 8 +++++++- pyproject.toml | 2 +- src/render.rs | 1 - wasm/Cargo.toml | 8 +++++++- wasm/src/lib.rs | 2 ++ 9 files changed, 51 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78e40ad..5ddd776 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,14 +19,15 @@ jobs: - run: pytest -q wasm: - needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown - - run: cargo build -p mdhtml-wasm --target wasm32-unknown-unknown --profile wasm + - run: cargo install wasm-bindgen-cli --version 0.2.128 + - run: npm run build + working-directory: wasm build: needs: test diff --git a/.gitignore b/.gitignore index f51f2bc..b9622b1 100644 --- a/.gitignore +++ b/.gitignore @@ -89,7 +89,11 @@ docs/_build/ # PyBuilder target/ + +# npm +node_modules/ wasm/pkg/ +wasm/package-lock.json # Jupyter Notebook .ipynb_checkpoints diff --git a/Cargo.toml b/Cargo.toml index f7465df..d73eca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,15 +3,22 @@ members = ["py", "wasm"] [workspace.package] version = "0.1.39" +edition = "2024" +rust-version = "1.91" +license = "Apache-2.0" +repository = "https://github.com/AnswerDotAI/mdhtml" + +[workspace.lints.clippy] +too_many_arguments = "allow" [package] name = "mdhtml-crate" version.workspace = true -edition = "2024" -rust-version = "1.91" -license = "Apache-2.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true description = "A bounded-time, Pandoc-leaning Markdown parser with GFM, Extra/kramdown, math, fenced divs, and MDHTML output." -repository = "https://github.com/AnswerDotAI/mdhtml" +repository.workspace = true homepage = "https://github.com/AnswerDotAI/mdhtml" documentation = "https://github.com/AnswerDotAI/mdhtml" keywords = ["markdown", "parser", "html", "commonmark", "gfm"] @@ -54,5 +61,5 @@ fast5ever = { git = "https://github.com/AnswerDotAI/fast5ever", version = "0.1.5 html-escape = ">=0.2" unicode-properties = "0.1" -[lints.clippy] -too_many_arguments = "allow" +[lints] +workspace = true diff --git a/DEV.md b/DEV.md index fc43690..1b67bd2 100644 --- a/DEV.md +++ b/DEV.md @@ -23,8 +23,9 @@ The `release` profile is optimized and incremental for fast local iteration. CI ```bash cargo fmt -cargo check -cargo test +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace pytest -q chkstyle python/mdhtml tests ``` @@ -37,7 +38,16 @@ The repository is a Cargo workspace with one published crate and one binding cra `py/` is `mdhtml-py`, the PyO3 glue that `python/mdhtml/` imports as `mdhtml._native`. maturin builds it through `manifest-path` in `pyproject.toml`. -`wasm/` is `mdhtml-wasm`, the `wasm-bindgen` glue for the browser, and `wasm/package.json` is the npm package `@answerdotai/mdhtml` around it. The package is private until its first publish, and its `version` field is a copy that `ship-bump` keeps in step through `[tool.fastship].version-files`. Building needs the wasm target and the bindgen CLI once (`rustup target add wasm32-unknown-unknown`, then `cargo install wasm-bindgen-cli --version` with the exact version pinned in `wasm/Cargo.toml`, because the CLI and the crate must match), then `npm run build` in `wasm/` compiles with the `wasm` profile (`dist` at `opt-level = "z"`, for size) and writes the ignored `wasm/pkg/`: the `.wasm`, the JavaScript glue, and type declarations. That is the `maturin develop` of the JavaScript side. In the browser, the output of `md2mdhtml` goes straight into the DOM, and the browser's own parser does the tree construction that fast5ever does for Python. +`wasm/` is `mdhtml-wasm`, the `wasm-bindgen` glue for the browser, and `wasm/package.json` is the npm package `@answerdotai/mdhtml` around it. The package is private until its first publish. Its `version` field is a copy that `ship-bump` keeps in step through `[tool.fastship].version-files`. + +Building needs two one-time installs. The CLI version must match the crate version pinned in `wasm/Cargo.toml`. + +```bash +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli --version 0.2.128 +``` + +`npm run build` in `wasm/` compiles with the `wasm` profile (`dist` at `opt-level = "z"`, for size) and runs `wasm-bindgen` into the ignored `wasm/pkg/`: the `.wasm`, the JavaScript glue, and type declarations. That is the `maturin develop` of the JavaScript side. In the browser the output of `md2mdhtml` goes straight into the DOM, and the browser's own parser does the tree construction that fast5ever does for Python. A binding crate can only reach the library's public surface, so anything a binding needs is exported from `src/lib.rs`. The Python glue needs six items beyond the documented API (`render_inlines`, `plain`, `code_block_open`, `CODE_BLOCK_CLOSE`, `trailing_attr_span`, `highlight_md`), exported by name so the modules that hold them stay private. @@ -100,7 +110,7 @@ Release flow is: release first, then bump. pytest -q ``` -2. Confirm the release version in `Cargo.toml` (`[package].version`). `pyproject.toml` gets the Python package version from Cargo via `dynamic = ["version"]`. +2. Confirm the release version in `Cargo.toml` (`[workspace.package].version`; every crate inherits it). `pyproject.toml` gets the Python package version from Cargo via `dynamic = ["version"]`. 3. Release: diff --git a/py/Cargo.toml b/py/Cargo.toml index 6366aff..baa1d8e 100644 --- a/py/Cargo.toml +++ b/py/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "mdhtml-py" version.workspace = true -edition = "2024" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true publish = false +[lints] +workspace = true + [lib] name = "mdhtml_native" crate-type = ["cdylib"] diff --git a/pyproject.toml b/pyproject.toml index ae7c14d..c79a3e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ Issues = "https://github.com/AnswerDotAI/mdhtml/issues" [project.optional-dependencies] fill = ["execnb>=0.3.2"] hl = ["fastpylight>=0.1.18"] -dev = ["fastship>=0.0.14", "maturin~=1.0", "pytest", "execnb>=0.3.2", "fastpylight>=0.1.18", "math-core~=0.7.0"] +dev = ["fastship>=0.1.5", "maturin~=1.0", "pytest", "execnb>=0.3.2", "fastpylight>=0.1.18", "math-core~=0.7.0"] [project.entry-points.fastaudit_safe_native] mdhtml = "mdhtml" diff --git a/src/render.rs b/src/render.rs index 644367b..8448b7f 100644 --- a/src/render.rs +++ b/src/render.rs @@ -10,7 +10,6 @@ pub(crate) fn render_document(doc: &Document) -> String { out } -#[allow(dead_code)] pub fn render_inlines(items: &[Inline]) -> String { let doc = Document::default(); let mut r = Renderer::new(&doc); diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 54d7297..3fb48bd 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "mdhtml-wasm" version.workspace = true -edition = "2024" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true publish = false +[lints] +workspace = true + [lib] crate-type = ["cdylib"] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 939e986..0b3be5d 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -6,5 +6,7 @@ use wasm_bindgen::prelude::*; /// Render Markdown to an MDHTML fragment with the default options. The browser /// parses the fragment, which is the tree-construction step fast5ever does in Python. +/// Python's `md2mdhtml` also returns warnings and frontmatter meta. This entry +/// returns the fragment alone until the JavaScript API needs them. #[wasm_bindgen] pub fn md2mdhtml(src: &str) -> String { render(&parse(src, &Options::default())) }