this repo has no description
1mod printer;
2mod source_links;
3#[cfg(test)]
4mod tests;
5
6use std::{collections::HashMap, time::SystemTime};
7
8use camino::Utf8PathBuf;
9use hexpm::version::Version;
10use printer::Printer;
11
12use crate::{
13 build::{Module, Package},
14 config::{DocsPage, PackageConfig},
15 docs::source_links::SourceLinker,
16 io::{Content, FileSystemReader, OutputFile},
17 package_interface::PackageInterface,
18 paths::ProjectPaths,
19 type_::{self},
20 version::COMPILER_VERSION,
21};
22use askama::Template;
23use ecow::EcoString;
24use itertools::Itertools;
25use serde::{Deserialize, Serialize};
26use serde_json::to_string as serde_to_string;
27
28#[derive(PartialEq, Eq, Copy, Clone, Debug)]
29pub enum DocContext {
30 HexPublish,
31 Build,
32}
33
34#[derive(PartialEq, Debug, Serialize, Deserialize)]
35pub struct PackageInformation {
36 #[serde(rename = "gleam.toml")]
37 package_config: PackageConfig,
38}
39
40/// Like `ManifestPackage`, but lighter and cheaper to clone as it is all that
41/// we need for printing documentation.
42#[derive(Debug, Clone)]
43pub struct Dependency {
44 pub version: Version,
45 pub kind: DependencyKind,
46}
47
48#[derive(Debug, Clone, Copy)]
49pub enum DependencyKind {
50 Hex,
51 Path,
52 Git,
53}
54
55#[derive(Debug)]
56pub struct DocumentationConfig<'a> {
57 pub package_config: &'a PackageConfig,
58 pub dependencies: HashMap<EcoString, Dependency>,
59 pub analysed: &'a [Module],
60 pub docs_pages: &'a [DocsPage],
61 pub rendering_timestamp: SystemTime,
62 pub context: DocContext,
63}
64
65pub fn generate_html<IO: FileSystemReader>(
66 paths: &ProjectPaths,
67 config: DocumentationConfig<'_>,
68 fs: IO,
69) -> Vec<OutputFile> {
70 let DocumentationConfig {
71 package_config: config,
72 dependencies,
73 analysed,
74 docs_pages,
75 rendering_timestamp,
76 context: is_hex_publish,
77 } = config;
78
79 let modules = analysed
80 .iter()
81 .filter(|module| module.origin.is_src())
82 .filter(|module| !config.is_internal_module(&module.name));
83
84 let rendering_timestamp = rendering_timestamp
85 .duration_since(SystemTime::UNIX_EPOCH)
86 .expect("get current timestamp")
87 .as_secs()
88 .to_string();
89
90 // Define user-supplied (or README) pages
91 let pages: Vec<_> = docs_pages
92 .iter()
93 .map(|page| Link {
94 name: page.title.to_string(),
95 path: page.path.to_string(),
96 })
97 .collect();
98
99 let doc_links = config.links.iter().map(|doc_link| Link {
100 name: doc_link.title.to_string(),
101 path: doc_link.href.to_string(),
102 });
103
104 let repo_link = config
105 .repository
106 .as_ref()
107 .map(|r| r.url())
108 .map(|path| Link {
109 name: "Repository".into(),
110 path,
111 });
112
113 let host = if is_hex_publish == DocContext::HexPublish {
114 "https://hexdocs.pm"
115 } else {
116 ""
117 };
118
119 // https://github.com/gleam-lang/gleam/issues/3020
120 let links: Vec<_> = match is_hex_publish {
121 DocContext::HexPublish => doc_links
122 .chain(repo_link)
123 .chain([Link {
124 name: "Hex".into(),
125 path: format!("https://hex.pm/packages/{0}", config.name).to_string(),
126 }])
127 .collect(),
128 DocContext::Build => doc_links.chain(repo_link).collect(),
129 };
130
131 let mut files = vec![];
132
133 let mut search_items = vec![];
134
135 let modules_links: Vec<_> = modules
136 .clone()
137 .map(|m| {
138 let path = [&m.name, ".html"].concat();
139 Link {
140 path,
141 name: m.name.split('/').join("<wbr />/"),
142 }
143 })
144 .sorted()
145 .collect();
146
147 // Generate user-supplied (or README) pages
148 for page in docs_pages {
149 let content = fs.read(&page.source).unwrap_or_default();
150 let rendered_content = render_markdown(&content, MarkdownSource::Standalone);
151 let unnest = page_unnest(&page.path);
152
153 let page_path_without_ext = page.path.split('.').next().unwrap_or("");
154 let page_title = match page_path_without_ext {
155 // The index page, such as README, should not push it's page title
156 "index" => format!("{} · v{}", config.name, config.version),
157 // Other page title's should say so
158 _other => format!("{} · {} · v{}", page.title, config.name, config.version),
159 };
160 let page_meta_description = match page_path_without_ext {
161 "index" => config.description.to_string().clone(),
162 _other => "".to_owned(),
163 };
164 let path = Utf8PathBuf::from(&page.path);
165
166 let temp = PageTemplate {
167 gleam_version: COMPILER_VERSION,
168 links: &links,
169 pages: &pages,
170 modules: &modules_links,
171 project_name: &config.name,
172 page_title: &page_title,
173 page_meta_description: &page_meta_description,
174 file_path: &path.clone(),
175 project_version: &config.version.to_string(),
176 content: rendered_content,
177 rendering_timestamp: &rendering_timestamp,
178 host,
179 unnest: &unnest,
180 };
181
182 files.push(OutputFile {
183 path,
184 content: Content::Text(temp.render().expect("Page template rendering")),
185 });
186
187 search_items.push(SearchItem {
188 type_: SearchItemType::Page,
189 parent_title: config.name.to_string(),
190 title: config.name.to_string(),
191 content: escape_html_content(content),
192 reference: page.path.to_string(),
193 })
194 }
195
196 // Generate module documentation pages
197 for module in modules {
198 let name = module.name.clone();
199 let unnest = page_unnest(&module.name);
200
201 // Read module src & create line number lookup structure
202 let source_links = SourceLinker::new(paths, config, module);
203
204 let documentation_content = module.ast.documentation.iter().join("\n");
205 let rendered_documentation =
206 render_markdown(&documentation_content.clone(), MarkdownSource::Comment);
207
208 let mut printer = Printer::new(
209 module.ast.type_info.package.clone(),
210 module.name.clone(),
211 &module.ast.names,
212 &dependencies,
213 );
214
215 let types: Vec<TypeDefinition<'_>> = module
216 .ast
217 .definitions
218 .iter()
219 .filter_map(|definition| printer.type_definition(&source_links, definition))
220 .sorted()
221 .collect();
222
223 let values: Vec<DocsValues<'_>> = module
224 .ast
225 .definitions
226 .iter()
227 .filter_map(|definition| printer.value(&source_links, definition))
228 .sorted()
229 .collect();
230
231 types.iter().for_each(|type_| {
232 let constructors = type_
233 .constructors
234 .iter()
235 .map(|constructor| {
236 let arguments = constructor
237 .arguments
238 .iter()
239 .map(|argument| format!("{}\n{}", argument.name, argument.doc))
240 .join("\n");
241
242 format!(
243 "{}\n{}\n{}",
244 constructor.definition, constructor.text_documentation, arguments
245 )
246 })
247 .join("\n");
248
249 search_items.push(SearchItem {
250 type_: SearchItemType::Type,
251 parent_title: module.name.to_string(),
252 title: type_.name.to_string(),
253 content: format!(
254 "{}\n{}\n{}\n{}",
255 type_.definition,
256 type_.text_documentation,
257 constructors,
258 import_synonyms(&module.name, type_.name)
259 ),
260 reference: format!("{}.html#{}", module.name, type_.name),
261 })
262 });
263 values.iter().for_each(|constant| {
264 search_items.push(SearchItem {
265 type_: SearchItemType::Value,
266 parent_title: module.name.to_string(),
267 title: constant.name.to_string(),
268 content: format!(
269 "{}\n{}\n{}",
270 constant.definition,
271 constant.text_documentation,
272 import_synonyms(&module.name, constant.name)
273 ),
274 reference: format!("{}.html#{}", module.name, constant.name),
275 })
276 });
277
278 search_items.push(SearchItem {
279 type_: SearchItemType::Module,
280 parent_title: module.name.to_string(),
281 title: module.name.to_string(),
282 content: documentation_content,
283 reference: format!("{}.html", module.name),
284 });
285
286 let page_title = format!("{} · {} · v{}", name, config.name, config.version);
287 let page_meta_description = "";
288 let path = Utf8PathBuf::from(format!("{}.html", module.name));
289
290 let template = ModuleTemplate {
291 gleam_version: COMPILER_VERSION,
292 host,
293 unnest,
294 links: &links,
295 pages: &pages,
296 documentation: rendered_documentation,
297 modules: &modules_links,
298 project_name: &config.name,
299 page_title: &page_title,
300 page_meta_description,
301 module_name: EcoString::from(&name),
302 file_path: &path.clone(),
303 project_version: &config.version.to_string(),
304 types,
305 values,
306 rendering_timestamp: &rendering_timestamp,
307 };
308
309 files.push(OutputFile {
310 path,
311 content: Content::Text(
312 template
313 .render()
314 .expect("Module documentation template rendering"),
315 ),
316 });
317 }
318
319 // Render static assets
320
321 files.push(OutputFile {
322 path: Utf8PathBuf::from("css/atom-one-light.min.css"),
323 content: Content::Text(
324 std::include_str!("../templates/docs-css/atom-one-light.min.css").to_string(),
325 ),
326 });
327
328 files.push(OutputFile {
329 path: Utf8PathBuf::from("css/atom-one-dark.min.css"),
330 content: Content::Text(
331 std::include_str!("../templates/docs-css/atom-one-dark.min.css").to_string(),
332 ),
333 });
334
335 files.push(OutputFile {
336 path: Utf8PathBuf::from("css/index.css"),
337 content: Content::Text(std::include_str!("../templates/docs-css/index.css").to_string()),
338 });
339
340 // highlightjs:
341
342 files.push(OutputFile {
343 path: Utf8PathBuf::from("js/highlight.min.js"),
344 content: Content::Text(
345 std::include_str!("../templates/docs-js/highlight.min.js").to_string(),
346 ),
347 });
348
349 files.push(OutputFile {
350 path: Utf8PathBuf::from("js/highlightjs-gleam.js"),
351 content: Content::Text(
352 std::include_str!("../templates/docs-js/highlightjs-gleam.js").to_string(),
353 ),
354 });
355
356 files.push(OutputFile {
357 path: Utf8PathBuf::from("js/highlightjs-erlang.min.js"),
358 content: Content::Text(
359 std::include_str!("../templates/docs-js/highlightjs-erlang.min.js").to_string(),
360 ),
361 });
362
363 files.push(OutputFile {
364 path: Utf8PathBuf::from("js/highlightjs-elixir.min.js"),
365 content: Content::Text(
366 std::include_str!("../templates/docs-js/highlightjs-elixir.min.js").to_string(),
367 ),
368 });
369
370 files.push(OutputFile {
371 path: Utf8PathBuf::from("js/highlightjs-javascript.min.js"),
372 content: Content::Text(
373 std::include_str!("../templates/docs-js/highlightjs-javascript.min.js").to_string(),
374 ),
375 });
376
377 files.push(OutputFile {
378 path: Utf8PathBuf::from("js/highlightjs-typescript.min.js"),
379 content: Content::Text(
380 std::include_str!("../templates/docs-js/highlightjs-typescript.min.js").to_string(),
381 ),
382 });
383
384 // lunr.min.js, search_data.json and index.js
385
386 files.push(OutputFile {
387 path: Utf8PathBuf::from("js/lunr.min.js"),
388 content: Content::Text(std::include_str!("../templates/docs-js/lunr.min.js").to_string()),
389 });
390
391 let search_data_json = serde_to_string(&SearchData {
392 items: search_items,
393 programming_language: SearchProgrammingLanguage::Gleam,
394 })
395 .expect("search index serialization");
396
397 files.push(OutputFile {
398 path: Utf8PathBuf::from("search_data.json"),
399 content: Content::Text(search_data_json.to_string()),
400 });
401
402 files.push(OutputFile {
403 path: Utf8PathBuf::from("js/index.js"),
404 content: Content::Text(std::include_str!("../templates/docs-js/index.js").to_string()),
405 });
406
407 // web fonts:
408
409 files.push(OutputFile {
410 path: Utf8PathBuf::from("fonts/karla-v23-regular-latin-ext.woff2"),
411 content: Content::Binary(
412 include_bytes!("../templates/docs-fonts/karla-v23-regular-latin-ext.woff2").to_vec(),
413 ),
414 });
415
416 files.push(OutputFile {
417 path: Utf8PathBuf::from("fonts/karla-v23-regular-latin.woff2"),
418 content: Content::Binary(
419 include_bytes!("../templates/docs-fonts/karla-v23-regular-latin.woff2").to_vec(),
420 ),
421 });
422
423 files.push(OutputFile {
424 path: Utf8PathBuf::from("fonts/karla-v23-bold-latin-ext.woff2"),
425 content: Content::Binary(
426 include_bytes!("../templates/docs-fonts/karla-v23-bold-latin-ext.woff2").to_vec(),
427 ),
428 });
429
430 files.push(OutputFile {
431 path: Utf8PathBuf::from("fonts/karla-v23-bold-latin.woff2"),
432 content: Content::Binary(
433 include_bytes!("../templates/docs-fonts/karla-v23-bold-latin.woff2").to_vec(),
434 ),
435 });
436
437 files.push(OutputFile {
438 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-cyrillic-ext.woff2"),
439 content: Content::Binary(
440 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-cyrillic-ext.woff2")
441 .to_vec(),
442 ),
443 });
444
445 files.push(OutputFile {
446 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-cyrillic.woff2"),
447 content: Content::Binary(
448 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-cyrillic.woff2")
449 .to_vec(),
450 ),
451 });
452
453 files.push(OutputFile {
454 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-greek-ext.woff2"),
455 content: Content::Binary(
456 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-greek-ext.woff2")
457 .to_vec(),
458 ),
459 });
460
461 files.push(OutputFile {
462 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-greek.woff2"),
463 content: Content::Binary(
464 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-greek.woff2").to_vec(),
465 ),
466 });
467
468 files.push(OutputFile {
469 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-latin-ext.woff2"),
470 content: Content::Binary(
471 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-latin-ext.woff2")
472 .to_vec(),
473 ),
474 });
475
476 files.push(OutputFile {
477 path: Utf8PathBuf::from("fonts/ubuntu-mono-v15-regular-latin.woff2"),
478 content: Content::Binary(
479 include_bytes!("../templates/docs-fonts/ubuntu-mono-v15-regular-latin.woff2").to_vec(),
480 ),
481 });
482
483 files
484}
485
486pub fn generate_json_package_interface(
487 path: Utf8PathBuf,
488 package: &Package,
489 cached_modules: &im::HashMap<EcoString, type_::ModuleInterface>,
490) -> OutputFile {
491 OutputFile {
492 path,
493 content: Content::Text(
494 serde_json::to_string(&PackageInterface::from_package(package, cached_modules))
495 .expect("JSON module interface serialisation"),
496 ),
497 }
498}
499
500pub fn generate_json_package_information(path: Utf8PathBuf, config: PackageConfig) -> OutputFile {
501 OutputFile {
502 path,
503 content: Content::Text(package_information_as_json(config)),
504 }
505}
506
507fn package_information_as_json(config: PackageConfig) -> String {
508 let info = PackageInformation {
509 package_config: config,
510 };
511 serde_json::to_string_pretty(&info).expect("JSON module information serialisation")
512}
513
514fn page_unnest(path: &str) -> String {
515 let unnest = path
516 .strip_prefix('/')
517 .unwrap_or(path)
518 .split('/')
519 .skip(1)
520 .map(|_| "..")
521 .join("/");
522 if unnest.is_empty() {
523 ".".into()
524 } else {
525 unnest
526 }
527}
528
529#[test]
530fn page_unnest_test() {
531 // Pages
532 assert_eq!(page_unnest("wibble.html"), ".");
533 assert_eq!(page_unnest("/wibble.html"), ".");
534 assert_eq!(page_unnest("/wibble/woo.html"), "..");
535 assert_eq!(page_unnest("/wibble/wobble/woo.html"), "../..");
536
537 // Modules
538 assert_eq!(page_unnest("string"), ".");
539 assert_eq!(page_unnest("gleam/string"), "..");
540 assert_eq!(page_unnest("gleam/string/inspect"), "../..");
541}
542
543fn escape_html_content(it: String) -> String {
544 it.replace('&', "&")
545 .replace('<', "<")
546 .replace('>', ">")
547 .replace('\"', """)
548 .replace('\'', "'")
549}
550
551#[test]
552fn escape_html_content_test() {
553 assert_eq!(
554 escape_html_content("&<>\"'".to_string()),
555 "&<>"'"
556 );
557}
558
559fn import_synonyms(parent: &str, child: &str) -> String {
560 format!("Synonyms:\n{parent}.{child}\n{parent} {child}")
561}
562
563fn text_documentation(doc: &Option<(u32, EcoString)>) -> String {
564 let raw_text = doc
565 .as_ref()
566 .map(|(_, it)| it.to_string())
567 .unwrap_or_else(|| "".into());
568
569 // TODO: parse markdown properly and extract the text nodes
570 raw_text.replace("```gleam", "").replace("```", "")
571}
572
573fn markdown_documentation(doc: &Option<(u32, EcoString)>) -> String {
574 doc.as_ref()
575 .map(|(_, doc)| render_markdown(doc, MarkdownSource::Comment))
576 .unwrap_or_default()
577}
578
579/// An enum to represent the source of a Markdown string to render.
580enum MarkdownSource {
581 /// A Markdown string that comes from the documentation of a
582 /// definition/module. This means that each line is going to be preceded by
583 /// a whitespace.
584 Comment,
585 /// A Markdown string coming from a standalone file like a README.md.
586 Standalone,
587}
588
589fn render_markdown(text: &str, source: MarkdownSource) -> String {
590 let text = match source {
591 MarkdownSource::Standalone => text.into(),
592 // Doc comments start with "///\s", which can confuse the markdown parser
593 // and prevent tables from rendering correctly, so remove that first space.
594 MarkdownSource::Comment => text
595 .split('\n')
596 .map(|s| s.strip_prefix(' ').unwrap_or(s))
597 .join("\n"),
598 };
599
600 let mut s = String::with_capacity(text.len() * 3 / 2);
601 let p = pulldown_cmark::Parser::new_ext(&text, pulldown_cmark::Options::all());
602 pulldown_cmark::html::push_html(&mut s, p);
603 s
604}
605
606#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)]
607struct Link {
608 name: String,
609 path: String,
610}
611
612#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
613struct TypeConstructor {
614 definition: String,
615 documentation: String,
616 text_documentation: String,
617 arguments: Vec<TypeConstructorArg>,
618}
619
620#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
621struct TypeConstructorArg {
622 name: String,
623 doc: String,
624}
625
626#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
627struct TypeDefinition<'a> {
628 name: &'a str,
629 definition: String,
630 documentation: String,
631 constructors: Vec<TypeConstructor>,
632 text_documentation: String,
633 source_url: String,
634 deprecation_message: String,
635 opaque: bool,
636}
637
638#[derive(PartialEq, Eq, PartialOrd, Ord)]
639struct DocsValues<'a> {
640 name: &'a str,
641 definition: String,
642 documentation: String,
643 text_documentation: String,
644 source_url: String,
645 deprecation_message: String,
646}
647
648#[derive(Template)]
649#[template(path = "documentation_page.html")]
650struct PageTemplate<'a> {
651 gleam_version: &'a str,
652 unnest: &'a str,
653 host: &'a str,
654 page_title: &'a str,
655 page_meta_description: &'a str,
656 file_path: &'a Utf8PathBuf,
657 project_name: &'a str,
658 project_version: &'a str,
659 pages: &'a [Link],
660 links: &'a [Link],
661 modules: &'a [Link],
662 content: String,
663 rendering_timestamp: &'a str,
664}
665
666#[derive(Template)]
667#[template(path = "documentation_module.html")]
668struct ModuleTemplate<'a> {
669 gleam_version: &'a str,
670 unnest: String,
671 host: &'a str,
672 page_title: &'a str,
673 page_meta_description: &'a str,
674 file_path: &'a Utf8PathBuf,
675 module_name: EcoString,
676 project_name: &'a str,
677 project_version: &'a str,
678 pages: &'a [Link],
679 links: &'a [Link],
680 modules: &'a [Link],
681 types: Vec<TypeDefinition<'a>>,
682 values: Vec<DocsValues<'a>>,
683 documentation: String,
684 rendering_timestamp: &'a str,
685}
686
687#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
688struct SearchData {
689 items: Vec<SearchItem>,
690 #[serde(rename = "proglang")]
691 programming_language: SearchProgrammingLanguage,
692}
693
694#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
695struct SearchItem {
696 #[serde(rename = "type")]
697 type_: SearchItemType,
698 #[serde(rename = "parentTitle")]
699 parent_title: String,
700 title: String,
701 #[serde(rename = "doc")]
702 content: String,
703 #[serde(rename = "ref")]
704 reference: String,
705}
706
707#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
708#[serde(rename_all = "lowercase")]
709enum SearchItemType {
710 Value,
711 Module,
712 Page,
713 Type,
714}
715
716#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
717#[serde(rename_all = "lowercase")]
718enum SearchProgrammingLanguage {
719 // Elixir,
720 // Erlang,
721 Gleam,
722}
723
724#[test]
725fn package_config_to_json() {
726 let input = r#"
727name = "my_project"
728version = "1.0.0"
729licences = ["Apache-2.0", "MIT"]
730description = "Pretty complex config"
731target = "erlang"
732repository = { type = "github", user = "example", repo = "my_dep" }
733links = [{ title = "Home page", href = "https://example.com" }]
734internal_modules = ["my_app/internal"]
735gleam = ">= 0.30.0"
736
737[dependencies]
738gleam_stdlib = ">= 0.18.0 and < 2.0.0"
739my_other_project = { path = "../my_other_project" }
740
741[dev-dependencies]
742gleeunit = ">= 1.0.0 and < 2.0.0"
743
744[documentation]
745pages = [{ title = "My Page", path = "my-page.html", source = "./path/to/my-page.md" }]
746
747[erlang]
748application_start_module = "my_app/application"
749extra_applications = ["inets", "ssl"]
750
751[javascript]
752typescript_declarations = true
753runtime = "node"
754
755[javascript.deno]
756allow_all = false
757allow_ffi = true
758allow_env = ["DATABASE_URL"]
759allow_net = ["example.com:443"]
760allow_read = ["./database.sqlite"]
761"#;
762
763 let config = toml::from_str::<PackageConfig>(&input).unwrap();
764 let info = PackageInformation {
765 package_config: config.clone(),
766 };
767 let json = package_information_as_json(config);
768 let output = format!("--- GLEAM.TOML\n{input}\n\n--- EXPORTED JSON\n\n{json}");
769 insta::assert_snapshot!(output);
770
771 let roundtrip: PackageInformation = serde_json::from_str(&json).unwrap();
772 assert_eq!(info, roundtrip);
773}
774
775#[test]
776fn barebones_package_config_to_json() {
777 let input = r#"
778name = "my_project"
779version = "1.0.0"
780"#;
781
782 let config = toml::from_str::<PackageConfig>(&input).unwrap();
783 let json = package_information_as_json(config);
784 let output = format!("--- GLEAM.TOML\n{input}\n\n--- EXPORTED JSON\n\n{json}");
785 insta::assert_snapshot!(output);
786}