Module: Carve
- Defined in:
- lib/carve.rb,
lib/carve/version.rb
Constant Summary collapse
- EXTENSIONS =
Extension names the native binding understands.
Taken from the engine itself rather than typed out here. This was a literal array, and the native binding had its own match arm listing the same names, so an extension carve-rs gained was unreachable from Ruby until somebody updated both by hand - and nothing failed in the meantime.
The names are the engine's kebab-case keys, exposed as symbols (
:"math-block"). The snake_case spellings this binding has always taken (:math_block) and its short aliases (:mermaid,:dot,:toc) still work as ARGUMENTS; this constant lists the canonical names. _extension_names.map(&:to_sym).freeze
- MODES =
Render modes the native binding understands.
:interactive(default) emits live HTML with client-script hooks (e.g.<pre class="mermaid">,<details>).:staticemits self-contained HTML for print / PDF / archival: it forces disclosure (<details open>) and pre-renders client-script constructs through therenderers:callables, degrading to (escaped) source when a renderer is absent or fails. %i[interactive static].freeze
- RENDERER_KEYS =
Renderer keys accepted by the
renderers:Hash (see .to_html). Each maps a construct's source to a self-contained HTML string emitted on the static path.mermaid/chart/graphvizare callables(String) -> String;mathis(String, display_bool) -> String. %i[mermaid chart graphviz math].freeze
- VERSION =
The released version of this gem. The gemspec derives
spec.versionfrom it, and an embedder quoting a version in a bug report is quoting this.It is not kept correct by hand on release: test/release_version_test.rb compares it against ext/carve/Cargo.toml and against the newest cut CHANGELOG section on every run, and .github/workflows/release.yml refuses to publish a gem whose version is not the tag being released.
"0.1.1"
Class Method Summary collapse
-
.needs_review?(source, current_version = nil) ⇒ Boolean
Whether a document was last processed under an older spec version than this engine targets, so the
[behavior]changelog entries between the two are worth reviewing. -
.parse(source) ⇒ Object
Parse Carve
sourceinto an AST: a tree of Ruby Hashes and Arrays. -
.read_stamp(source) ⇒ Object
Read a document's provenance marker, as written by
carve fmt --stamp. -
.to_html(source, extensions: nil, mode: nil, renderers: nil, symbols: nil, safe: false, profile: nil, sections: true) ⇒ Object
Render Carve
sourceto an HTML string.
Class Method Details
.needs_review?(source, current_version = nil) ⇒ Boolean
Whether a document was last processed under an older spec version than
this engine targets, so the [behavior] changelog entries between the two
are worth reviewing.
Carve.needs_review?(stored_document)
An UNSTAMPED document answers true: its provenance is unknown, and
assuming it is current is the unsafe direction. Pass current_version to
compare against something other than this engine's spec version.
See https://markup-carve.github.io/carve/versioning for what a version difference means for a stored document.
211 212 213 |
# File 'lib/carve.rb', line 211 def needs_review?(source, current_version = nil) _stamp_needs_review(source.to_s, current_version&.to_s) end |
.parse(source) ⇒ Object
Parse Carve source into an AST: a tree of Ruby Hashes and Arrays.
Carve.parse("# Hi")
# => {type: "document",
# children: [{type: "heading", level: 1,
# children: [{type: "text", value: "Hi"}], attrs: nil}],
# srcByteLength: 4}
The root carries exactly :type, :children and :srcByteLength (spec
PART 12 §7). Frontmatter and footnote definitions are block nodes in
:children, not root fields: a root field cannot carry the position §4
requires of every node, and both are source an editor navigates to.
Frontmatter is the first child, carrying :format and RAW :content.
Every node is a Hash with a :type key plus its fields; child collections
are Arrays; :attrs is nil or a Hash of {id:, classes:, key_values:}.
Keys are symbols. This is the raw parse tree (default profile, no
extensions), suitable for a custom renderer (e.g. Carve -> PDF).
233 234 235 236 237 238 239 |
# File 'lib/carve.rb', line 233 def parse(source) # max_nesting: false - the engine already bounds nesting (its own # MAX_NESTING_DEPTH cap), and that cap exceeds Ruby JSON's default # max_nesting of 100. Without this, deeply-nested-but-valid documents # that Carve.to_html renders fine would raise JSON::NestingError here. JSON.parse(_to_ast_json(source.to_s), symbolize_names: true, max_nesting: false) end |
.read_stamp(source) ⇒ Object
Read a document's provenance marker, as written by carve fmt --stamp.
Carve.read_stamp(source)
# => {version: "0.1", generated_by: "carve-php 0.1.0"}
Carve.read_stamp("# Hi\n")
# => nil
Returns nil when the document carries no marker. That is the normal case
for a hand-written document and means "unknown", not "current".
Both documented marker forms are read - the trailing %% line and the
%%% block - and a marker written by any Carve engine reads the same,
which is the point of recording it.
195 196 197 |
# File 'lib/carve.rb', line 195 def read_stamp(source) _read_stamp(source.to_s) end |
.to_html(source, extensions: nil, mode: nil, renderers: nil, symbols: nil, safe: false, profile: nil, sections: true) ⇒ Object
Render Carve source to an HTML string.
With no extensions:
Carve.to_html("# Hello") # => "<section ...>\n <h1>Hello</h1>..."
With extensions (Array of names as Symbols or Strings):
Carve.to_html(src, extensions: [:math_block])
Carve.to_html(src, extensions: %w[math-block list-table])
Recognized extension names: see Carve::EXTENSIONS. Names may be given snake_case (:math_block) or hyphenated ("math-block").
An unknown extension name raises ArgumentError (from the native layer).
Static render mode
Pass mode: :static (or "static") to emit self-contained HTML for print /
PDF / archival. In static mode disclosure is forced (<details open>) and
client-script constructs are pre-rendered through the renderers: Hash:
Carve.to_html(src, extensions: [:fenced_render], mode: :static,
renderers: { mermaid: ->(s) { "<svg>#{s}</svg>" } })
Renderer callables (Symbol or String keys, see Carve::RENDERER_KEYS):
* +:mermaid+ / +:chart+ / +:graphviz+ -> callable `(String) -> String`
* +:math+ -> callable `(String, display) -> String`
When a needed renderer is absent, or a renderer raises / returns a
non-String, the construct degrades to its HTML-ESCAPED source (never blank,
never raw HTML). Omitting mode: defaults to interactive (non-breaking).
An unknown mode or renderer key raises ArgumentError (from the native layer).
Symbols
A :name: symbol renders its literal :name: source unless the name is
in the symbols: Hash (String or Symbol keys, String values):
Carve.to_html("Ship it :rocket: :shrug:", symbols: { "rocket" => "🚀" })
# => "<p>Ship it 🚀 :shrug:</p>" (an unmapped name stays literal)
The leading word-boundary guard is unaffected by an active map: a:b:c,
10:30: and me@example.com never become symbols. A non-String value
raises TypeError (from the native layer).
SECURITY: a mapped symbol value is inserted as TRUSTED RAW output in the
target format - it is NOT escaped, the same trust class as a renderers:
callable. { "b" => "x" } emits a real <b> element, not
escaped text. This is deliberate: processor configuration is trusted.
NEVER build a symbols map out of untrusted / user-supplied input.
Section wrappers
A top-level heading is wrapped, along with the content following it up to
the next same-or-shallower heading, in a <section> carrying the heading's
id (spec PART 9 §13). Only the id moves - {#install .featured} gives
Pass sections: false to render headings flat, with the id back on the
<h*>:
Carve.to_html("# A\n\np\n")
# => "<section id=\"A\">\n <h1>A</h1>\n <p>p</p>\n</section>"
Carve.to_html("# A\n\np\n", sections: false)
# => "<h1 id=\"A\">A</h1>\n<p>p</p>"
This is for a host whose CSS or JS assumes rendered blocks are direct
children of the content container - the .stack > * + * spacing
idiom, :first-child, nth-child() counting, DOM child walks - all of
which stop matching once a wrapper sits in between. It is the one output
change that breaks a document whose source migrated cleanly.
Nothing else changes: ids, collision dedup, </#id>
cross-references, implicit [Heading][] references and heading numbering
all resolve against the slug rather than the element carrying it. The
endnotes is a separate construct and
is still emitted.
Untrusted input
Carve's normative hardening is always on and needs no argument here:
dangerous URL schemes are blanked, event-handler attributes such as
onclick are dropped, and the bidi override / isolate characters behind
Trojan Source are removed from rendered text.
Raw passthrough is the deliberate exception - a ```=html block or
a ...{=html} span renders VERBATIM by design - so it is the one
thing input you did not author has to switch off:
Carve.to_html(user_input, safe: true, profile: :comment)
safe: escapes those raw blocks and spans instead of emitting them.
profile: restricts which constructs are allowed at all and caps input
length: :full, :article, :comment or :minimal (String or Symbol),
nil for no profile. An unknown profile name raises ArgumentError (from
the native layer) rather than being ignored.
A profile REJECTION also raises ArgumentError - input past the profile's
max_length, or a denied construct when the profile's action is error:
Carve.to_html("x" * 20_000, profile: :minimal)
# ArgumentError: Profile violations: 'document' is not allowed:
# max_length_exceeded (...)
It is an exception rather than a return value because the engine's infallible entry point answers a rejection with an empty String, which a caller cannot tell from a document that legitimately rendered to nothing.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/carve.rb', line 154 def to_html(source, extensions: nil, mode: nil, renderers: nil, symbols: nil, safe: false, profile: nil, sections: true) list = Array(extensions) # Fast path: interactive (default), no extensions, no renderers, no # symbols, neither safe-render control in play, and section wrapping left # on. `sections: false` MUST fall through -- `_to_html` takes no options, # so short-circuiting to it would drop the flag and return wrapped output # with no error, which is the failure mode a caller cannot see. if list.empty? && (mode.nil? || mode.to_s == "interactive") && (renderers.nil? || renderers.empty?) && (symbols.nil? || symbols.empty?) && !safe && profile.nil? && sections return _to_html(source.to_s) end _to_html_safe( source.to_s, list.map(&:to_s), (mode || :interactive).to_s, renderers || {}, symbols || {}, !!safe, profile&.to_s, !!sections, ) end |