Skip to main content

ssg/plugins/
shortcodes.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Shortcode expansion plugin.
5//!
6//! Preprocesses Markdown content before compilation, expanding
7//! `{{< shortcode args >}}` patterns into HTML fragments.
8
9use crate::error::{PathErrorExt, SsgError};
10use crate::plugin::{Plugin, PluginContext};
11use crate::MAX_DIR_DEPTH;
12use std::{
13    collections::HashMap,
14    fs,
15    path::{Path, PathBuf},
16};
17
18/// Plugin that expands shortcodes in Markdown content.
19///
20/// Runs in `before_compile` to transform content before staticdatagen
21/// processes it.
22///
23/// Built-in shortcodes:
24/// - `{{< youtube id="..." >}}` — responsive `YouTube` embed
25/// - `{{< gist user="..." id="..." >}}` — GitHub gist embed
26/// - `{{< figure src="..." alt="..." caption="..." >}}` — figure with caption
27/// - `{{< warning >}}...{{< /warning >}}` — admonition blocks
28/// - `{{< info >}}...{{< /info >}}`
29/// - `{{< tip >}}...{{< /tip >}}`
30/// - `{{< danger >}}...{{< /danger >}}`
31#[derive(Debug, Clone, Copy)]
32pub struct ShortcodePlugin;
33
34impl Plugin for ShortcodePlugin {
35    fn name(&self) -> &'static str {
36        "shortcodes"
37    }
38
39    fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
40        if !ctx.content_dir.exists() {
41            return Ok(());
42        }
43
44        let md_files = collect_md_files(&ctx.content_dir)?;
45        let mut expanded = 0usize;
46
47        for path in &md_files {
48            let content = fs::read_to_string(path).with_path(path)?;
49            let result = expand_shortcodes(&content);
50            if result != content {
51                fs::write(path, &result).with_path(path)?;
52                expanded += 1;
53            }
54        }
55
56        if expanded > 0 {
57            log::info!(
58                "[shortcodes] Expanded shortcodes in {expanded} file(s)"
59            );
60        }
61        Ok(())
62    }
63}
64
65/// Expands all shortcodes in a string.
66///
67/// # Examples
68///
69/// ```rust
70/// use ssg::shortcodes::expand_shortcodes;
71///
72/// let out = expand_shortcodes("{{< warning >}}heads up{{< /warning >}}");
73/// assert!(out.contains("admonition"));
74/// ```
75#[must_use]
76pub fn expand_shortcodes(input: &str) -> String {
77    let mut result = input.to_string();
78
79    // Block shortcodes: {{< name >}}...{{< /name >}}
80    for name in &["warning", "info", "tip", "danger"] {
81        result = expand_block_shortcode(&result, name);
82    }
83
84    // Inline shortcodes: {{< name key="value" >}}
85    result = expand_inline_shortcodes(&result);
86
87    result
88}
89
90/// Expands block shortcodes like `{{< warning >}}...{{< /warning >}}`.
91fn expand_block_shortcode(input: &str, name: &str) -> String {
92    let open = format!("{{{{< {name} >}}}}");
93    let close = format!("{{{{< /{name} >}}}}");
94    let mut result = input.to_string();
95
96    while let Some(start) = result.find(&open) {
97        let after_open = start + open.len();
98        if let Some(end_offset) = result[after_open..].find(&close) {
99            let end = after_open + end_offset;
100            let inner = result[after_open..end].trim();
101            let html = format!(
102                "<div class=\"admonition admonition-{}\" role=\"note\">\n\
103                 <p class=\"admonition-title\">{}</p>\n\
104                 <div class=\"admonition-content\">\n{}\n</div>\n</div>",
105                name,
106                capitalize(name),
107                inner
108            );
109            result = format!(
110                "{}{}{}",
111                &result[..start],
112                html,
113                &result[end + close.len()..]
114            );
115        } else {
116            break;
117        }
118    }
119
120    result
121}
122
123/// Expands inline shortcodes like `{{< youtube id="..." >}}`.
124///
125/// Safe for non-ASCII input: byte-level slicing is guarded by
126/// `is_char_boundary` and fallthrough characters are iterated via
127/// `char_indices()` so multi-byte codepoints (emoji, `©`, etc.) are
128/// preserved verbatim rather than truncated mid-byte.
129fn expand_inline_shortcodes(input: &str) -> String {
130    let mut result = String::with_capacity(input.len());
131    let mut pos = 0;
132
133    while pos < input.len() {
134        // The opening marker "{{<" is pure ASCII so byte-level
135        // comparison is safe *as long as pos lands on a char
136        // boundary*. Guard with is_char_boundary to be explicit.
137        if input.is_char_boundary(pos)
138            && pos + 3 <= input.len()
139            && input.as_bytes()[pos] == b'{'
140            && input.as_bytes()[pos + 1] == b'{'
141            && input.as_bytes()[pos + 2] == b'<'
142        {
143            if let Some(end) = input[pos..].find(">}}") {
144                let tag = input[pos + 3..pos + end].trim();
145                let html = render_inline_shortcode(tag);
146                result.push_str(&html);
147                pos += end + 3;
148                continue;
149            }
150        }
151        // Fallthrough: push the next full codepoint, not just one
152        // byte — this handles multi-byte UTF-8 characters cleanly.
153        // The `chars().next()` is guaranteed to be `Some` because
154        // the loop guard `pos < input.len()` ensures the suffix is
155        // non-empty, so an `expect` here cannot panic.
156        #[allow(clippy::expect_used)]
157        let c = input[pos..]
158            .chars()
159            .next()
160            .expect("loop guard ensures pos < input.len()");
161        result.push(c);
162        pos += c.len_utf8();
163    }
164
165    result
166}
167
168/// Renders a single inline shortcode tag content.
169fn render_inline_shortcode(tag: &str) -> String {
170    let parts = parse_shortcode_attrs(tag);
171    let name = parts.get("_name").map_or("", String::as_str);
172
173    match name {
174        "youtube" => {
175            let id = parts.get("id").map_or("", String::as_str);
176            if id.is_empty() {
177                return "<!-- youtube: missing id -->".to_string();
178            }
179            format!(
180                "<div class=\"video-container\" style=\"position:relative;padding-bottom:56.25%;height:0;overflow:hidden\">\
181                 <iframe src=\"https://www.youtube-nocookie.com/embed/{id}\" \
182                 style=\"position:absolute;top:0;left:0;width:100%;height:100%\" \
183                 frameborder=\"0\" allowfullscreen loading=\"lazy\" \
184                 title=\"YouTube video\"></iframe></div>"
185            )
186        }
187        "gist" => {
188            let user = parts.get("user").map_or("", String::as_str);
189            let id = parts.get("id").map_or("", String::as_str);
190            if user.is_empty() || id.is_empty() {
191                return "<!-- gist: missing user or id -->".to_string();
192            }
193            format!(
194                "<script src=\"https://gist.github.com/{user}/{id}.js\"></script>"
195            )
196        }
197        "figure" => {
198            let src = parts.get("src").map_or("", String::as_str);
199            let alt = parts.get("alt").map_or("", String::as_str);
200            let caption = parts.get("caption").map_or("", String::as_str);
201            let mut html = format!(
202                "<figure><img src=\"{src}\" alt=\"{alt}\" loading=\"lazy\">"
203            );
204            if !caption.is_empty() {
205                html.push_str(&format!("<figcaption>{caption}</figcaption>"));
206            }
207            html.push_str("</figure>");
208            html
209        }
210        "island" => {
211            let component = parts.get("component").map_or("", String::as_str);
212            let hydrate =
213                parts.get("hydrate").map_or("visible", String::as_str);
214            let props = parts.get("props").map_or("{}", String::as_str);
215            if component.is_empty() {
216                return "<!-- island: missing component -->".to_string();
217            }
218            format!(
219                "<ssg-island component=\"{component}\" hydrate=\"{hydrate}\" props='{props}'>\
220                 <template shadowrootmode=\"open\"><slot></slot></template>\
221                 </ssg-island>"
222            )
223        }
224        _ => format!("<!-- unknown shortcode: {name} -->"),
225    }
226}
227
228/// Parses shortcode attributes: `name key="value" key2="value2"`
229fn parse_shortcode_attrs(tag: &str) -> HashMap<String, String> {
230    let mut attrs = HashMap::new();
231    let trimmed = tag.trim();
232
233    // First token is the shortcode name
234    let mut chars = trimmed.char_indices().peekable();
235    let mut name_end = 0;
236    while let Some(&(i, c)) = chars.peek() {
237        if c.is_whitespace() {
238            name_end = i;
239            break;
240        }
241        name_end = i + c.len_utf8();
242        let _ = chars.next();
243    }
244    let _ = attrs.insert("_name".to_string(), trimmed[..name_end].to_string());
245
246    // Parse key="value" pairs
247    let rest = &trimmed[name_end..];
248    let mut pos = 0;
249    while pos < rest.len() {
250        // Skip whitespace
251        while pos < rest.len() && rest.as_bytes()[pos].is_ascii_whitespace() {
252            pos += 1;
253        }
254        if pos >= rest.len() {
255            break;
256        }
257
258        // Find key
259        let key_start = pos;
260        while pos < rest.len() && rest.as_bytes()[pos] != b'=' {
261            pos += 1;
262        }
263        if pos >= rest.len() {
264            break;
265        }
266        let key = rest[key_start..pos].trim().to_string();
267        pos += 1; // skip =
268
269        // Find value (quoted)
270        if pos < rest.len() && rest.as_bytes()[pos] == b'"' {
271            pos += 1;
272            let val_start = pos;
273            while pos < rest.len() && rest.as_bytes()[pos] != b'"' {
274                pos += 1;
275            }
276            let val = rest[val_start..pos].to_string();
277            let _ = attrs.insert(key, val);
278            pos += 1; // skip closing "
279        }
280    }
281
282    attrs
283}
284
285fn capitalize(s: &str) -> String {
286    let mut c = s.chars();
287    match c.next() {
288        None => String::new(),
289        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
290    }
291}
292
293fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
294    crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_youtube_shortcode() {
303        let input = r#"Check this: {{< youtube id="abc123" >}}"#;
304        let result = expand_shortcodes(input);
305        assert!(result.contains("youtube-nocookie.com/embed/abc123"));
306        assert!(result.contains("video-container"));
307    }
308
309    #[test]
310    fn test_gist_shortcode() {
311        let input = r#"{{< gist user="octocat" id="12345" >}}"#;
312        let result = expand_shortcodes(input);
313        assert!(result.contains("gist.github.com/octocat/12345.js"));
314    }
315
316    #[test]
317    fn test_figure_shortcode() {
318        let input = r#"{{< figure src="/img/photo.jpg" alt="A photo" caption="My photo" >}}"#;
319        let result = expand_shortcodes(input);
320        assert!(result.contains("<figure>"));
321        assert!(result.contains("alt=\"A photo\""));
322        assert!(result.contains("<figcaption>My photo</figcaption>"));
323    }
324
325    #[test]
326    fn test_warning_block() {
327        let input = "{{< warning >}}\nBe careful!\n{{< /warning >}}";
328        let result = expand_shortcodes(input);
329        assert!(result.contains("admonition-warning"));
330        assert!(result.contains("Warning"));
331        assert!(result.contains("Be careful!"));
332    }
333
334    #[test]
335    fn test_info_block() {
336        let input = "{{< info >}}\nNote this.\n{{< /info >}}";
337        let result = expand_shortcodes(input);
338        assert!(result.contains("admonition-info"));
339        assert!(result.contains("Info"));
340    }
341
342    #[test]
343    fn test_unknown_shortcode() {
344        let input = r#"{{< unknown key="val" >}}"#;
345        let result = expand_shortcodes(input);
346        assert!(result.contains("<!-- unknown shortcode: unknown -->"));
347    }
348
349    #[test]
350    fn test_no_shortcodes() {
351        let input = "Regular markdown with no shortcodes.";
352        let result = expand_shortcodes(input);
353        assert_eq!(result, input);
354    }
355
356    #[test]
357    fn test_parse_attrs() {
358        let attrs = parse_shortcode_attrs(r#"youtube id="abc" "#);
359        assert_eq!(attrs.get("_name").unwrap(), "youtube");
360        assert_eq!(attrs.get("id").unwrap(), "abc");
361    }
362
363    // -------------------------------------------------------------------
364    // Plugin surface + missing-param branches
365    // -------------------------------------------------------------------
366
367    #[test]
368    fn name_returns_static_shortcodes_identifier() {
369        assert_eq!(ShortcodePlugin.name(), "shortcodes");
370    }
371
372    #[test]
373    fn shortcode_plugin_debug_output_names_the_type() {
374        // The derived `Debug` impl is never otherwise exercised (no
375        // test formats the plugin with `{:?}`), so it was showing up
376        // as an uncovered function/region.
377        assert_eq!(format!("{:?}", ShortcodePlugin), "ShortcodePlugin");
378    }
379
380    #[test]
381    fn before_compile_missing_content_dir_returns_ok() {
382        // Line 41: `!ctx.content_dir.exists()` early return.
383        let dir = tempfile::tempdir().unwrap();
384        let missing = dir.path().join("missing");
385        let ctx =
386            PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
387        ShortcodePlugin.before_compile(&ctx).unwrap();
388    }
389
390    #[test]
391    fn before_compile_no_markdown_files_is_noop() {
392        let dir = tempfile::tempdir().unwrap();
393        let content = dir.path().join("content");
394        fs::create_dir_all(&content).unwrap();
395        let ctx =
396            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
397        ShortcodePlugin.before_compile(&ctx).unwrap();
398    }
399
400    #[test]
401    fn before_compile_unchanged_file_is_not_rewritten() {
402        // Line 50 else branch: file doesn't contain any shortcodes,
403        // so result == content and the fs::write is skipped.
404        let dir = tempfile::tempdir().unwrap();
405        let content = dir.path().join("content");
406        fs::create_dir_all(&content).unwrap();
407        let body = "plain markdown no shortcodes";
408        fs::write(content.join("p.md"), body).unwrap();
409        let ctx =
410            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
411        ShortcodePlugin.before_compile(&ctx).unwrap();
412        assert_eq!(fs::read_to_string(content.join("p.md")).unwrap(), body);
413    }
414
415    // -------------------------------------------------------------------
416    // render_inline_shortcode — missing required-param branches
417    // -------------------------------------------------------------------
418
419    #[test]
420    fn render_inline_shortcode_youtube_missing_id_emits_comment() {
421        // Line 146: the `if id.is_empty()` branch.
422        let result = expand_shortcodes(r"{{< youtube >}}");
423        assert!(result.contains("<!-- youtube: missing id -->"));
424    }
425
426    #[test]
427    fn render_inline_shortcode_gist_missing_user_emits_comment() {
428        // Line 160: the `if user.is_empty() || id.is_empty()` branch.
429        let result = expand_shortcodes(r#"{{< gist id="123" >}}"#);
430        assert!(result.contains("<!-- gist: missing user or id -->"));
431    }
432
433    #[test]
434    fn render_inline_shortcode_gist_missing_id_emits_comment() {
435        let result = expand_shortcodes(r#"{{< gist user="octocat" >}}"#);
436        assert!(result.contains("<!-- gist: missing user or id -->"));
437    }
438
439    #[test]
440    fn render_inline_shortcode_figure_without_caption_omits_figcaption() {
441        // Line 173 else branch: no caption argument means the
442        // `<figcaption>` is NOT appended.
443        let result =
444            expand_shortcodes(r#"{{< figure src="/a.jpg" alt="A" >}}"#);
445        assert!(result.contains("<figure>"));
446        assert!(result.contains(r#"alt="A""#));
447        assert!(!result.contains("<figcaption>"));
448    }
449
450    #[test]
451    fn render_inline_shortcode_figure_with_caption_includes_figcaption() {
452        let result = expand_shortcodes(
453            r#"{{< figure src="/a.jpg" alt="A" caption="Hi" >}}"#,
454        );
455        assert!(result.contains("<figcaption>Hi</figcaption>"));
456    }
457
458    // -------------------------------------------------------------------
459    // expand_block_shortcode — unterminated block break
460    // -------------------------------------------------------------------
461
462    #[test]
463    fn expand_block_shortcode_unterminated_breaks_out_cleanly() {
464        // Line 107: the `break` branch when the closing tag is
465        // missing. `expand_block_shortcode` must return without
466        // looping forever; the inline-shortcode pass then
467        // subsequently processes the unterminated `{{< warning >}}`
468        // as an unknown inline tag. What matters for coverage is
469        // that the function terminates.
470        let input = "{{< warning >}}\nno closing tag\n";
471        let result = expand_block_shortcode(input, "warning");
472        // Since there's no close tag, the block expander leaves
473        // the input untouched.
474        assert_eq!(result, input);
475    }
476
477    // -------------------------------------------------------------------
478    // capitalize
479    // -------------------------------------------------------------------
480
481    #[test]
482    fn capitalize_empty_string_returns_empty() {
483        assert_eq!(capitalize(""), "");
484    }
485
486    #[test]
487    fn capitalize_single_word_uppercases_first_letter() {
488        assert_eq!(capitalize("warning"), "Warning");
489        assert_eq!(capitalize("info"), "Info");
490    }
491
492    // -------------------------------------------------------------------
493    // collect_md_files — depth guard + filter
494    // -------------------------------------------------------------------
495
496    #[test]
497    fn shortcodes_collect_md_files_respects_max_dir_depth() {
498        let dir = tempfile::tempdir().unwrap();
499        let mut current = dir.path().to_path_buf();
500        for i in 0..MAX_DIR_DEPTH + 2 {
501            current = current.join(format!("d{i}"));
502            fs::create_dir_all(&current).unwrap();
503            fs::write(current.join("p.md"), "").unwrap();
504        }
505        let files = collect_md_files(dir.path()).unwrap();
506        assert!(files.len() <= MAX_DIR_DEPTH + 1);
507    }
508
509    #[test]
510    fn parse_shortcode_attrs_trailing_whitespace_breaks_outer_loop() {
511        // Line 210: `if pos >= rest.len() { break }` after the
512        // whitespace-skip loop. Trigger by trailing whitespace
513        // immediately after the shortcode name (no key=value).
514        let attrs = parse_shortcode_attrs("name   ");
515        assert_eq!(attrs.get("_name").unwrap(), "name");
516        assert_eq!(attrs.len(), 1);
517    }
518
519    #[test]
520    fn parse_shortcode_attrs_with_value_then_trailing_whitespace() {
521        let attrs = parse_shortcode_attrs("youtube id=\"x\"   ");
522        assert_eq!(attrs.get("_name").unwrap(), "youtube");
523        assert_eq!(attrs.get("id").unwrap(), "x");
524    }
525
526    #[test]
527    fn parse_shortcode_attrs_key_without_equals_breaks() {
528        // Line 219: `if pos >= rest.len() { break }` when searching
529        // for `=` falls off the end of the string.
530        let attrs = parse_shortcode_attrs("youtube id=\"x\" trailingflag");
531        assert_eq!(attrs.get("_name").unwrap(), "youtube");
532        // The trailing token without `=` is silently dropped.
533        assert!(!attrs.contains_key("trailingflag"));
534    }
535
536    #[test]
537    fn parse_shortcode_attrs_unquoted_value_is_dropped() {
538        // Line 234: the `if rest.as_bytes()[pos] == b'"'` is FALSE
539        // for unquoted values, so the body is skipped.
540        let attrs = parse_shortcode_attrs("name id=unquoted");
541        assert_eq!(attrs.get("_name").unwrap(), "name");
542        assert!(!attrs.contains_key("id"));
543    }
544
545    #[test]
546    fn expand_inline_shortcodes_unterminated_tag_falls_through_to_pushchar() {
547        // Line 128 path: when `{{<` is found but no `>}}` follows,
548        // the if-let returns None and the byte-by-byte fallback at
549        // line 130 takes over.
550        let result = expand_shortcodes("text {{< unterminated");
551        assert!(result.contains("text"));
552        assert!(result.contains("unterminated"));
553    }
554
555    #[test]
556    fn shortcodes_collect_md_files_filters_non_md_extensions() {
557        let dir = tempfile::tempdir().unwrap();
558        fs::write(dir.path().join("a.md"), "").unwrap();
559        fs::write(dir.path().join("b.txt"), "").unwrap();
560        let files = collect_md_files(dir.path()).unwrap();
561        assert_eq!(files.len(), 1);
562    }
563
564    #[test]
565    fn test_plugin_expands_files() {
566        let dir = tempfile::tempdir().unwrap();
567        let content = dir.path().join("content");
568        fs::create_dir_all(&content).unwrap();
569        fs::write(
570            content.join("test.md"),
571            r#"---
572title: Test
573---
574{{< youtube id="xyz" >}}
575"#,
576        )
577        .unwrap();
578
579        let ctx =
580            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
581        ShortcodePlugin.before_compile(&ctx).unwrap();
582
583        let result = fs::read_to_string(content.join("test.md")).unwrap();
584        assert!(result.contains("youtube-nocookie.com"));
585    }
586
587    #[test]
588    fn render_inline_shortcode_island_missing_component_emits_comment() {
589        // The `if component.is_empty()` early return of the island arm.
590        let result = expand_shortcodes(r"{{< island >}}");
591        assert!(result.contains("<!-- island: missing component -->"));
592    }
593
594    #[test]
595    #[cfg(unix)]
596    fn before_compile_propagates_walk_error_from_unreadable_subdir() {
597        use std::os::unix::fs::PermissionsExt;
598
599        let dir = tempfile::tempdir().unwrap();
600        let content = dir.path().join("content");
601        let locked = content.join("locked");
602        fs::create_dir_all(&locked).unwrap();
603        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
604            .unwrap();
605
606        let ctx =
607            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
608        let result = ShortcodePlugin.before_compile(&ctx);
609        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
610            .unwrap();
611        assert!(result.is_err(), "unreadable subdir must surface as Err");
612    }
613
614    #[test]
615    #[cfg(unix)]
616    fn before_compile_write_failure_on_readonly_file_is_propagated() {
617        use std::os::unix::fs::PermissionsExt;
618
619        let dir = tempfile::tempdir().unwrap();
620        let content = dir.path().join("content");
621        fs::create_dir_all(&content).unwrap();
622        let file = content.join("locked.md");
623        fs::write(&file, r#"{{< youtube id="abc" >}}"#).unwrap();
624        // Read works, the write-back of the expanded content does not.
625        fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
626
627        let ctx =
628            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
629        let result = ShortcodePlugin.before_compile(&ctx);
630        fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
631        assert!(result.is_err(), "read-only file write must surface as Err");
632    }
633
634    #[test]
635    fn before_compile_read_failure_returns_io_error() {
636        let dir = tempfile::tempdir().unwrap();
637        let content = dir.path().join("content");
638        fs::create_dir_all(&content).unwrap();
639
640        // Create a file with invalid UTF-8 to make read_to_string fail.
641        let file_path = content.join("fail.md");
642        fs::write(&file_path, [0xFF, 0xFE, 0xFD]).unwrap();
643
644        let ctx =
645            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
646        let res = ShortcodePlugin.before_compile(&ctx);
647        assert!(res.is_err());
648        let err = res.unwrap_err();
649        assert!(
650            matches!(err, SsgError::Io { ref path, .. } if path == &file_path)
651        );
652    }
653}
654
655#[cfg(test)]
656mod proptests {
657    use super::*;
658    use proptest::prelude::*;
659
660    proptest! {
661        #![proptest_config(ProptestConfig::with_cases(1000))]
662
663        /// `expand_shortcodes` must never panic on arbitrary input.
664        #[test]
665        fn expand_never_panics(input in "\\PC*") {
666            let _ = expand_shortcodes(&input);
667        }
668
669        /// Strings without `{{<` must pass through unchanged.
670        #[test]
671        fn no_shortcode_identity(input in "[^{]*") {
672            let output = expand_shortcodes(&input);
673            prop_assert_eq!(&output, &input);
674        }
675    }
676}