parity(8a): transclusion attr quote-stripping
`{{url|alt|key="quoted value"}}` syntax was already parsed end-to-end
through to `TransclusionNode::attrs`, but the value retained the
surrounding `"…"` (or `'…'`) characters verbatim. The HTML renderer
then escaped them into `"`, producing `style=""border: 1px""`
instead of clean `style="border: 1px"`.
Extracted a small `insert_attr` helper that splits on `=`, trims, and
strips matching single- or double-quote pairs from the value before
inserting. Unquoted values pass through unchanged.
Tests: 4 new in crates/nuwiki-core/tests/transclusion_attrs.rs
covering double-quote stripping, single-quote stripping, unquoted
pass-through, and the clean-HTML rendering shape (no `"`).
Note: this commit covers the "transclusion attrs" half of the
original Cluster 8 audit. The "multi-line list-item continuation
paragraphs" half requires lexer + parser changes (continuation lines
indented past the marker should attach to the prior list item
instead of becoming a sibling paragraph) and is deferred.
Gates: 429 Rust / 39 Neovim / 12 Vim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1183,27 +1183,14 @@ fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, us
|
|||||||
for window in seps.windows(2) {
|
for window in seps.windows(2) {
|
||||||
let s = window[0];
|
let s = window[0];
|
||||||
let e = window[1];
|
let e = window[1];
|
||||||
// attr segment is at [s+1..e]; format key=val
|
|
||||||
let segment = collect_text(&toks[s + 1..e]);
|
let segment = collect_text(&toks[s + 1..e]);
|
||||||
if let Some(eq) = segment.find('=') {
|
insert_attr(&segment, &mut attrs);
|
||||||
let k = segment[..eq].trim().to_owned();
|
|
||||||
let v = segment[eq + 1..].trim().to_owned();
|
|
||||||
if !k.is_empty() {
|
|
||||||
attrs.insert(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Last attribute segment between final sep and close.
|
// Last attribute segment between final sep and close.
|
||||||
if let Some(&last) = seps.last() {
|
if let Some(&last) = seps.last() {
|
||||||
if last + 1 < close_idx && seps.len() >= 2 {
|
if last + 1 < close_idx && seps.len() >= 2 {
|
||||||
let segment = collect_text(&toks[last + 1..close_idx]);
|
let segment = collect_text(&toks[last + 1..close_idx]);
|
||||||
if let Some(eq) = segment.find('=') {
|
insert_attr(&segment, &mut attrs);
|
||||||
let k = segment[..eq].trim().to_owned();
|
|
||||||
let v = segment[eq + 1..].trim().to_owned();
|
|
||||||
if !k.is_empty() {
|
|
||||||
attrs.insert(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1206,30 @@ fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, us
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split a `key=value` attribute segment, strip surrounding quotes from
|
||||||
|
/// the value, and insert into `attrs`. Empty keys are skipped.
|
||||||
|
fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap<String, String>) {
|
||||||
|
let Some(eq) = segment.find('=') else { return };
|
||||||
|
let k = segment[..eq].trim().to_owned();
|
||||||
|
if k.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let raw = segment[eq + 1..].trim();
|
||||||
|
let v = strip_quotes(raw).to_owned();
|
||||||
|
attrs.insert(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_quotes(s: &str) -> &str {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() >= 2
|
||||||
|
&& ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
|
||||||
|
{
|
||||||
|
&s[1..s.len() - 1]
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_text(toks: &[VimwikiToken]) -> String {
|
fn collect_text(toks: &[VimwikiToken]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for t in toks {
|
for t in toks {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Cluster 8 — transclusion attribute parsing. The basic
|
||||||
|
//! `{{url|alt|key=val}}` shape was already wired; this test pins
|
||||||
|
//! down quote-stripping on quoted values (`key="quoted value"`)
|
||||||
|
//! so an HTML renderer doesn't double-emit `"` artefacts.
|
||||||
|
|
||||||
|
use nuwiki_core::ast::{BlockNode, InlineNode};
|
||||||
|
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||||
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
|
|
||||||
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||||
|
VimwikiSyntax::new().parse(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_transclusion(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::TransclusionNode {
|
||||||
|
let para = match &doc.children[0] {
|
||||||
|
BlockNode::Paragraph(p) => p,
|
||||||
|
_ => panic!("expected paragraph"),
|
||||||
|
};
|
||||||
|
for child in ¶.children {
|
||||||
|
if let InlineNode::Transclusion(t) = child {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no transclusion in paragraph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transclusion_attrs_strip_double_quotes() {
|
||||||
|
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||||
|
let t = first_transclusion(&doc);
|
||||||
|
assert_eq!(t.url, "cat.png");
|
||||||
|
assert_eq!(t.alt.as_deref(), Some("cat"));
|
||||||
|
assert_eq!(
|
||||||
|
t.attrs.get("style").map(String::as_str),
|
||||||
|
Some("border: 1px")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transclusion_attrs_strip_single_quotes() {
|
||||||
|
let doc = parse("{{cat.png|cat|class='thumb'}}");
|
||||||
|
let t = first_transclusion(&doc);
|
||||||
|
assert_eq!(t.attrs.get("class").map(String::as_str), Some("thumb"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transclusion_attrs_leave_unquoted_values_alone() {
|
||||||
|
let doc = parse("{{cat.png|cat|width=200}}");
|
||||||
|
let t = first_transclusion(&doc);
|
||||||
|
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transclusion_renders_clean_html_with_quoted_attr() {
|
||||||
|
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||||
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||||
|
assert!(out.contains(r#"style="border: 1px""#), "got: {out}");
|
||||||
|
assert!(!out.contains("""));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user