Plugin & extension design¶
This port keeps its core a faithful mirror of upstream rich. Everything we add
lives on the outside of that core, through a small set of extension points.
This document describes how that works today (internal-facing) and the intended
path to opening it up to third-party plugins.
Why¶
If our own features were woven into crates/rich, every upstream sync would be a
manual three-way merge. Instead:
crates/richdefines extension-point traits and calls them, but ships only upstream's built-in implementations. It has no knowledge ofrich-ext.crates/rich-extprovides extra implementations and registers them onto aConsole. Syncing upstream never touchesrich-ext.
Extension points (today)¶
Defined in crates/rich/src/protocol.rs:
| trait | upstream analogue | purpose |
|---|---|---|
Renderable |
__rich_console__ protocol |
make a custom type printable by Console |
LineRenderable |
incremental consumption of rendering generators | stream styled lines without collecting the full rendered output; implemented by Table |
Highlighter |
Highlighter ABC |
add style spans to Text (numbers, URLs, syntax, โฆ) |
FenceRenderer |
none (upstream always highlights a fence) | draw ```lang fences in Markdown instead of highlighting them, via Markdown::fence_renderer; with none added, Markdown is unchanged (0.0.12) |
CodeHighlighter |
Pygments behind Syntax |
the syntax-highlighting engine for Syntax and Markdown code; SyntectHighlighter is the default, and Syntax::highlighter / Markdown::highlighter take any other (0.0.12) |
More seams (custom Box sets, spinners, themes) are added here as the
corresponding modules are ported โ always as a trait the core calls, never as an
if cfg!(feature = "ours") branch inside core logic.
Registration (explicit by default)¶
Registration is explicit by default: a host names each plugin it adds, which is easy to debug, reason about and test, and keeps the install order deterministic. Linked and runtime plugins (below) are opt-in on top of that.
use rich::{Console, ColorSystem};
use rich_ext::{ExtensionRegistry, ConsoleExt};
// Option A: the convenience trait
let mut console = Console::new();
console.install_extensions();
// Option B: a registry you compose yourself
let mut console = Console::new();
let mut registry = ExtensionRegistry::new();
registry.register_highlighter(|| Box::new(rich_ext::NumberHighlighter::new()));
registry.install(&mut console);
The registry (crates/rich-ext/src/registry.rs)
holds factories so one registry can be installed onto many consoles.
Writing a plugin (0.0.12)¶
The plugin contract is its own crate, rs-rich-plugin-api (imported as
rich_plugin_api, and re-exported as rich_ext::plugin). It depends only on
core rich, so a plugin crate never depends on rich-ext, and every
first-party plugin goes through the same contract.
A plugin implements Plugin: metadata() says who it is, and register()
adds capabilities through a PluginRegistrar:
| registrar method | adds |
|---|---|
highlighter(factory) |
a regex Highlighter for printed text |
code_highlighter(name, engine) |
a CodeHighlighter, selectable by name |
theme(name, theme) |
a named Theme |
box_style(name, box) |
a named table/panel box style |
renderer(name, renderer) |
a SourceRenderer that turns source text into a renderable |
fence_renderer(language, renderer) |
a FenceRenderer for Markdown fences in that language |
transform(name, transform) |
a TextTransform, chained by name into a text pipeline (0.0.12; see Transforms) |
action(name, action) |
a CustomAction on interactive views: list items, table rows, tree nodes and file entries (0.0.13, #491; see Actions). It has a default body, so a host without interactive views ignores it |
component(name, factory) |
an interactive component (a PluginComponent, from rich_plugin_api::component) that an app mounts by name beside the built-ins with rich_interact::plugin::PluginView (0.0.14; see Components from plugins). The factory makes a fresh one per mount. It has a default body, so a host without interactive views ignores it |
use rich_ext::plugin::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
use rich_ext::ExtensionRegistry;
struct Solarized;
impl Plugin for Solarized {
fn metadata(&self) -> PluginMetadata {
PluginMetadata::new("solarized", "Solarized themes", env!("CARGO_PKG_VERSION"))
}
fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
let theme = rich::Theme::from_styles([("repr.number", "#268bd2")], true)
.map_err(|e| PluginError::Other(e.to_string()))?;
registrar.theme("solarized", theme);
Ok(())
}
}
let mut registry = ExtensionRegistry::with_defaults();
registry.add_plugin(&Solarized)?;
ExtensionRegistry::add_plugin is the host. It is all-or-nothing: a plugin
whose register fails, or that breaks a rule below, leaves the registry as it
was.
- API version.
PluginMetadata::newrecordsPLUGIN_API_VERSION. A host refuses a plugin built for another version (PluginError::IncompatibleApi);is_supported_api_versionis that check, for a tool that chooses a plugin before adding it. At 0.0.x the contract still changes, and every breaking change bumps it. - Names. The plugin id and every capability name use lowercase letters,
digits,
-,_and.(at most 64 bytes), and start with a letter or digit, so they are safe to print and to use as CLI values. - No silent overrides. Two plugins may not register the same capability
under the same name, and a plugin id may be added once (
Conflict,DuplicatePlugin).
ExtensionRegistry::with_defaults() adds the built-in rich-ext plugin, which
provides the number highlighter and the syntect code highlighter. Query what
is registered with plugins(), code_highlighter(name), theme(name),
box_style(name), renderer(name), fence_renderer(language),
transform(name), transform_names(), actions(), component(name),
component_names(), create_component(name) (a fresh instance, or None
when the factory panics) and provided_by(capability).
text_pipeline(names) chains registered text transforms, in the order given,
into a rich_ext::transform::Pipeline. fences() combines every registered fence renderer
into one for Markdown::fence_renderer, routed by language.
A default code highlighter. ExtensionRegistry::set_default_code_highlighter(name,
theme) chooses a registered code highlighter (and optionally one of its
themes) for every console the registry is installed onto, through core's
ConsoleCodeHighlighting. Syntax, Markdown code, source_view and the diff
views without a highlighter of their own use it. An unknown name or theme is a
HighlighterChoiceError listing the choices. The CLI exposes this as
--highlighter and --code-theme.
rs-rich-lumis (LumisPlugin) registers the code highlighter "lumis":
tree-sitter grammars with lumis's Neovim themes, and ansi_dark/ansi_light
mapped from tree-sitter capture names to upstream's Pygments token styles.
The first plugin built this way is rs-rich-mermaid (MermaidPlugin): a
mermaid fence renderer and source renderer, with flowcharts drawn as text and
every diagram type through mmdc behind its mmdc feature.
rs-rich-diagram (rich_diagram::plugin::DotPlugin, behind its plugin
feature) registers DOT the same way: dot and graphviz fence renderers and a
dot source renderer, drawn natively through the diagram layout.
rich doctor lists the registered plugins and the API version (and includes
them in --report json).
Document formats (0.0.19)¶
rich_ext::document keeps documentation formats in a DocumentRegistry of
its own, beside the extension registry: Markdown, reStructuredText, Org,
AsciiDoc and man pages are built in, and a host adds a format with
DocumentRegistry::register(Arc<dyn DocumentFormat>). A format names its id,
aliases and extensions, sniffs content (a confidence from 0 to 1), and
renders source to a renderable with diagnostics; see
Documents.
A plugin cannot declare a document format through PluginRegistrar in this
API version: that needs a registrar method (and the extensions and sniffer it
would carry) in rich-plugin-api, which is left for a later version rather
than forced into renderer. Today a plugin registers a SourceRenderer with
renderer(name, โฆ) as before, and a host that wants it as a document format
wraps it:
use rich_ext::document::{DocumentRegistry, SourceRendererFormat};
let renderer = extensions.renderer("textile").expect("the plugin's renderer");
let mut documents = DocumentRegistry::with_builtins();
documents.register(std::sync::Arc::new(
SourceRendererFormat::new("textile", renderer).extensions(&["textile"]),
))?;
The rich binary uses the built-in formats only.
Third-party plugins (0.0.13)¶
Beyond add_plugin, a plugin can reach a registry in three more ways, each
more opt-in than the last. Why they work this way is in the design note,
Plugin loading.
Linked: add the dependency¶
A plugin crate registers itself once:
A host that wants every linked plugin asks for them:
They are collected at link time (with inventory) and added sorted by
plugin id, never in link order; two with the same id are an error
(PluginError::DuplicatePlugin) and none is added. with_defaults() does not
add them. The rich binary does, so a custom build that depends on a plugin
crate gets it. If the binary never names the crate, the linker may drop it:
write use my_plugin as _;.
Runtime: native libraries and WASM modules¶
Runtime plugins are loaded from a path, behind rs-rich-ext features that are
off by default: dylib-plugins for native libraries (.so, .dylib,
.dll) and wasm-plugins for WASM modules (.wasm).
use rich_ext::plugin_loading::{load, LoadOptions};
let plugin = load(path, &LoadOptions::default())?; // a LoadError names the path
registry.add_plugin(&plugin)?;
Rust types cannot cross a library boundary, so a runtime plugin exchanges UTF-8
text only, through the ABI in rich_plugin_api::abi. It may contribute:
| kind | input | output |
|---|---|---|
transform |
plain text | plain text, as a named TextTransform |
highlighter |
plain text | START END STYLE lines (byte offsets), as a Highlighter |
fence-markup |
a fence's code and the width | rich markup, as a FenceRenderer |
fence-ansi |
a fence's code and the width | ANSI SGR text, as a FenceRenderer |
Themes, box styles, renderables, code highlighters and interactive
components stay compile-time only. A component keeps state between events,
and the runtime ABI is stateless text in and out: each WASM call runs in a
fresh instance. So a component comes from a plugin added with add_plugin
or linked with export_plugin!, and rich plugins list shows it as
component "name".
- A native plugin is a
cdylibthat depends onrs-rich-plugin-apiand callsexport_dylib_plugin!with anabi::Exportsdescription; it needs no unsafe code. Seeexamples/dylib-plugin. - A WASM plugin exports
memory,rich_plugin_alloc,rich_plugin_manifestandrich_plugin_call(seeabi::wasm) and imports nothing. See the hand-writtenexamples/wasm/shout.wat. To write one in Rust, start from the template,templates/wasm-plugin/(0.0.24): a crate with no dependencies whose module the host loads, built and loaded by rs-rich-ext'stests/wasm_plugin_template.rs. The guide, Write a plugin, goes from the template to a listing on the marketplace.
The ABI is versioned separately from PLUGIN_API_VERSION: it is
abi::ABI_MAJOR.abi::ABI_MINOR, 1.0. A host refuses a plugin built for
another major (abi::is_supported is the check both loaders make), and one
that declares a capability kind the host does not know.
In the rich CLI¶
rich plugins list shows every plugin with its source (built-in, linked,
native, wasm), version, ABI and capabilities, and rich plugins info NAME
shows one; both take --report json. A default build has three built in:
rich-ext (the syntect code highlighter), dot and mermaid, plus lumis in
a build with that feature. A default build loads WASM plugins (its
wasm-plugins feature, on by default since 0.0.22), and a build with
dylib-plugins (cargo install rs-rich-cli --features dylib-plugins)
native ones too. --plugin PATH loads a runtime plugin for one run, and a plugins = [...]
list in ~/.config/rich/config.toml or a file given with --config loads
them every time. A WASM plugin installed with rich plugin install (below)
loads every time too, until it is disabled. Loaded plugins draw Markdown
fences and highlight printed text, and --transform NAME (repeatable, in
the order given) applies their transforms to text, --print and
--syntax, after --filter and before --highlight.
The marketplace client (0.0.22)¶
rs-rich-marketplace finds, checks and installs packages from the
marketplace; rich plugin is built on it. It has
no terminal code, and it is the only place the marketplace's trust rules
live:
- The key chain.
KeySetis one generation of the main key (it signs indexes) and the backup key (it signs only the next generation).KeySet::walkaccepts later generations one at a time, each signed by both keys of the one before, or by its backup key alone when it revokes a lost main key. - The index.
verify_indexrefuses a signature by any key but the current main key before checking it, refuses minisign's legacy unhashed algorithm, and checks the trusted comment's serial, theschema_version, a rollback against the last serial accepted (AcceptedIndex) and the expiry. - Listings.
VerifiedIndex::read_listingchecks a listing's SHA-256 against the index, then the rulesscripts/validate_marketplace.pychecks. - Resolution.
resolvepicks the newest version that is not yanked and that this host loads, withabi::is_supportedandis_supported_api_version, the loaders' own checks. A WASM version with a capability kind this host does not know is skipped. - Installing.
Installerdownloads over HTTPS only, through aTransport(HttpsTransport, ureq, behind the defaulthttpfeature; it trusts the system's certificate store first, so a TLS-inspecting proxy's root CA works, and Mozilla's bundled roots only when that store cannot be used (a certificate the system refuses stays refused): seerich_marketplace::tls), reads at most the declared size, checks the SHA-256, then checks the artifact by kind: a WASM module's header (a host with the sandbox passes anArtifactCheckerthat runsload_wasmandcompare_wasm_manifest), a micro pack'spack.json, a theme's parse. Only then is the file moved into place and recorded in the lockfile. - Loading.
Installer::loadandLockfile::verifyread an installed file only when it still hashes to what the lockfile records. No network and no index are needed, so an expired index never stops what is installed.
The official public keys (generation 1, the same file as the marketplace
repository's keys/keys-1.json) are compiled in since 0.0.23, as
crates/rich-marketplace/keys/keys-1.json. The crate's tests sign a
fixture marketplace with test keys checked into
crates/rich-marketplace/tests/fixtures/keys, trusted only by those tests.
rich plugin: installing from the marketplace (0.0.22)¶
rich plugin is the marketplace in the CLI, on rs-rich-marketplace:
rich plugin search diagram # id, kind, tier, the version this host loads
rich plugin search --sort downloads # most downloaded first
rich plugin info textcase # tier, source, versions, what loads here, downloads, ๐/๐
rich plugin install textcase # shows tier, permissions, capabilities; asks
rich plugin install textcase@0.1.0 --yes
rich plugin list # what is installed; no network
rich plugin disable textcase # keep it, do not load it
rich plugin update # newest for this host, asking first
rich plugin verify # every file, and every version against the index
rich plugin remove textcase
rich plugin browse # the same, in a selector with provenance
On the web, the marketplace's own site,
buchochelliq-labs.github.io/rs-rich-marketplace,
lists the same packages in a browser, with search, filters, download
counts and reviews, and the command that installs each one; the docs'
Marketplace page does too. rich plugin search matches capabilities
and authors too, and filters with --kind, --tier and --recommended
(the CLI guide).
Which packages are rs-rich's own. The official tier, shown as
โ official in the CLI and an "Official ยท by rs-rich" badge on the page,
is only for packages built and published by rs-rich from its own
repositories: the marketplace refuses official for any other repository,
and only a maintainer can set a tier. rich plugin info and the install
prompt say who publishes every package and who authored it, and a
non-official package says "not by rs-rich". reviewed packages are shown
as Approved: a maintainer read the source and rebuilt the same file.
community packages were only checked by the marketplace's CI. A
maintainer can also mark any package โ
recommended, with a short reason
(the format).
- What is listed (0.0.23). The official marketplace,
buchochelliq-labs/rs-rich-marketplace, goes live with 0.0.23. It lists: - the Mermaid, DOT and lumis crates;
textcase, a WASM plugin. 0.1.0 iscrates/rich-plugin-api/examples/wasm/textcase.wat: theupcaseanddowncasetransforms and aloudfence. 0.2.0 (crates/rich-cli/official/wasm/textcase-0.2.0.wat) adds thetitlecase,snakecase,kebabcaseandcamelcasetransforms;wordplay0.1.0, a WASM plugin (crates/rich-cli/official/wasm/wordplay-0.1.0.wat): therot13,reverseandslugtransforms;- the built-in micro packs
status,devandfun, as.richmicroarchives; - five themes, from
crates/rich-cli/official/themes/:duskandocean(dark),paper(light),high-contrast(every text colour at least 7:1 against black, WCAG AAA) andmono(no colour, attributes only). Each is an upstream[styles]file that sets every style of rich's default theme that has a colour (but the colour names, such asred); the coloured ones meet WCAG AA (4.5:1) against the background each file names.
The case transforms, rot13 and slug change ASCII letters only, and
pass every byte of a non-ASCII character through as part of a word, so
their output is UTF-8. The identifier cases and slug work line by line.
reverse reverses each line by character cluster: a character with its
combining marks, variation selectors, skin tones and ZWJ sequences, and a
flag's two regional indicators, move as one. It needs no Unicode tables,
so it is not full UAX #29 (conjoining Hangul jamo and Indic spacing marks
are split). Each transform takes at least 256 KiB in one call within the
default fuel. A listed version never changes, so each WASM version has
its own source file. The plugins, packs and themes are built
reproducibly, and crates/rich-cli/tests/official_artifacts.rs checks
each rebuild against its pinned hash (official_packages.rs checks what
the plugins and themes do). The marketplace serves them from its own
artifacts/. Until the maintainer signs the first index,
search says the marketplace has no index.json and exits 3.
- Downloads, ratings and reviews (0.0.23). info shows a package's
downloads (per version), its ๐ and ๐ and its reviews link, and its
JSON report has a stats object; search --sort downloads sorts by
downloads. They come from the marketplace's stats.json, written
nightly to its stats branch from GitHub's release download counts,
crates.io's counts and each package's discussion in the "Reviews"
category. It is not signed and is shown only: nothing installs by it,
a missing or slow file is left out silently (3-second timeout), and
rich sends nothing to be counted. To rate a package, react ๐ or ๐
to its Reviews discussion; to review it, comment there. See
Stats.
- Hosting (0.0.23). The marketplace hosts the official WASM plugins,
packs and themes as release assets of its repository, one release per
version (releases/download/ID-vVERSION/ID-VERSION.EXT); the first
listings keep their raw main URLs.
- What installs. A WASM plugin, a micro pack or a theme. A crate is
listed only: install refuses it and prints its cargo add line, and
info shows the same. An installed, enabled micro pack is loaded into
micro's user layer on every run (after the user's own directory, so the
user's own asset keeps a name both claim), once its file is checked
against the lockfile; disable stops it. An installed, enabled theme is
what --theme ID selects when the user's config has no theme of that
name (the config's own theme wins a clash), once its file is checked
against the lockfile; a changed file is not used, with a warning.
- The prompt. install and update print the package's tier, source,
revision, artifact URL, SHA-256, size, requirement, capabilities and
permissions, then ask. Nothing is downloaded before the answer. --yes
answers for a script; without a terminal and without --yes, nothing is
asked or written and the command exits 3. Answering no exits 1.
- The checks. The index's signature, key, serial (a rollback is
refused) and expiry; each listing's hash; the version against this host
(a yanked version, or one for another plugin ABI, is refused); then the
download's size and SHA-256. A WASM module is loaded in the sandbox with
the same loader --plugin uses, and its manifest's name, version, ABI and
capabilities must equal the listing's. Only then is the file written,
with the lockfile.
- Loading. An installed, enabled WASM plugin loads on every run without
--plugin (a build without the default wasm-plugins feature warns and
skips them). Each file is read once,
checked against the lockfile, and loaded from the bytes checked. A file
that changed on disk is not loaded: the run goes on without it and warns.
A plugin named with --plugin wins over an installed copy.
- Verifying. rich plugin verify checks every installed file against
the lockfile and every installed version against the current index: a
version no longer listed or listed with another hash is a problem (exit
4); a version yanked since is reported with its reason (exit 0), shown by
list from then on, and replaced by update.
- Where. Packages go to --plugin-dir DIR, else RICH_PLUGIN_DIR, else
$XDG_DATA_HOME/rich/plugins (~/.local/share/rich/plugins;
%LOCALAPPDATA%\rich\plugins on Windows), as ID/ID-VERSION.EXT beside
marketplace.lock.json. marketplace.state.json there keeps the disabled
packages, the last index each marketplace served, the key generations
verified after each trust root (verified again from the root whenever
they are read) and the yank reasons last seen. On Unix the directory is
created private (mode 700); an existing one keeps its mode.
- Which marketplace. --marketplace LOCATION, else RICH_MARKETPLACE,
else the official one. A location is an https:// base URL, or a
directory (a path or a file:// URL) laid out as the marketplace
repository is. A local marketplace serves each artifact
https://HOST/PATH from its own artifacts/HOST/PATH, never from the
network: that is how the tests and CI's compatibility job run offline.
- Which keys. --marketplace-keys FILE (a keys-N.json), else
RICH_MARKETPLACE_KEYS, else the official keys compiled into the crate
(since 0.0.23). Later key generations are fetched from the
marketplace's keys/ and accepted one at a time.
- Errors use the CLI's exit classes: 2 usage (an unknown id, a crate, a
version not listed, no trust root), 3 input (a file or download that
cannot be read, no terminal for the prompt), 4 data (a signature, hash,
manifest, yanked version or incompatible version). --report json writes
the result or error envelope on stderr and nothing on stdout.
Publishing. rich plugin publish FILE --url URL checks a package and
writes its listing, for a pull request to the marketplace repository. It
needs no network: the file is read locally, and URL is where its author
will upload it. A WASM plugin is loaded in the sandbox, and its manifest
gives the id, version, ABI and capabilities (--source-revision COMMIT is
required); a micro pack is read with micro's limits and its pack.json
gives the id, version and assets; a theme must parse, and takes --id and
--version. The SHA-256 and size are computed, and the listing is read
back with the crate's own parser before it is written to
packages/ID.json (or --output). A new listing needs --license and
--repository; --listing FILE adds the version to an existing one,
newest first, and refuses a version already listed.
rich plugin publish shout.wasm \
--url https://github.com/me/shout/releases/download/v0.2.0/shout.wasm \
--source-revision "$(git rev-parse HEAD)" \
--license MIT --repository https://github.com/me/shout
Checking a listing pull request happens in the marketplace repository
too: its check-listings workflow validates every listing, checks each new
version's artifact (size, SHA-256, a crate's crates.io checksum) and
installs each new WASM plugin, micro pack and theme with rich from a copy
of the marketplace signed with a throwaway key, with no secrets.
Signing happens in the marketplace repository, not here: when a listing
pull request merges, its workflow rebuilds index.json, bumps the serial
and signs it with the main key, held in the protected
marketplace-signing environment, and pushes it with a short-lived token of the signer's GitHub App. The workflow and its setup are kept in
docs/marketplace-repo/
to hand over.
Security: threat model¶
Plugins are code from someone else. What each kind can do, and what stands in its way:
- Nothing loads unless the user opts in. Compile-time plugins are chosen
by whoever builds the binary. A runtime plugin needs a path: on the
command line, or in the user's own config, or an install the user approved
with
rich plugin install. Therichbinary loads sandboxed WASM plugins by default (itswasm-pluginsfeature, since 0.0.22); native libraries need itsdylib-pluginsfeature, which stays off by default. A project's./rich.tomlmay not list plugins;richignores the list with a warning, so cloning a repository and runningrichin it never loads that repository's code. (The same rule keeps a project config from choosingmermaid_backend = "mmdc",dot_backend = "graphviz"or files to write.) - Native plugins run arbitrary code, so they are trust-only. Loading a
library runs its initialisers, and every call runs in the
richprocess with the user's permissions. The ABI makes the boundary well-defined (onlyrepr(C)data crosses it, another ABI major is refused before the rest of the descriptor is read, and the functionsexport_dylib_plugin!generates turn a panic into an error), but it is not a sandbox. Load only a library you would run as a program. - WASM plugins are sandboxed. A module that imports anything is refused, so it has no WASI, file system, network, clock or randomness. Each call runs in a fresh instance with a fuel limit (a loop that never ends is stopped) and a cap on its memory and table (a module that starts or grows past either is refused or trapped). A WASM plugin can waste at most one call's fuel and cannot see anything but its input.
- Output is sanitized, whatever the kind. Output over 4 MiB or not UTF-8
is an error. Every control character in transform, highlighter and markup
output, ESC included, is made visible. Only
fence-ansimay carry ANSI, and it goes through the same sanitizerrich viewuses: SGR styling survives, OSC strings (titles, clipboard, hyperlinks) are removed, and every other control is made visible. No style in plugin output keeps a link:[link=โฆ]markup andlinkin a highlighter span are dropped, so a plugin cannot show one URL and link to another. A highlighter span that does not fit its text is skipped. A failed fence renders as a code block. - Names cannot collide or spoof. Every plugin, compiled in or loaded, goes
through
add_plugin: its id and capability names are restricted to lowercase letters, digits,-,_and., and a name another plugin already provides is refused, so a runtime plugin cannot replace a built-in.
Roadmap: from internal to public¶
- Done โ internal.
rich-extwas the only registrant. - Done โ a public contract (0.0.12).
rs-rich-plugin-apidefines what a plugin is; the ext registry hosts it. Not yet a stability promise: see the API version above. - Done โ third-party loading (0.0.13). Linked plugins, native and WASM
runtime plugins, and
rich plugins list/info, as above. - Live with 0.0.23 โ the marketplace (#232). A Git-backed, signed index of
packages (the listing format, 0.0.21), the
rs-rich-marketplacecrate (0.0.22, below),rich plugin install(0.0.22), and the marketplace repository with every official plugin and the built-in micro packs listed and its pull request checks (0.0.23). It serves its first signed index once the maintainer has signed it.
Whatever we add, the invariant holds: the core never learns about a specific extension.
rich_ext::layout offers bounded composition independently of core Layout.
allocate validates constraints and returns sizes, padding and relaxed request
indices. fit_segments shares Unicode-aware overflow across extension renderables.
See cargo run -p rs-rich-ext --example layout -- 20 for a narrow sidebar layout.
Explicit rendering environment¶
The sanctioned protocol::RenderEnvironment trait and optional
protocol::ConsoleEnvironment let a Console carry an immutable capability snapshot
through nested renderables. This is the only new core seam; a Console without it
keeps upstream-compatible behavior. rich-ext::target::RenderTarget implements
destination policy and creates configured consoles. Core has no dependency on ext,
art or CLI. Prefer one observation at the application boundary followed by explicit
capabilities; never redetect stdout inside an explicit render.
TargetKind covers Terminal, PlainStream, Capture, Html, Svg and Custom.
Detection provenance is separate from the rendering snapshot. Support::Inferred
is a conservative hint; protocol rendering can require Confirmed. Unicode and
hyperlink policy are declared by the caller. Legacy renderers may still have their
own sizing choices; wrap them in bounded LayoutNode containers for strict cells.
layout::Overflowing gives any renderable one explicit OverflowPolicy: Syntax,
JSON and Text are rendered at their measured natural width and every line is then
wrapped, folded, cropped or ellipsised to the cell width (wide glyphs never split);
output that already fits is returned byte-identical to core.
LayoutNode leaves receive their region's height, like upstream Layout, so a
Panel leaf fills its region; opt into .content_height() for natural height.
Enable rs-rich-ext features testing, log and tracing independently. The
snapshot helper has no process environment dependency or assertion-framework
requirement. See the snapshot, diagnostic, log_adapter, tracing_adapter,
layout, live_regions and expanded_release examples in the crate.