Skip to main content

rumdl_lib/config/
flavor.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5/// Canonical values shown in user-facing configuration diagnostics.
6pub(crate) const CANONICAL_MARKDOWN_FLAVORS: &str =
7    "standard, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg, gh-aw";
8
9// ============================================================================
10// Typestate markers for configuration pipeline
11// ============================================================================
12
13/// Marker type for configuration that has been loaded but not yet validated.
14/// This is the initial state after `load_with_discovery()`.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct ConfigLoaded;
17
18/// Marker type for configuration that has been validated.
19/// Only validated configs can be converted to `Config`.
20#[derive(Debug, Clone, Copy, Default)]
21pub struct ConfigValidated;
22
23/// Markdown flavor/dialect enumeration
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum MarkdownFlavor {
27    /// Standard Markdown without flavor-specific adjustments
28    #[serde(rename = "standard", alias = "none", alias = "")]
29    #[default]
30    Standard,
31    /// MkDocs flavor with auto-reference support
32    #[serde(rename = "mkdocs")]
33    MkDocs,
34    /// MDX flavor with JSX and ESM support (.mdx files)
35    #[serde(rename = "mdx")]
36    MDX,
37    /// Pandoc Markdown — fenced divs, attribute lists, citations, definition
38    /// lists, math, and other Pandoc-specific syntax.
39    #[serde(rename = "pandoc")]
40    Pandoc,
41    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
42    #[serde(rename = "quarto")]
43    Quarto,
44    /// Obsidian flavor with tag syntax support (#tagname as tags, not headings)
45    #[serde(rename = "obsidian")]
46    Obsidian,
47    /// Kramdown flavor for Jekyll sites with IAL, ALD, and extension block support
48    #[serde(rename = "kramdown")]
49    Kramdown,
50    /// Azure DevOps flavor — treats `:::lang` blocks as opaque code fences
51    #[serde(rename = "azure_devops", alias = "azure", alias = "ado")]
52    AzureDevOps,
53    /// MyST (Markedly Structured Text) flavor — directives, roles, dollar math, % comments
54    #[serde(rename = "myst", alias = "mystmd")]
55    MyST,
56    /// Hugo flavor — GFM plus Goldmark block attribute lists (`{class="a" id="b"}`)
57    #[serde(rename = "hugo", alias = "goldmark")]
58    Hugo,
59    /// Markdown with Gherkin (MDG) flavor for executable specifications (`.feature.md` files)
60    #[serde(rename = "mdg", alias = "markdown_with_gherkin")]
61    MDG,
62    /// GitHub Agentic Workflows — YAML frontmatter and Handlebars-style control directives
63    #[serde(rename = "gh-aw")]
64    GhAw,
65}
66
67/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
68fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
69    schemars::json_schema!({
70        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg, gh-aw (preview). Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown, azure/ado map to azure_devops, mystmd maps to myst, goldmark maps to hugo, markdown_with_gherkin maps to mdg.",
71        "type": "string",
72        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd", "hugo", "goldmark", "mdg", "markdown_with_gherkin", "gh-aw"]
73    })
74}
75
76impl schemars::JsonSchema for MarkdownFlavor {
77    fn schema_name() -> std::borrow::Cow<'static, str> {
78        std::borrow::Cow::Borrowed("MarkdownFlavor")
79    }
80
81    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
82        markdown_flavor_schema(generator)
83    }
84}
85
86impl fmt::Display for MarkdownFlavor {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            MarkdownFlavor::Standard => write!(f, "standard"),
90            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
91            MarkdownFlavor::MDX => write!(f, "mdx"),
92            MarkdownFlavor::Pandoc => write!(f, "pandoc"),
93            MarkdownFlavor::Quarto => write!(f, "quarto"),
94            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
95            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
96            MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
97            MarkdownFlavor::MyST => write!(f, "myst"),
98            MarkdownFlavor::Hugo => write!(f, "hugo"),
99            MarkdownFlavor::MDG => write!(f, "mdg"),
100            MarkdownFlavor::GhAw => write!(f, "gh-aw"),
101        }
102    }
103}
104
105impl FromStr for MarkdownFlavor {
106    type Err = String;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        match s.to_lowercase().as_str() {
110            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
111            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
112            "mdx" => Ok(MarkdownFlavor::MDX),
113            "pandoc" => Ok(MarkdownFlavor::Pandoc),
114            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
115            "obsidian" => Ok(MarkdownFlavor::Obsidian),
116            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
117            "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
118            "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
119            "hugo" | "goldmark" => Ok(MarkdownFlavor::Hugo),
120            // GFM and CommonMark are aliases for Standard since the base parser
121            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
122            // strikethrough, autolinks, etc.) which are a superset of CommonMark
123            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
124            "mdg" | "markdown_with_gherkin" => Ok(MarkdownFlavor::MDG),
125            "gh-aw" => Ok(MarkdownFlavor::GhAw),
126            _ => Err(format!("Unknown markdown flavor: {s}")),
127        }
128    }
129}
130
131impl MarkdownFlavor {
132    /// Detect flavor from file extension
133    pub fn from_extension(ext: &str) -> Self {
134        match ext.to_lowercase().as_str() {
135            "mdx" => Self::MDX,
136            "qmd" => Self::Quarto,
137            "rmd" => Self::Quarto,
138            "kramdown" => Self::Kramdown,
139            _ => Self::Standard,
140        }
141    }
142
143    /// Detect flavor from file path
144    pub fn from_path(path: &std::path::Path) -> Self {
145        if path
146            .file_name()
147            .and_then(|name| name.to_str())
148            .is_some_and(|name| name.to_ascii_lowercase().ends_with(".feature.md"))
149        {
150            return Self::MDG;
151        }
152
153        path.extension()
154            .and_then(|e| e.to_str())
155            .map_or(Self::Standard, Self::from_extension)
156    }
157
158    /// Check if this flavor supports ESM imports/exports (MDX-specific)
159    pub fn supports_esm_blocks(self) -> bool {
160        matches!(self, Self::MDX)
161    }
162
163    /// Check if this flavor supports JSX components (MDX-specific)
164    pub fn supports_jsx(self) -> bool {
165        matches!(self, Self::MDX)
166    }
167
168    /// Check if this flavor supports auto-references (MkDocs-specific)
169    pub fn supports_auto_references(self) -> bool {
170        matches!(self, Self::MkDocs)
171    }
172
173    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
174    pub fn supports_kramdown_syntax(self) -> bool {
175        matches!(self, Self::Kramdown)
176    }
177
178    /// Check if this flavor supports attribute lists ({#id .class key="value"})
179    pub fn supports_attr_lists(self) -> bool {
180        matches!(self, Self::MkDocs | Self::Kramdown | Self::Hugo)
181    }
182
183    /// Check if this flavor requires strict (≥4-space) list continuation indent.
184    ///
185    /// Python-Markdown (used by MkDocs) requires 4-space indentation for ordered
186    /// list continuation content, regardless of marker width.
187    pub fn requires_strict_list_indent(self) -> bool {
188        matches!(self, Self::MkDocs)
189    }
190
191    /// True for any flavor that includes Pandoc-style syntax — fenced divs,
192    /// attribute lists, citations, definition lists, math, raw blocks.
193    /// Use this to gate behavior shared by both Pandoc and Quarto users.
194    pub fn is_pandoc_compatible(self) -> bool {
195        matches!(self, Self::Pandoc | Self::Quarto)
196    }
197
198    /// Get a human-readable name for this flavor
199    pub fn name(self) -> &'static str {
200        match self {
201            Self::Standard => "Standard",
202            Self::MkDocs => "MkDocs",
203            Self::MDX => "MDX",
204            Self::Pandoc => "Pandoc",
205            Self::Quarto => "Quarto",
206            Self::Obsidian => "Obsidian",
207            Self::Kramdown => "Kramdown",
208            Self::AzureDevOps => "AzureDevOps",
209            Self::MyST => "MyST",
210            Self::Hugo => "Hugo",
211            Self::MDG => "Markdown with Gherkin",
212            Self::GhAw => "GitHub Agentic Workflows",
213        }
214    }
215
216    /// True only for Azure DevOps flavor, which uses `:::lang` as a code fence.
217    pub fn supports_colon_code_fences(self) -> bool {
218        matches!(self, Self::AzureDevOps)
219    }
220
221    /// True for MyST flavor — supports directive syntax (backtick and colon fences with `{name}`)
222    pub fn supports_myst_directives(self) -> bool {
223        matches!(self, Self::MyST)
224    }
225
226    /// True for MyST flavor — supports role syntax (`{role}`content``)
227    pub fn supports_myst_roles(self) -> bool {
228        matches!(self, Self::MyST)
229    }
230
231    /// True for MyST flavor — supports `%` line comments
232    pub fn supports_myst_comments(self) -> bool {
233        matches!(self, Self::MyST)
234    }
235}
236
237/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
238pub fn normalize_key(key: &str) -> String {
239    // If the key looks like a rule name (e.g., MD013), uppercase it
240    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
241        key.to_ascii_uppercase()
242    } else {
243        key.replace('_', "-").to_ascii_lowercase()
244    }
245}
246
247/// Warns if a per-file-ignores pattern contains a comma but no braces.
248/// This is a common mistake where users expect "A.md,B.md" to match both files,
249/// but glob syntax requires "{A.md,B.md}" for brace expansion.
250///
251/// The pattern is a key out of the config file, so for one reached through
252/// `extends` it is named rather than shown, and the suggestion that would have
253/// rewritten it becomes a description of the rewrite.
254pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: super::types::ConfigRef<'_>) {
255    if pattern.contains(',') && !pattern.contains('{') {
256        if config_file.may_quote_contents() {
257            eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
258            eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
259        } else {
260            eprintln!("Warning: A pattern in {config_file} contains a comma but no braces.");
261            eprintln!("  To match multiple files, wrap the pattern in braces for brace expansion.");
262        }
263        eprintln!("  Or use separate entries for each file.");
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// Every MarkdownFlavor variant must produce a lowercase, unquoted string via Display.
272    /// This guards against new variants being added without a matching Display arm,
273    /// and against the Display impl regressing to Debug-style output (e.g. "Standard").
274    #[test]
275    fn test_display_all_variants_are_lowercase() {
276        let cases = [
277            (MarkdownFlavor::Standard, "standard"),
278            (MarkdownFlavor::MkDocs, "mkdocs"),
279            (MarkdownFlavor::MDX, "mdx"),
280            (MarkdownFlavor::Pandoc, "pandoc"),
281            (MarkdownFlavor::Quarto, "quarto"),
282            (MarkdownFlavor::Obsidian, "obsidian"),
283            (MarkdownFlavor::Kramdown, "kramdown"),
284            (MarkdownFlavor::AzureDevOps, "azure_devops"),
285            (MarkdownFlavor::MyST, "myst"),
286            (MarkdownFlavor::Hugo, "hugo"),
287            (MarkdownFlavor::MDG, "mdg"),
288            (MarkdownFlavor::GhAw, "gh-aw"),
289        ];
290        assert_eq!(
291            cases.iter().map(|(_, name)| *name).collect::<Vec<_>>().join(", "),
292            CANONICAL_MARKDOWN_FLAVORS,
293            "user-facing canonical flavor diagnostics drifted from the enum matrix"
294        );
295        for (variant, expected) in cases {
296            let displayed = variant.to_string();
297            assert_eq!(
298                displayed, expected,
299                "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
300            );
301            // Must be lowercase — no uppercase letters anywhere
302            assert!(
303                displayed.chars().all(|c| !c.is_ascii_uppercase()),
304                "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
305            );
306        }
307    }
308
309    /// Display output must round-trip through FromStr — every variant's Display string
310    /// must parse back to the same variant.
311    #[test]
312    fn test_display_round_trips_through_from_str() {
313        let variants = [
314            MarkdownFlavor::Standard,
315            MarkdownFlavor::MkDocs,
316            MarkdownFlavor::MDX,
317            MarkdownFlavor::Pandoc,
318            MarkdownFlavor::Quarto,
319            MarkdownFlavor::Obsidian,
320            MarkdownFlavor::Kramdown,
321            MarkdownFlavor::AzureDevOps,
322            MarkdownFlavor::MyST,
323            MarkdownFlavor::Hugo,
324            MarkdownFlavor::MDG,
325            MarkdownFlavor::GhAw,
326        ];
327        for variant in variants {
328            let displayed = variant.to_string();
329            let parsed: MarkdownFlavor = displayed
330                .parse()
331                .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
332            assert_eq!(
333                parsed, variant,
334                "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
335            );
336        }
337    }
338
339    #[test]
340    fn test_pandoc_from_str() {
341        assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
342        assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
343    }
344
345    #[test]
346    fn test_pandoc_name_and_display() {
347        assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
348        assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
349    }
350
351    #[test]
352    fn test_from_extension_does_not_auto_detect_pandoc() {
353        // Pandoc files use .md — must NOT auto-detect to Pandoc.
354        assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
355        assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
356    }
357
358    #[test]
359    fn test_from_path_does_not_auto_detect_gh_aw() {
360        use std::path::Path;
361
362        assert_eq!(
363            MarkdownFlavor::from_path(Path::new(".github/workflows/triage.md")),
364            MarkdownFlavor::Standard
365        );
366    }
367
368    #[test]
369    fn test_gh_aw_name_and_serde() {
370        assert_eq!("GH-AW".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::GhAw);
371        assert_eq!(MarkdownFlavor::GhAw.name(), "GitHub Agentic Workflows");
372        assert_eq!(
373            toml::Value::try_from(MarkdownFlavor::GhAw).unwrap().as_str(),
374            Some("gh-aw")
375        );
376    }
377
378    #[test]
379    fn test_mdg_aliases_and_name() {
380        assert_eq!("MDG".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MDG);
381        assert_eq!(
382            "markdown_with_gherkin".parse::<MarkdownFlavor>().unwrap(),
383            MarkdownFlavor::MDG
384        );
385        assert_eq!(MarkdownFlavor::MDG.name(), "Markdown with Gherkin");
386    }
387
388    #[test]
389    fn test_mdg_serde_names() {
390        assert_eq!(
391            MarkdownFlavor::deserialize(toml::Value::String("markdown_with_gherkin".to_string())).unwrap(),
392            MarkdownFlavor::MDG
393        );
394        assert_eq!(
395            toml::Value::try_from(MarkdownFlavor::MDG).unwrap().as_str(),
396            Some("mdg")
397        );
398    }
399
400    #[test]
401    fn test_from_path_detects_feature_md_compound_suffix() {
402        use std::path::Path;
403
404        assert_eq!(
405            MarkdownFlavor::from_path(Path::new("features/login.feature.md")),
406            MarkdownFlavor::MDG
407        );
408        assert_eq!(
409            MarkdownFlavor::from_path(Path::new("features/login.FEATURE.MD")),
410            MarkdownFlavor::MDG
411        );
412        assert_eq!(
413            MarkdownFlavor::from_path(Path::new("README.md")),
414            MarkdownFlavor::Standard
415        );
416        assert_eq!(
417            MarkdownFlavor::from_path(Path::new("features/login.feature.markdown")),
418            MarkdownFlavor::Standard
419        );
420    }
421
422    #[test]
423    fn test_is_pandoc_compatible() {
424        assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
425        assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
426
427        assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
428        assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
429        assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
430        assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
431        assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
432    }
433
434    #[test]
435    fn test_azure_devops_from_str() {
436        assert_eq!(
437            "azure_devops".parse::<MarkdownFlavor>().unwrap(),
438            MarkdownFlavor::AzureDevOps
439        );
440        assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
441        assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
442        assert_eq!(
443            "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
444            MarkdownFlavor::AzureDevOps
445        );
446    }
447
448    #[test]
449    fn test_azure_devops_display_and_round_trip() {
450        assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
451        let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
452        assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
453    }
454
455    #[test]
456    fn test_supports_colon_code_fences() {
457        assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
458        assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
459        assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
460        assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
461        assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
462        assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
463        assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
464        assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
465        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
466    }
467
468    #[test]
469    fn test_azure_devops_not_pandoc_compatible() {
470        assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
471    }
472
473    #[test]
474    fn test_display_all_variants_covers_azure_devops() {
475        let displayed = MarkdownFlavor::AzureDevOps.to_string();
476        assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
477    }
478
479    #[test]
480    fn test_myst_from_str() {
481        assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
482        assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
483        assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
484    }
485
486    #[test]
487    fn test_myst_display_and_round_trip() {
488        assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
489        let parsed: MarkdownFlavor = "myst".parse().unwrap();
490        assert_eq!(parsed, MarkdownFlavor::MyST);
491    }
492
493    #[test]
494    fn test_myst_capabilities() {
495        assert!(MarkdownFlavor::MyST.supports_myst_directives());
496        assert!(MarkdownFlavor::MyST.supports_myst_roles());
497        assert!(MarkdownFlavor::MyST.supports_myst_comments());
498        assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
499        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
500        assert!(!MarkdownFlavor::MyST.supports_jsx());
501
502        assert!(!MarkdownFlavor::Standard.supports_myst_directives());
503        assert!(!MarkdownFlavor::Standard.supports_myst_roles());
504        assert!(!MarkdownFlavor::Standard.supports_myst_comments());
505    }
506
507    #[test]
508    fn test_myst_name() {
509        assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
510    }
511
512    #[test]
513    fn test_hugo_from_str() {
514        assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
515        assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
516        assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
517    }
518
519    #[test]
520    fn test_hugo_display_name_and_round_trip() {
521        assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
522        assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
523        let parsed: MarkdownFlavor = "hugo".parse().unwrap();
524        assert_eq!(parsed, MarkdownFlavor::Hugo);
525    }
526
527    #[test]
528    fn test_hugo_supports_attr_lists() {
529        // Hugo/Goldmark supports block attribute lists like MkDocs and Kramdown.
530        assert!(MarkdownFlavor::Hugo.supports_attr_lists());
531        assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
532        assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
533
534        // Standard Markdown does not: there, `{class="a"}` is literal text.
535        assert!(!MarkdownFlavor::Standard.supports_attr_lists());
536        assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
537
538        // Hugo is otherwise plain GFM.
539        assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
540        assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
541        assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
542    }
543}