diff --git a/packages/preview/xmlit/0.1.3/LICENSE b/packages/preview/xmlit/0.1.3/LICENSE new file mode 100644 index 0000000000..a7179dc406 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jason Siefken + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/xmlit/0.1.3/README.md b/packages/preview/xmlit/0.1.3/README.md new file mode 100644 index 0000000000..535fcbc506 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/README.md @@ -0,0 +1,270 @@ +# typst-xmlit + +Tools for generating, validating, and outputting XML using Typst syntax. + +
+
+
A
Area:
` full of prose (or
+ even a whole document with no element-only nesting) can pretty-print to
+ one very long line; the character-level window is what keeps the snippet
+ short regardless.
+
+ Use `.with(pretty-print: true)` in a show rule:
+ `#show: utils.validate-and-render.with(pretty-print: true)`.
+
+ - `render-and-show-validation-errors(body, pretty-print: true) -> content` —
+ a template (`#show: utils.render-and-show-validation-errors`) for
+ authoring/preview: like `validate-and-render`, but instead of panicking on
+ an invalid document it renders the XML source anyway, highlighting each
+ offending element's line in place with its error message. Errors that
+ can't be tied to a specific element are listed below the block. Useful
+ while iterating on a document you know isn't finished yet.
+ - `validate(doc) -> (valid: bool, errors: array)` — validate content or an
+ XML string without panicking. On failure, each entry in `errors` also
+ carries a `snippet` — the same windowed source excerpt used in
+ `validate-and-render`'s panic message (`none` only for errors with no
+ locatable position, e.g. an internal buffer-limit error). For a raw `doc`
+ string the snippet windows directly around the error's position in that
+ string (no round-trip through the grammar needed), and the plugin's raw
+ `line`/`column` fields are kept, since they index the exact string
+ written. For authored content the snippet is mapped through the element
+ that produced it, and `line`/`column` are *removed* — those would be
+ positions in an invisible, internally-generated XML string, not anything
+ actually written, so they'd only mislead.
+ - `roots` — the element names allowed as the document root. (All element
+ names are `elements.keys()`.)
+
+A `handlers:` argument passed to `create-from-relaxng` is forwarded to every
+generated tag function, so one handler table configures markup/math
+conversion for the whole grammar (see [Markup in bodies](#markup-in-bodies)).
+
+## Serializing
+
+### `xml-to-string(node, handlers: auto, extract-math: false, pretty-print: false) -> str | dictionary`
+
+`xml-to-string` accepts authored trees (the return value of a tag function or
+any markup content), plain node dictionaries/strings/arrays, and — faithfully —
+the output of Typst's built-in `xml()` reader. Parameters:
+
+- `node` — an authored tree, a node dict/str/array, or `xml()` reader output.
+- `handlers` — overrides for content conversion (see [Markup in bodies](#markup-in-bodies)).
+- `extract-math` — return a `(xml, math-items)` dictionary with equations pulled out (see below).
+- `pretty-print` — indent element-only content (see below).
+
+```typst
+#xml-to-string(xml("doc.xml"))
+```
+
+Attribute order is preserved, text/attribute contexts are escaped correctly,
+empty elements self-close, and default-namespace declarations are re-emitted
+only where the namespace actually changes.
+
+### Pretty printing
+
+Pass `pretty-print: true` to indent the output. Only elements whose children
+are *all elements* are reflowed — one child per line; elements containing any
+text (mixed content) stay inline, so no significant whitespace is introduced:
+
+```typst
+#xml-to-string(root(a(b()), b(id: "2")), pretty-print: true)
+//
Some bold text
(mixed content — left inline) +``` + +Pretty-printed output is meant for reading; it is not byte-faithful to +`xml()` reader input. + diff --git a/packages/preview/xmlit/0.1.3/src/elem/elem.typ b/packages/preview/xmlit/0.1.3/src/elem/elem.typ new file mode 100644 index 0000000000..b4495545bb --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/elem/elem.typ @@ -0,0 +1,291 @@ +// elem: author XML trees with Typst syntax. +// +// Tag functions created by `make-tag` return their node wrapped in +// `metadata(...)` content. Because content joins cleanly with content and +// strings, all three call styles work and can be mixed: +// +// #let (foo, bar) = make-tags("foo", "bar") +// #foo(bar(baz: "zz"), "text") // positional +// #foo({ bar(baz: "zz"); "text" }) // code block (content joins) +// #foo[#bar(baz: "zz") text $x^2$] // markup block +// +// The node model is the same one Typst's `xml()` reader uses: an element is a +// dictionary with `tag`, `attrs`, and `children`; a text node is a string. + +// Math constructs whose AST cannot be reconstructed node-by-node, matched +// wholesale by comparing against the parsed form of their source. `dif`, for +// example, expands to `sequence(h(..), class(.., styled(..)))`, none of which +// can be re-created by evaluating serialized output -- but the whole node +// compares equal to `$dif$.body`, so it serializes back to "dif". +#let _special-math-forms = ( + ("dif", repr($dif$.body)), +) + +/// Serialize equation (body) content to a Typst math source string: +/// $x^2$ -> "x^2", $1/2$ -> "1/2", $sqrt(x+1)$ -> "sqrt(x+1)". Symbols +/// appear as their Unicode characters (e.g. "∫" for `integral`). +/// +/// For supported constructs the output round-trips: evaluating it as math +/// (e.g. `eval("$" + s + "$")`) reproduces exactly the original expression +/// (the property is enforced by the unit tests). Constructs without a +/// faithful serialization (matrices, cases, alignment points, ...) fall back +/// to their `repr`, which is visible in the output but is NOT valid math +/// source -- override the `"math"` handler to serialize those yourself. +#let math-to-string(c) = { + if type(c) == str { return c } + if type(c) != content { return repr(c) } + for (source, form) in _special-math-forms { + if repr(c) == form { return source } + } + let join-all(parts) = { + let s = parts.join("") + if s == none { "" } else { s } + } + // Parenthesize a sub-expression unless it is atomic: a single grapheme + // (letters and symbols), a number (single tokens in Typst math), a quoted + // string, or already parenthesized. + let group(s) = { + if s.clusters().len() <= 1 { return s } + if s.match(regex("^[0-9]+(\\.[0-9]+)?$")) != none { return s } + if s.starts-with("\"") and s.ends-with("\"") { return s } + if s.starts-with("(") and s.ends-with(")") { return s } + "(" + s + ")" + } + let name = repr(c.func()) + // Letters and symbols (x, π, ∫) are `symbol` elements: single characters + // that re-evaluate to themselves. + if name == "symbol" { + c.text + } else if name == "text" { + // Digit runs / decimals and single characters (operators like "+", ",") + // re-evaluate to the same `text` node; longer runs would re-parse as + // per-letter symbols, so they are emitted as quoted strings. + let t = c.text + if t.clusters().len() <= 1 or t.match(regex("^[0-9]+(\\.[0-9]+)?$")) != none { + t + } else { + "\"" + t.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + } + } else if name == "space" { + " " + } else if name == "sequence" { + join-all(c.children.map(math-to-string)) + } else if name == "equation" { + math-to-string(c.fields().body) + } else if name == "lr" { + // Parentheses re-create the lr group when re-parsed; other delimiter + // pairs (|x|, [x], ...) need the explicit lr(...) form. + let inner = math-to-string(c.fields().body) + if inner.starts-with("(") and inner.ends-with(")") { + inner + } else { + "lr(" + inner + ")" + } + } else if name == "op" { + // Named operators (lim, sin, max, ...) re-parse to the identical op node. + c.fields().text.text + } else if name == "primes" { + join-all(range(c.fields().count).map(_ => "'")) + } else if name == "frac" { + let f = c.fields() + group(math-to-string(f.num)) + "/" + group(math-to-string(f.denom)) + } else if name == "root" { + let f = c.fields() + let index = f.at("index", default: none) + if index == none { + "sqrt(" + math-to-string(f.radicand) + ")" + } else { + "root(" + math-to-string(index) + ", " + math-to-string(f.radicand) + ")" + } + } else if name == "attach" { + let f = c.fields() + let s = math-to-string(f.base) + // Primes attach top-right without an operator: $x'$ -> "x'". + let tr = f.at("tr", default: none) + if tr != none { s += math-to-string(tr) } + let b = f.at("b", default: none) + if b != none { s += "_" + group(math-to-string(b)) } + let t = f.at("t", default: none) + if t != none { s += "^" + group(math-to-string(t)) } + s + } else { + // No faithful serialization known: degrade to repr (visible, but not + // valid math source). Override the "math" handler for full control. + repr(c) + } +} + +/// Built-in handlers that map Typst content elements to XML nodes. +/// +/// Keys are content-function names (as produced by `repr(c.func())`), plus the +/// special `"math"` slot used to serialize equation bodies. Each handler is +/// called as `handler(element, convert, ctx)` where: +/// +/// - `convert`: function converting any child value to an array of nodes +/// (using the same merged handler table); use it to process bodies: +/// "strong": (c, convert, ctx) => ((tag: "b", attrs: (:), children: convert(c.body)),) +/// - `ctx`: a dictionary for handlers that delegate to another slot, with: +/// - `handlers`: the merged handler table (built-ins + user overrides); +/// see the built-in `equation` handler, which dispatches to `"math"` +/// +/// and must return either an array of nodes (element dictionaries and/or +/// strings) or a single such node (a bare element dictionary or string), which +/// is normalized to a one-element array. Override or extend any of these by +/// passing `handlers: (...)` to `make-tag` or `make-tags`. +#let default-handlers = ( + "sequence": (c, convert, ctx) => c.children.map(convert).flatten(), + "text": (c, convert, ctx) => (c.text,), + "space": (c, convert, ctx) => (" ",), + "linebreak": (c, convert, ctx) => ("\n",), + "parbreak": (c, convert, ctx) => ("\n\n",), + "smartquote": (c, convert, ctx) => { + let double = c.fields().at("double", default: true) + (if double { "\"" } else { "'" },) + }, + // A show/set rule inside a body wraps the rest in `styled`; recurse into the + // wrapped child (the style information itself has no XML representation). + "styled": (c, convert, ctx) => convert(c.fields().child), + // Nodes authored by xmlit tag functions travel through markup as metadata. + "metadata": (c, convert, ctx) => { + let v = c.value + if type(v) == dictionary and "tag" in v { + (v,) + } else { + panic("xmlit: metadata content does not carry an XML node: " + repr(v)) + } + }, + // Default markup mappings (in the spirit of Typst's HTML export). Override + // via `handlers:` to emit different tags. + "strong": (c, convert, ctx) => ((tag: "b", attrs: (:), children: convert(c.body)),), + "emph": (c, convert, ctx) => ((tag: "em", attrs: (:), children: convert(c.body)),), + "raw": (c, convert, ctx) => { + let f = c.fields() + // A plain code value (e.g. a bare dictionary) interpolated into markup + // displays as inline raw with lang "typc" -- almost certainly a mistake. + if f.at("lang", default: none) == "typc" { + panic( + "xmlit: a plain code value was interpolated into markup: `" + f.text + + "`. Tag functions already return content; did you interpolate a " + + "bare dictionary?", + ) + } + let tag = if f.at("block", default: false) { "pre" } else { "c" } + ((tag: tag, attrs: (:), children: (f.text,)),) + }, + // $...$ ->full of prose -- +// would otherwise reproduce, carets and all, the very wall of text this +// machinery exists to avoid. +#let _target-line-snippet(text, start-idx, end-idx) = { + let cls = text.clusters() + let needs-window = cls.len() > _snippet-max-line-chars + let lo = if needs-window { calc.max(0, start-idx - _snippet-window-chars) } else { 0 } + let hi = if needs-window { calc.min(cls.len(), end-idx + _snippet-window-chars) } else { cls.len() } + let before = (if needs-window and lo > 0 { "…" } else { "" }) + _joined(cls.slice(lo, start-idx)) + let marked = _joined(cls.slice(start-idx, end-idx)) + let mcls = marked.clusters() + if mcls.len() > _snippet-max-marked-chars { + let half = calc.quo(_snippet-max-marked-chars, 2) + marked = _joined(mcls.slice(0, half)) + "…" + _joined(mcls.slice(mcls.len() - half)) + } + let after = _joined(cls.slice(end-idx, hi)) + (if needs-window and hi < cls.len() { "…" } else { "" }) + ( + text: before + marked + after, + pad: " " * before.clusters().len(), + carets: "^" * calc.max(marked.clusters().len(), 1), + ) +} + +/// Build a small line-numbered, character-windowed excerpt of `text` around +/// the byte range `[start, end)` (see `format-errors` for the two-level, +/// bounded-size windowing rationale), with the target line's span +/// underlined. `start`/`end` must be valid byte positions within `text`. +/// +/// This is the shared core `error-snippet` builds on top of (after mapping +/// a validator offset to an authored element's position in the pretty +/// string). It needs no element-path structure itself, so it also works +/// directly on a raw XML string passed to `validate` -- there `start`/`end` +/// are already byte offsets into that exact string (it IS both what was +/// validated and what to excerpt), no path-mapping step required. +#let snippet-at(text, start, end) = { + let lines = text.split("\n") + let target = line-of(text, start) + let col = text.slice(0, start).split("\n").last().len() + let target-text = lines.at(target - 1) + let span = calc.min(end - start, target-text.len() - col) + + let tl = _target-line-snippet(target-text, _char-index-of(target-text, col), _char-index-of(target-text, col + span)) + + let lo = calc.max(1, target - _snippet-context-lines) + let hi = calc.min(lines.len(), target + _snippet-context-lines) + let width = str(hi).len() + let pad-num(n) = " " * (width - str(n).len()) + str(n) + + range(lo, hi + 1).map(n => { + let gutter = if n == target { "> " } else { " " } + let prefix = gutter + pad-num(n) + " | " + if n == target { + let caret-line = " " * (width + 5) + tl.pad + tl.carets + prefix + tl.text + "\n" + caret-line + } else { + prefix + _capped-line(lines.at(n - 1)) + } + }).join("\n") +} + +/// Locate a single error's byte offset -> deepest element -> that element's +/// line in the pretty-printed source, and render a small line-numbered +/// window around it via `snippet-at`. Returns `none` if the error has no +/// position or can't be mapped to an element (e.g. `TextBufferOverflow`, or +/// `doc` was a raw XML string rather than authored content, which carries +/// no element path structure -- use `snippet-at` on that string directly +/// instead, no mapping needed). +/// +/// `compact-ranges`/`pretty` are as in `format-errors`. `e` is a single +/// entry from a `(valid: false, errors: (..))` validation result. +#let error-snippet(compact-ranges, pretty, e) = { + if e.at("start", default: none) == none { return none } + let path = locate-path(compact-ranges, e.start) + if path == none { return none } + let drange = pretty.ranges.find(r => r.path == path) + if drange == none { return none } + snippet-at(pretty.xml, drange.start, drange.end) +} + +/// Format the errors of a `(valid: false, errors: (..))` validation result +/// into a readable multi-line string, with a small windowed snippet of the +/// pretty-printed source around each error's location instead of dumping the +/// whole document. `compact-ranges` is the `ranges` from +/// `xml-to-string-with-ranges` of the COMPACT serialization (the one +/// actually validated; used to locate each error's byte offset via +/// `locate-path`). `pretty` is `(xml: str, ranges: array)` from +/// `xml-to-string-with-ranges(..., pretty-print: true)` of the SAME tree +/// (used to build the displayed snippet). +/// +/// Pretty-printing only breaks lines between all-element children (see +/// `xml-to-string`'s `pretty-print`); mixed content -- e.g. a `
` full of
+/// prose -- stays on one line however long, and in the extreme a whole
+/// document can pretty-print to a single line with no newlines at all. So
+/// the target line itself is ALSO windowed to a bounded number of characters
+/// around the error (not just "N lines of context") -- that inner windowing
+/// is what actually guarantees a bounded snippet regardless of document
+/// shape.
+#let format-errors(result, compact-ranges, pretty) = {
+ // No "(line, column)" suffix here: this is always called on authored
+ // content (from `validate-and-render`), where those positions are offsets
+ // into an invisible, internally-generated compact string the user never
+ // sees or writes -- meaningless without the snippet, which already shows
+ // the actual location relative to the readable pretty-printed source.
+ let entries = result.errors.map(e => {
+ let snippet = error-snippet(compact-ranges, pretty, e)
+ "- " + e.message + (if snippet == none { "" } else { "\n" + snippet })
+ })
+
+ let shown = entries.slice(0, calc.min(entries.len(), _snippet-max-errors))
+ let rest = entries.len() - shown.len()
+ shown.join("\n\n") + (if rest > 0 { "\n\n...and " + str(rest) + " more error(s)" } else { "" })
+}
+
+/// Create XML-authoring functions from a RELAX NG grammar (compact syntax).
+///
+/// Returns `(elements: (..), utils: (..))` -- destructure it directly:
+///
+/// #let (utils, elements) = create-from-relaxng(rnc)
+///
+/// - `elements` maps each element name defined in the grammar to its tag
+/// function; destructure what you need: `#let (foo, bar) = elements`
+/// - `utils` holds the grammar-level helpers `render` (a `#show:` template
+/// that serializes its body and renders the XML source WITHOUT validating
+/// -- for fast iteration; accepts `pretty-print`), `validate-and-render`
+/// (a `#show:` template that validates its body and renders the XML
+/// source, panicking with readable errors if invalid; also accepts
+/// `pretty-print` -- `#show: utils.validate-and-render.with(pretty-print:
+/// true)`), `render-and-show-validation-errors` (a `#show:` template that,
+/// instead of panicking, renders the XML source and highlights the offending
+/// element(s) with their messages in place -- for authoring/preview; also
+/// accepts `pretty-print`, default true), `validate` (non-panicking; returns
+/// `(valid: bool, errors: (..))`), and `roots` (the element names allowed as
+/// the document root).
+///
+/// - `rnc`: the grammar source (str or bytes), or -- for grammars split
+/// across several files with `include`/`external` -- a dictionary mapping
+/// file names to file contents. The FIRST entry is the entry point:
+/// create-from-relaxng((
+/// "pretext.rnc": read("pretext.rnc"),
+/// "pf-adapter.rnc": read("pf-adapter.rnc"),
+/// ...
+/// ))
+/// - `handlers`: forwarded to `make-tag` -- overrides for how Typst content
+/// (markup, math, ...) is converted to XML.
+/// - `wasm`: the validator plugin; defaults to the bundled one. Pass a
+/// `plugin(...)` module or raw wasm bytes to substitute your own build.
+#let create-from-relaxng(rnc, handlers: auto, wasm: auto) = {
+ let p = if wasm == auto {
+ relaxng-plugin
+ } else if type(wasm) == bytes {
+ plugin(wasm)
+ } else {
+ wasm
+ }
+ // Multi-file grammars are sent as a JSON VFS object (the plugin detects
+ // the leading "{"); single grammars are sent verbatim.
+ let rnc-bytes = if type(rnc) == dictionary {
+ bytes(json.encode(rnc))
+ } else {
+ bytes(rnc)
+ }
+ let info = json(p.list_elements(rnc-bytes))
+
+ // Validate content (authored with the tag functions) or a raw XML string.
+ // On failure, each entry in `errors` additionally carries a `snippet`: the
+ // same located, windowed source excerpt used in `validate-and-render`'s
+ // panic message -- for authored content via `error-snippet`, or directly
+ // via `snippet-at` for a raw string (its own text is both what was
+ // validated and what to excerpt, so no element-path mapping is needed).
+ // `none` only if the error itself has no position (e.g.
+ // `TextBufferOverflow`). Ranges/snippets are only computed on this (rare)
+ // failure path, so the common valid-input case stays as cheap as before.
+ let validate = doc => {
+ let is-str = type(doc) == str
+ let xml-str = if is-str { doc } else { xml-to-string(doc, handlers: handlers) }
+ let result = json(p.validate(rnc-bytes, bytes(xml-str)))
+ if result.valid {
+ return result
+ }
+ if is-str {
+ // The exact validated string IS the source to excerpt -- window
+ // snippets directly around each error's byte offset in it, no
+ // element-path mapping needed (that machinery only exists to bridge
+ // between separately-serialized compact/pretty representations of
+ // AUTHORED content; a raw string has just the one representation).
+ return (
+ valid: false,
+ errors: result.errors.map(e => {
+ let snippet = if e.at("start", default: none) == none {
+ none
+ } else {
+ snippet-at(xml-str, e.start, e.at("end", default: e.start))
+ }
+ e + (snippet: snippet)
+ }),
+ )
+ }
+ let compact = xml-to-string-with-ranges(doc, handlers: handlers)
+ let pretty = xml-to-string-with-ranges(doc, handlers: handlers, pretty-print: true)
+ (
+ valid: false,
+ errors: result.errors.map(e => {
+ // line/column are positions in the invisible, internally-generated
+ // compact string -- meaningless for authored content (the user
+ // never wrote that string), so drop them; `snippet` already shows
+ // the real location relative to the readable pretty-printed source.
+ let with-snippet = e + (snippet: error-snippet(compact.ranges, pretty, e))
+ let _ = with-snippet.remove("line", default: none)
+ let _ = with-snippet.remove("column", default: none)
+ with-snippet
+ }),
+ )
+ }
+
+ // Non-validating template: serialize the body and render the XML source,
+ // without calling the validator at all. Use as `#show: utils.render`, or
+ // with options via `#show: utils.render.with(pretty-print: true)`. Useful
+ // while iterating, when paying for a validation round-trip on every render
+ // isn't wanted; switch to `validate-and-render` once the document is ready
+ // to be checked.
+ let render = (body, pretty-print: false) => {
+ raw(xml-to-string(body, handlers: handlers, pretty-print: pretty-print), lang: "xml", block: true)
+ }
+
+ // Validating template: serialize the body, panic on validation errors,
+ // render the XML source on success. Use as
+ // `#show: utils.validate-and-render`, or with options via
+ // `#show: utils.validate-and-render.with(pretty-print: true)`.
+ //
+ // Validation always uses the compact serialization (so cosmetic
+ // pretty-print whitespace can never affect the result); only the rendered
+ // output is indented when `pretty-print` is true. The panic message uses a
+ // windowed snippet of the source around each error's location, not the
+ // whole document (see `format-errors`) -- ranges are only computed on this
+ // (rare) failure path, so the common valid-input case stays cheap.
+ let validate-and-render = (body, pretty-print: false) => {
+ let xml-str = xml-to-string(body, handlers: handlers)
+ let result = json(p.validate(rnc-bytes, bytes(xml-str)))
+ if not result.valid {
+ let compact = xml-to-string-with-ranges(body, handlers: handlers)
+ let pretty = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: true)
+ panic(
+ "XML failed RELAX NG validation:\n" + format-errors(result, compact.ranges, pretty),
+ )
+ }
+ let display = if pretty-print {
+ xml-to-string(body, handlers: handlers, pretty-print: true)
+ } else {
+ xml-str
+ }
+ raw(display, lang: "xml", block: true)
+ }
+
+ // Non-panicking, error-locating template: serialize and render the body's
+ // XML source, but when validation fails, highlight the offending element(s)
+ // in place and show each message next to the element it belongs to (instead
+ // of aborting the compile like `validate-and-render`). Use as
+ // `#show: utils.render-and-show-validation-errors`, or with options via
+ // `.with(pretty-print: false)`.
+ //
+ // Validation still runs on the compact serialization (so cosmetic
+ // pretty-print whitespace can never affect the result); the plugin's
+ // byte-offset error positions are mapped to the specific element via
+ // `xml-to-string-with-ranges`, then to that element's line in the rendered
+ // (pretty by default) output. Ranges are only computed on the failure
+ // path -- the cheap, ranges-free `xml-to-string` handles the common
+ // valid-input case (mirrors `validate-and-render` and the plugin's own
+ // fast-validate/slow-diagnostic split).
+ let render-and-show-validation-errors = (body, pretty-print: true) => {
+ let xml-str = xml-to-string(body, handlers: handlers)
+ let result = json(p.validate(rnc-bytes, bytes(xml-str)))
+
+ if result.valid {
+ let display-str = if pretty-print {
+ xml-to-string(body, handlers: handlers, pretty-print: true)
+ } else {
+ xml-str
+ }
+ return raw(display-str, lang: "xml", block: true)
+ }
+
+ let compact = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: false)
+ let display = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: pretty-print)
+
+ // Map each error -> deepest offending element -> that element's line in the
+ // displayed source. Errors that can't be tied to an element are listed
+ // separately below the block.
+ let messages-by-line = (:)
+ let spans-by-line = (:)
+ let unlocated = ()
+ for e in result.errors {
+ // `start` is none (JSON null) for positionless errors (e.g. the
+ // plugin's internal buffer/pattern limits) -- those can't be tied to
+ // an element, so they fall through to the `unlocated` list.
+ let start = e.at("start", default: none)
+ let path = if start == none { none } else { locate-path(compact.ranges, start) }
+ let drange = if path == none { none } else { display.ranges.find(r => r.path == path) }
+ if drange == none {
+ unlocated.push(e.message)
+ } else {
+ let key = str(line-of(display.xml, drange.start))
+ messages-by-line.insert(key, messages-by-line.at(key, default: ()) + (e.message,))
+ // Byte column of the element's start within its line, plus the
+ // element's full byte length (clipped to the line by `_underline-spans`).
+ let col = display.xml.slice(0, drange.start).split("\n").last().len()
+ spans-by-line.insert(key, spans-by-line.at(key, default: ()) + ((col: col, span: drange.end - drange.start),))
+ }
+ }
+
+ // Render the source as a single-column grid, one row per line. A grid
+ // (rather than a `raw` block + `show raw.line`) is used because the
+ // squiggle underlines are themselves `raw`, which would recurse through a
+ // `raw.line` show rule. `row-gutter: 0` + a per-row `inset` make the red
+ // fill of consecutive error lines touch (contiguous) while still giving
+ // every line vertical breathing room.
+ let arrow = place(top + left, dx: -1em, text(fill: rgb("#cc0000"), size: 0.85em)[←])
+ {
+ grid(
+ columns: (1fr,),
+ row-gutter: 0pt,
+ inset: (x: 4pt, y: 3pt),
+ fill: (_, row) => if str(row + 1) in messages-by-line { rgb("#ffe3e3") } else { none },
+ ..display.xml.split("\n").enumerate().map(((i, line-text)) => {
+ let key = str(i + 1)
+ let msgs = messages-by-line.at(key, default: ())
+ if msgs.len() > 0 {
+ // The message lives in a reserved right column so a wrapping
+ // message stays within its own block (never wrapping back under
+ // the source); the `←` is `place`d into the gutter so it hangs to
+ // the left of the message text instead of being part of the flow.
+ grid(
+ columns: (1fr, 40%),
+ column-gutter: 1.4em,
+ // Source line with the offending span(s) squiggle-underlined.
+ _underline-spans(line-text, spans-by-line.at(key, default: ())),
+ {
+ arrow
+ text(fill: rgb("#cc0000"), size: 0.85em, style: "italic")[#msgs.join("; ")]
+ },
+ )
+ } else {
+ raw(line-text, lang: "xml")
+ }
+ }),
+ )
+ for m in unlocated {
+ text(fill: rgb("#cc0000"), size: 0.85em)[⚠ #m]
+ linebreak()
+ }
+ }
+ }
+
+ let tags = (:)
+ for name in info.elements {
+ tags.insert(name, make-tag(name, handlers: handlers))
+ }
+
+ (
+ elements: tags,
+ utils: (
+ render: render,
+ validate-and-render: validate-and-render,
+ render-and-show-validation-errors: render-and-show-validation-errors,
+ validate: validate,
+ roots: info.roots,
+ ),
+ )
+}
diff --git a/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm
new file mode 100755
index 0000000000..a2e7e29c80
Binary files /dev/null and b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm differ
diff --git a/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ b/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ
new file mode 100644
index 0000000000..1ce2f7ec0b
--- /dev/null
+++ b/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ
@@ -0,0 +1,38 @@
+// to-xml: serialize plain node dictionaries (no namespace handling). The
+// element-authoring API (`make-tag`, `elem`, ...) lives in ../elem/elem.typ.
+
+/// Escape special XML characters in a string value.
+#let xml-escape(s) = {
+ s.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'")
+}
+
+/// Serialize a single XML node (dictionary with `tag`, `attrs`, `children`)
+/// or a plain string to an XML string.
+#let to-xml(node) = {
+ if type(node) == str {
+ return xml-escape(node)
+ }
+
+ let tag = node.tag
+ let attrs = node.at("attrs", default: (:))
+ let children = node.at("children", default: ())
+
+ // Build attribute string
+ let attrs-str = attrs.pairs().map(((k, v)) => {
+ let vs = if type(v) == str { v } else if type(v) == bool { repr(v) } else { str(v) }
+ " " + k + "=\"" + xml-escape(vs) + "\""
+ }).join("")
+
+ // Serialize children
+ let inner = children.map(to-xml).join("")
+
+ if inner == "" {
+ "<" + tag + attrs-str + " />"
+ } else {
+ "<" + tag + attrs-str + ">" + inner + "" + tag + ">"
+ }
+}
diff --git a/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ b/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ
new file mode 100644
index 0000000000..36da05eebe
--- /dev/null
+++ b/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ
@@ -0,0 +1,236 @@
+// xml-to-string: serialize XML node trees to an XML string -- both trees
+// authored with xmlit tag functions and the output of Typst's built-in
+// `xml()` reader (faithfully reversed).
+
+#import "../elem/elem.typ": convert
+
+/// Escape a string for use as XML text content (`&`, `<`, `>`).
+/// `&` must be replaced first so already-escaped entities are not double-escaped.
+#let esc-text(s) = {
+ s.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+}
+
+/// Escape a string for use inside a double-quoted attribute value
+/// (`&`, `<`, `"`).
+#let esc-attr(s) = {
+ s.replace("&", "&")
+ .replace("\"", """)
+ .replace("<", "<")
+}
+
+// One level of pretty-print indentation.
+#let _indent-unit = " "
+
+// Core serializer. Returns `(text: str, math: dict, ranges: array)`. When
+// `extract` is true, a node carrying the reserved `math` key (an equation
+// element, as produced by the built-in `equation` handler) is emitted with a
+// text sentinel in place of its serialized children, and the equation content
+// is collected into `math` under a sequential document-order id.
+//
+// When `pretty` is true, an element whose children are all elements (no text
+// nodes) has each child placed on its own line, indented by `depth` levels.
+// Elements with any text child (mixed content) stay inline, so significant
+// whitespace is never introduced.
+//
+// When `record` is true, `ranges` collects one `(path, start, end)` entry per
+// element node, where `path` is the sequence of child indices from the root
+// and `start`/`end` are byte offsets of the element's serialized text within
+// the returned `text` (byte offsets so they line up with the plugin's error
+// positions and Typst's byte-indexed `str.len`/`str.slice`). Otherwise
+// `ranges` is empty and the bookkeeping is skipped.
+#let _serialize(node, inherited-ns, handlers, extract, pretty, depth, math, path, record) = {
+ // Authored content (metadata-wrapped nodes, markup, ...): normalize first.
+ if type(node) == content {
+ node = convert(node, handlers: handlers)
+ }
+
+ // Array of nodes (e.g. the direct return of `xml(...)`).
+ if type(node) == array {
+ let text = ""
+ let ranges = ()
+ let off = 0
+ let i = 0
+ for n in node {
+ let r = _serialize(n, inherited-ns, handlers, extract, pretty, depth, math, path + (i,), record)
+ if record {
+ for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) }
+ }
+ text += r.text
+ off += r.text.len()
+ math = r.math
+ i += 1
+ }
+ return (text: text, math: math, ranges: ranges)
+ }
+
+ if type(node) == str {
+ return (text: esc-text(node), math: math, ranges: ())
+ }
+
+ let tag = node.at("tag", default: "")
+ // Comment / processing-instruction sentinel: content is lost, so skip it.
+ if tag == "" {
+ return (text: "", math: math, ranges: ())
+ }
+
+ let ns = node.at("namespace", default: none)
+ let attrs = node.at("attrs", default: (:))
+ let children = node.at("children", default: ())
+
+ let attrs-str = attrs.pairs().map(((k, v)) => {
+ let vs = if type(v) == str { v } else if type(v) == bool { repr(v) } else { str(v) }
+ " " + k + "=\"" + esc-attr(vs) + "\""
+ }).join("")
+ if attrs-str == none { attrs-str = "" }
+
+ // Declare a default namespace only when it changes relative to the parent.
+ let ns-str = if ns != inherited-ns {
+ " xmlns=\"" + esc-attr(if ns == none { "" } else { ns }) + "\""
+ } else {
+ ""
+ }
+
+ let open = "<" + tag + ns-str + attrs-str
+
+ // Range covering this element's whole serialized text (added once the text
+ // length is known); descendants' ranges are shifted into this frame.
+ let self-range(text, ranges) = if record {
+ (((path: path, start: 0, end: text.len()),) + ranges)
+ } else { () }
+
+ // Math extraction: replace the (string-serialized) children with a
+ // sentinel and collect the actual equation content under a fresh id.
+ if extract and "math" in node {
+ let id = "math-" + str(math.len())
+ math.insert(id, node.math)
+ let text = open + ">⟦" + id + "⟧" + tag + ">"
+ return (text: text, math: math, ranges: self-range(text, ()))
+ }
+
+ if children.len() == 0 {
+ let text = open + " />"
+ return (text: text, math: math, ranges: self-range(text, ()))
+ }
+
+ // Pretty-print only when every child is an element node: reformatting
+ // mixed content (any text child) would inject significant whitespace.
+ let element-only = pretty and children.all(c =>
+ type(c) == dictionary and c.at("tag", default: "") != "")
+
+ let opengt = open + ">"
+ let inner = ""
+ let ranges = ()
+ let off = opengt.len()
+ let i = 0
+ if element-only {
+ let child-indent = _indent-unit * (depth + 1)
+ for c in children {
+ let r = _serialize(c, ns, handlers, extract, pretty, depth + 1, math, path + (i,), record)
+ let prefix = "\n" + child-indent
+ off += prefix.len()
+ if record {
+ for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) }
+ }
+ inner += prefix + r.text
+ off += r.text.len()
+ math = r.math
+ i += 1
+ }
+ let text = opengt + inner + "\n" + _indent-unit * depth + "" + tag + ">"
+ (text: text, math: math, ranges: self-range(text, ranges))
+ } else {
+ for c in children {
+ let r = _serialize(c, ns, handlers, extract, pretty, depth, math, path + (i,), record)
+ if record {
+ for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) }
+ }
+ inner += r.text
+ off += r.text.len()
+ math = r.math
+ i += 1
+ }
+ let text = opengt + inner + "" + tag + ">"
+ (text: text, math: math, ranges: self-range(text, ranges))
+ }
+}
+
+/// Serialize XML nodes to an XML string; also faithfully reverses the output
+/// of Typst's built-in `xml()` reader.
+///
+/// For `xml()` reader output:
+/// - Text nodes are re-escaped for text context.
+/// - Attribute values are re-escaped for attribute context; attribute order is
+/// preserved.
+/// - Namespaces are emitted as default `xmlns="..."` declarations, but only
+/// when an element's resolved namespace URI differs from the one it inherits
+/// from its parent (mirroring how `xml()` repeats the URI on every
+/// descendant). Original prefixes and attribute namespaces are not
+/// recoverable from `xml()` output and are not reconstructed.
+/// - Elements with no children are written self-closing (`