plates_render/visibility.rs
1//! Audience visibility filtering: which *parts* of a document leave.
2//!
3//! The gate ([`prov::exports`]) decides which documents a site publishes. This
4//! decides which regions of one does, filtered against the same audience name,
5//! so a body and the site holding it can never disagree about who a paragraph
6//! is for.
7//!
8//! # A region is a container
9//!
10//! Every grammar spells a marked region as one node, and twig parses all three
11//! into the same AST kind — a `container` with a name, a class list and
12//! children:
13//!
14//! | Grammar | Spelling | `name` | classes |
15//! |---|---|---|---|
16//! | Markdown | `:::vis{.family}` … `:::` | `"vis"` | `family` |
17//! | Markdown (inline) | `:vis[text]{.family}` | `"vis"` | `family` |
18//! | Djot | `{.vis .family}` on the line above `:::` | `""` | `vis family` |
19//! | HTML | `<div class="vis family">` | `"div"` | `vis family` |
20//!
21//! So the predicate is uniform and needs no per-grammar branch: **a region is a
22//! container named `vis`, or one whose classes contain `vis`**, and **its
23//! declared audiences are its classes, less `vis` itself**.
24//! That is why a Djot body and a Markdown body filter through one function
25//! rather than two that agree until one of them is fixed.
26//!
27//! # Why the parser and not a scanner
28//!
29//! This module used to scan text for `:::vis{…}` and `:vis[…]{…}` without
30//! parsing it, which was grammar-blind on purpose — one scanner for three
31//! grammars. It was also blind to everything else, and the cost was a
32//! disclosure bug: a marker inside a code span
33//! (`` `:::vis{.family}` ``, in a document *explaining* the syntax) was treated
34//! as a real directive, and a real directive whose fence a list had indented
35//! was not.
36//!
37//! twig parses the body it is going to render anyway, so the spans are
38//! available for free and they are the spans the renderer will agree with.
39//! prov re-exports twig for exactly this ([`prov::twig`]).
40//!
41//! # Fail-closed
42//!
43//! Filtering is a disclosure boundary, so every way this can fail ends with
44//! *less* leaving rather than more. A body whose grammar cannot be parsed, a
45//! region this cannot account for, a marker left standing after the walk — all
46//! are [`Error`], never a body returned unfiltered. See [`Error`] for why the
47//! residue check exists at all.
48
49use prov::ContentFormat;
50use prov::twig::{self, Editor, MarkdownExtensions};
51
52/// The class — and, in Markdown, the directive name — that marks a region as
53/// audience-scoped.
54///
55/// One word across all three grammars, because it is the *marker*, not a
56/// grammar's spelling of one.
57pub const MARKER: &str = "vis";
58
59/// Why a body could not be filtered.
60///
61/// Every variant means the caller must **not** publish the body it passed in.
62/// There is no "filtered as best we could" outcome on purpose: a partial filter
63/// is indistinguishable from a complete one by inspection, and the thing it
64/// silently keeps is the thing someone declared private.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Error {
67 /// The body could not be parsed, so no region in it could be located.
68 Parse(String),
69 /// An edit twig refused. Structural, not authorial — a bug here rather than
70 /// in the document.
71 Edit(String),
72 /// A marker survived the filter.
73 ///
74 /// The backstop for the failure this module is most likely to have: a
75 /// region spelled in a way this module does not recognize is a region
76 /// nothing removed, and its content is then published to everyone. Cheap to
77 /// check, and it converts the worst outcome (a silent leak) into the
78 /// ordinary one (a refused publish naming the document).
79 Residue {
80 /// What was still there, for the message.
81 found: String,
82 },
83 /// A region the parser could not read as one.
84 ///
85 /// The signature of twig's one structural gap here: it does not nest
86 /// *inline* directives, so the outer half of `:vis[a :vis[b]{.x} c]{.y}`
87 /// parses as a bare `:vis` carrying neither attributes nor an interior,
88 /// while `a`, `c` and the `{.y}` that scoped them stay outside it as prose.
89 ///
90 /// Unwrapping that marker would delete the word `:vis` and publish
91 /// everything it was scoping — a leak with no marker left behind for
92 /// [`Residue`](Self::Residue) to find, which is why it is caught here by
93 /// shape instead. Nested *block* regions are unaffected and work.
94 Malformed {
95 /// The source of the region that could not be read.
96 found: String,
97 },
98}
99
100impl std::fmt::Display for Error {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::Parse(e) => write!(f, "body could not be parsed for visibility filtering: {e}"),
104 Self::Edit(e) => write!(f, "visibility filter could not edit the body: {e}"),
105 Self::Malformed { found } => write!(
106 f,
107 "a `{MARKER}` region could not be read as one ({found}) — an inline region \
108 nested inside another inline region is not supported; use a block region \
109 (`:::{MARKER}`) for the outer one"
110 ),
111 Self::Residue { found } => write!(
112 f,
113 "a `{MARKER}` marker survived visibility filtering ({found}) — refusing to \
114 publish a body whose audience regions were not all resolved"
115 ),
116 }
117 }
118}
119
120impl std::error::Error for Error {}
121
122/// Who the filtered body is for.
123#[derive(Debug, Clone, Copy)]
124pub enum Audience<'a> {
125 /// Every region survives; only the markers are removed.
126 ///
127 /// For rendering a document *as its author sees it* — an editor preview,
128 /// a local build with no audience chosen. Never for a publish.
129 All,
130 /// A region survives when it declares at least one of these.
131 ///
132 /// An empty slice keeps nothing, which is the honest reading of "for no
133 /// audience" and matches the gate: a document declaring nothing is visible
134 /// to no one.
135 Only(&'a [&'a str]),
136}
137
138impl Audience<'_> {
139 /// Whether a region declaring `declared` survives.
140 fn admits(&self, declared: &[String]) -> bool {
141 match self {
142 Self::All => true,
143 // Case-insensitive and trimmed, which is what this has always been.
144 // Deliberately *not* tightened to the gate's exact match in the same
145 // change that moves the spelling to classes: two ways for a region
146 // to vanish silently, landing together, is one migration nobody can
147 // debug. `SitePlan::case_drift` is where that argument belongs.
148 Self::Only(wanted) => declared.iter().any(|d| {
149 let d = d.trim();
150 wanted.iter().any(|w| d.eq_ignore_ascii_case(w.trim()))
151 }),
152 }
153 }
154}
155
156/// Cheap pre-check: is it even worth parsing this body?
157///
158/// Text-level and deliberately over-eager — it answers "might there be a
159/// region", and a false positive costs one parse. A false *negative* would skip
160/// the filter on a body that needed it, so this must stay wider than the real
161/// syntax, never narrower.
162pub fn has_visibility_directives(body: &str) -> bool {
163 body.contains(MARKER)
164}
165
166/// A region twig found, reduced to what filtering needs.
167struct Region {
168 /// The node's whole byte range.
169 span: std::ops::Range<usize>,
170 /// Its interior, when it has one — what survives an unwrap.
171 content: Option<std::ops::Range<usize>>,
172 /// The audiences it declares: its classes, less [`MARKER`].
173 declared: Vec<String>,
174}
175
176/// Read a container as a visibility region, or `None` when it is some other
177/// container (an HTML `<figure>`, a Markdown `:::note`).
178///
179/// The whole per-grammar difference lives here, and it amounts to two ways of
180/// carrying one marker: Markdown puts it in the directive's *name*, Djot and
181/// HTML in the element's *class list*. Everything downstream sees one shape.
182fn region_of(node: &twig::FlatNode) -> Option<Region> {
183 if !matches!(node.kind, twig::Kind::Container) {
184 return None;
185 }
186 let classes: Vec<String> = node
187 .attrs
188 .iter()
189 .find(|(k, _)| k == "class")
190 .and_then(|(_, v)| v.as_deref())
191 .map(|v| v.split_whitespace().map(str::to_string).collect())
192 .unwrap_or_default();
193
194 let named = node.name.as_deref() == Some(MARKER);
195 let classed = classes.iter().any(|c| c == MARKER);
196 if !named && !classed {
197 return None;
198 }
199
200 Some(Region {
201 span: node.span.clone(),
202 content: node.content_span.clone(),
203 declared: classes.into_iter().filter(|c| c != MARKER).collect(),
204 })
205}
206
207/// Filter `body` to the regions `audience` may see.
208///
209/// The document's own grammar decides how a region is spelled and twig decides
210/// where it is; this decides which survive. Returns the body with every region
211/// either removed or unwrapped — never one with a marker still in it (see
212/// [`Error::Residue`]).
213pub fn filter_body(
214 body: &str,
215 format: ContentFormat,
216 audience: Audience<'_>,
217) -> Result<String, Error> {
218 if !has_visibility_directives(body) {
219 return Ok(body.to_string());
220 }
221
222 let mut editor = Editor::new_ext(body.as_bytes(), twig_format(format), extensions(format))
223 .map_err(|e| Error::Parse(format!("{e:?}")))?;
224
225 // One region per pass, re-reading the tree each time: twig reparses after
226 // every edit, so every span but the one just used is stale. The alternative
227 // — collecting all the spans and splicing them back-to-front — is wrong the
228 // moment regions nest, because an outer region's end moves when its inner
229 // one is rewritten.
230 //
231 // Bounded by the region count: each pass removes exactly one container,
232 // either by deleting it or by replacing it with its own interior, and
233 // neither puts a `vis` container back.
234 loop {
235 let nodes = editor.nodes().map_err(|e| Error::Parse(format!("{e:?}")))?;
236 let Some(region) = nodes.iter().find_map(region_of) else {
237 break;
238 };
239
240 // A region with neither an interior nor a declared audience is not a
241 // region twig read correctly — see `Error::Malformed`. Refusing here is
242 // what stops the unwrap below from stripping the marker and publishing
243 // the text it was scoping.
244 if region.content.is_none() && region.declared.is_empty() {
245 let source = editor
246 .source_str()
247 .map_err(|e| Error::Edit(format!("{e:?}")))?;
248 return Err(Error::Malformed {
249 found: source
250 .get(region.span.clone())
251 .unwrap_or("?")
252 .replace('\n', "\\n"),
253 });
254 }
255
256 let replacement = if audience.admits(®ion.declared) {
257 // Unwrap: the region's own text stays, its marker goes. Taken from
258 // the *current* source rather than the original, which is a
259 // different string after the first pass.
260 match ®ion.content {
261 Some(interior) => {
262 let source = editor
263 .source_str()
264 .map_err(|e| Error::Edit(format!("{e:?}")))?;
265 source
266 .get(interior.clone())
267 .ok_or_else(|| {
268 Error::Edit(format!("interior {interior:?} is not a char boundary"))
269 })?
270 .to_string()
271 }
272 // A container with no interior has nothing to keep.
273 None => String::new(),
274 }
275 } else {
276 String::new()
277 };
278
279 let start = attrs_aware_start(
280 &editor
281 .source_str()
282 .map_err(|e| Error::Edit(format!("{e:?}")))?,
283 region.span.start,
284 );
285 editor
286 .edit_range(start, region.span.end, &replacement)
287 .map_err(|e| Error::Edit(format!("{e:?}")))?;
288 }
289
290 let out = editor
291 .source_str()
292 .map_err(|e| Error::Edit(format!("{e:?}")))?;
293 residue_check(&out, format)?;
294 Ok(out)
295}
296
297/// Extend a region's start backwards over a standalone attribute line.
298///
299/// Djot writes a block's attributes on the line *above* it (`{.vis .family}`
300/// then `:::`), and twig attaches them to the container while leaving them
301/// outside its `span`. Deleting the span alone would leave the attribute line
302/// behind as a paragraph — which publishes the *audience's name* to everyone,
303/// a small disclosure in its own right and a visible artefact either way.
304///
305/// Only a line that is nothing but a `{…}` block is absorbed, so prose ending
306/// in a brace is untouched.
307fn attrs_aware_start(source: &str, span_start: usize) -> usize {
308 let before = &source[..span_start];
309 // Only a block region has a line above it. An inline one starts mid-line,
310 // where the preceding text is prose and absorbing it would eat the sentence.
311 let Some(trimmed) = before.strip_suffix('\n') else {
312 return span_start;
313 };
314 let line_start = trimmed.rfind('\n').map(|i| i + 1).unwrap_or(0);
315 let line = trimmed[line_start..].trim();
316 if line.starts_with('{') && line.ends_with('}') && line.len() > 1 {
317 line_start
318 } else {
319 span_start
320 }
321}
322
323/// Refuse a body that still carries a marker outside of code.
324///
325/// The walk above ends when no `vis` container is left, so a *parser*-level
326/// check would only re-ask a question that already answered itself. What it
327/// cannot answer is the case that matters: a body parsed under the wrong
328/// grammar, or Markdown parsed without [`extensions`]'s directive opt-in, has
329/// no containers to find and returns every region intact. Text is the only
330/// evidence left, so text is what this reads.
331///
332/// Code is excluded, and that exclusion is the whole reason this needs prov: a
333/// document *explaining* the syntax quotes it, and quoting is not declaring.
334/// [`prov::code_spans`] is the same code-awareness prov's own link scan uses,
335/// so a marker in a code span is prose here for exactly the reason it is prose
336/// there.
337fn residue_check(out: &str, format: ContentFormat) -> Result<(), Error> {
338 let code = prov::code_spans(out, format).unwrap_or_default();
339 let in_code = |at: usize| code.iter().any(|s| s.contains(&at));
340
341 for spelling in [":::vis", "::vis", ":vis["] {
342 let mut from = 0;
343 while let Some(rel) = out[from..].find(spelling) {
344 let at = from + rel;
345 if !in_code(at) {
346 let end = out[at..]
347 .char_indices()
348 .nth(40)
349 .map_or(out.len(), |(i, _)| at + i);
350 return Err(Error::Residue {
351 found: out[at..end].replace('\n', "\\n"),
352 });
353 }
354 from = at + spelling.len();
355 }
356 }
357 Ok(())
358}
359
360/// twig's name for a prov content format.
361///
362/// Spelled here because prov's own mapping is private, and prov is right to
363/// keep it so: it converts for its two FFI calls, not as a public claim about
364/// which twig format a `ContentFormat` *is*.
365fn twig_format(format: ContentFormat) -> twig::Format {
366 match format {
367 ContentFormat::Markdown => twig::Format::Markdown,
368 ContentFormat::Djot => twig::Format::Djot,
369 ContentFormat::Html => twig::Format::Html,
370 }
371}
372
373/// The parse extensions a grammar needs to see a region at all.
374///
375/// Markdown's generic directives are opt-in, and **the opt-in is
376/// load-bearing**: without it `:::vis{.family}` is a paragraph of literal text,
377/// no container matches, and the region publishes intact. Djot and HTML spell a
378/// region with syntax they always parse, so they need nothing.
379fn extensions(format: ContentFormat) -> MarkdownExtensions {
380 MarkdownExtensions {
381 directives: matches!(format, ContentFormat::Markdown),
382 ..MarkdownExtensions::default()
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn only<'a>(a: &'a [&'a str]) -> Audience<'a> {
391 Audience::Only(a)
392 }
393
394 #[test]
395 fn markdown_keeps_the_matching_region_and_drops_the_rest() {
396 let body = ":::vis{.public}\nSeen\n:::\n\n:::vis{.family}\nHidden\n:::\n";
397 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
398 assert!(out.contains("Seen"), "{out:?}");
399 assert!(!out.contains("Hidden"), "{out:?}");
400 }
401
402 #[test]
403 fn a_region_declaring_several_audiences_matches_any_of_them() {
404 let body = ":::vis{.family .friends}\nBoth\n:::\n";
405 for who in ["family", "friends"] {
406 let out = filter_body(body, ContentFormat::Markdown, only(&[who])).unwrap();
407 assert!(out.contains("Both"), "{who}: {out:?}");
408 }
409 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
410 assert!(!out.contains("Both"), "{out:?}");
411 }
412
413 #[test]
414 fn inline_regions_filter_too() {
415 let body = "a :vis[keep]{.public} b :vis[drop]{.family} c\n";
416 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
417 assert!(out.contains("keep"), "{out:?}");
418 assert!(!out.contains("drop"), "{out:?}");
419 }
420
421 /// The bug the text scanner had, and the reason this module parses. A
422 /// document *about* the syntax quotes it; a quoted marker is prose.
423 #[test]
424 fn a_marker_inside_a_code_span_is_prose() {
425 let body = "Write `:::vis{.family}` to scope a region.\n\n:::vis{.family}\nHidden\n:::\n";
426 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
427 assert!(out.contains("Write `:::vis{.family}`"), "{out:?}");
428 assert!(!out.contains("Hidden"), "{out:?}");
429 }
430
431 #[test]
432 fn nested_regions_resolve_from_the_inside_out() {
433 let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
434 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
435 assert!(out.contains("outer"), "{out:?}");
436 assert!(!out.contains("inner"), "{out:?}");
437 }
438
439 #[test]
440 fn html_regions_filter_by_class() {
441 let body = "<div class=\"vis public\">Seen</div>\n<div class=\"vis family\">Hidden</div>\n";
442 let out = filter_body(body, ContentFormat::Html, only(&["public"])).unwrap();
443 assert!(out.contains("Seen"), "{out:?}");
444 assert!(!out.contains("Hidden"), "{out:?}");
445 }
446
447 /// Djot carries a block's attributes on the line above it. Removing the
448 /// container without them would leave `{.vis .family}` standing — the
449 /// audience's name, published.
450 #[test]
451 fn djot_regions_take_their_attribute_line_with_them() {
452 let body = "{.vis .family}\n:::\nHidden\n:::\n";
453 let out = filter_body(body, ContentFormat::Djot, only(&["public"])).unwrap();
454 assert!(!out.contains("Hidden"), "{out:?}");
455 assert!(!out.contains("family"), "the audience name leaked: {out:?}");
456 }
457
458 #[test]
459 fn all_keeps_every_region_and_removes_every_marker() {
460 let body = ":::vis{.public}\nA\n:::\n\n:::vis{.family}\nB\n:::\n";
461 let out = filter_body(body, ContentFormat::Markdown, Audience::All).unwrap();
462 assert!(out.contains('A') && out.contains('B'), "{out:?}");
463 assert!(!out.contains("vis"), "{out:?}");
464 }
465
466 /// No audience is not "every audience". A document declaring nothing is
467 /// visible to no one, and a region is judged the same way.
468 #[test]
469 fn an_empty_audience_list_keeps_nothing() {
470 let body = ":::vis{.public}\nA\n:::\n";
471 let out = filter_body(body, ContentFormat::Markdown, only(&[])).unwrap();
472 assert!(!out.contains('A'), "{out:?}");
473 }
474
475 /// A body with no marker never reaches the parser.
476 #[test]
477 fn a_body_with_no_regions_is_returned_unchanged() {
478 let body = "# Title\n\nJust prose.\n";
479 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
480 assert_eq!(out, body);
481 }
482
483 /// The migration's sharp edge, and the reason it is safe. The old bare-key
484 /// spelling declares no *class*, so it matches no audience and its content
485 /// is dropped — content vanishes rather than leaking. The marker still goes,
486 /// so this does not trip the residue check.
487 #[test]
488 fn the_old_bare_key_spelling_drops_rather_than_leaks() {
489 let body = ":::vis{public}\nHidden\n:::\n";
490 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
491 assert!(
492 !out.contains("Hidden"),
493 "old spelling must not publish: {out:?}"
494 );
495 }
496
497 /// twig does not nest inline directives, and the half-parsed result would
498 /// otherwise publish the text it was scoping with only the marker removed.
499 /// Refused by shape, since no marker survives for the residue check to find.
500 #[test]
501 fn a_nested_inline_region_is_refused_rather_than_half_filtered() {
502 let body = "A :vis[secret :vis[inner]{.public} end]{.family}\n";
503 let err = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap_err();
504 assert!(matches!(err, Error::Malformed { .. }), "{err:?}");
505 }
506
507 /// The same nesting spelled with block regions is fine — the gap is inline
508 /// directives only.
509 #[test]
510 fn nested_block_regions_are_not_affected_by_that_gap() {
511 let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
512 let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
513 assert!(out.contains("outer") && !out.contains("inner"), "{out:?}");
514 }
515
516 /// The backstop, exercised directly: a marker the walk did not account for
517 /// refuses the body instead of publishing it.
518 #[test]
519 fn a_surviving_marker_is_refused() {
520 let err = residue_check(
521 "text\n:::vis{.family}\nHidden\n:::\n",
522 ContentFormat::Markdown,
523 )
524 .unwrap_err();
525 assert!(matches!(err, Error::Residue { .. }), "{err:?}");
526 }
527}