stanhope/
main.rs

1#![doc(html_logo_url = "https://images.squarespace-cdn.com/content/v1/60416644b68a5453a868e856/1628206791960-M61IU1QP850NRLI9CE07/Stanhope+Etching.jpg?format=2500w")]
2//! **Stanhope**, named after the Stanhope Printing Press that improved upon Gutenberg's design by using sturdy iron and steel parts and hand levers to increase printing speed and quality.
3//! 
4//! This application takes an input argument file in a specific markup and produces a static, printable HTML page based on the markup file's contents.
5//! 
6//! The real power in this application is the markup language, which has these features:
7//! * Single-purpose: just for writing processes that can be printed as documents
8//! * General: suitable for writing any process
9//! * Consistent: output branding and style is centrally controlled for 100% consistent processes in an organization's library
10//! * *Visually* flexible: custom templates can mold the look/feel of the HTML produced
11//! * *Format* rigid: we want process authors to think carefully to help operators in the field, and Stanhope forces authors to conform to pre-thought out process language and structure that solves this
12
13mod read_csv;
14mod read_ebml;
15mod write_ebml;
16mod write_html;
17mod write_script;
18mod write_webmenu;
19
20//use std::process::Command; // needed for current implementation of PDF generation
21use std::{
22    process::Command,
23    //fs::{self, File},
24    //io::{self, BufRead, BufReader},
25    fs::{self},
26    io::{self},
27    path::Path,
28};
29use chrono::prelude::*;
30use read_ebml::Process;
31
32use clap::Parser; // used for command line parameters and --help generation
33use read_csv::read_csv; // 
34use write_ebml::get_process_folder_name;
35use write_ebml::write_ebml;
36use read_ebml::read_ebml;
37use write_html::generate_complete_html;
38use write_webmenu::generate_complete_webmenu;
39use glob::glob;
40
41
42/// Command Line Interface (CLI) argument structure
43#[derive(Parser, Debug)]
44#[command(version, about =
45"\n\n\x1b[1;30;47mStanope\x1b[0m\x1b[30;47m, the Easy Button process generation engine.\x1b[0m
46
47Arguments passed into the options should be surrounded by single or double quotes, e.g.
48% ./stanhope -p \"EB-WI-0010\"    \x1b[36m<< double quotes are accepted\x1b[0m
49% ./stanhope -a 'EB-WI-*'       \x1b[36m<< single quotes are accepted\x1b[0m",
50author = "Strativus Group <contact@strativusgroup.com>",
51long_about = None,
52after_help = 
53"
54")]
55struct StanhopeArgs {
56    /// Read a single CSV file to create many new first-draft processes from scratch
57    /// - CSV-FILE
58    ///   - Comma-separated text file that strictly conforms to the template below
59    ///   - Exception: _TFlag_ columns (7th column and beyond) are user-defined
60    ///   - Free Excel file to modify and export as CSV: <https://stanhope.strativusgroup.com/latest/DocumentList.xlsx>
61    /// 
62    /// | "Document Number" | "Title" | "Subject" | "Product" | "Author" | "Reviewer" | _TFlag_ | _TFlag_ | ... |
63    /// | ----------------- | ------- | --------- | --------- | -------- | ---------- | ------- | ------- | --- |
64    /// | ...               | ...     | ...       | ...       | ...      | ...        |    x    |         | ... |
65    /// | ...               | ...     | ...       | ...       | ...      | ...        |         |    x    | ... |
66    /// | ...               | ...     | ...       | ...       | ...      | ...        |         |         | ... |
67    /// | ...               | ...     | ...       | ...       | ...      | ...        |    x    |    x    | ... |
68    #[arg(short, long, value_name = "CSV-FILE", default_value_t = String::from(""), verbatim_doc_comment)]
69    listgen: String,
70
71    /// Generate (or overwrite) a menu that indexes useful information for every process in a library
72    /// WebMenu.html is generated in the Process Library root folder
73    #[arg(short, long, default_value_t = false, verbatim_doc_comment)]
74    webmenu: bool,
75
76    /// Read a single EBML and produce an HTML file as well as a PDF of that process
77    /// - DOCUMENT-NUMBER should be surrounded by quotes, particularly if wildcards are used
78    ///   - Wildcards are accepted, e.g. *, ?, [0-9]
79    ///   - Wildcards follow "glob" formatting: <https://docs.rs/glob/0.3.3/glob/struct.Pattern.html>
80    #[arg(short, long, value_name = "DOCUMENT-NUMBER", default_value_t = String::from(""), verbatim_doc_comment)]
81    process_ebml: String,
82
83    /// Export every "Command" line from a single process into a script with a defined language/format
84    /// - DOCUMENT-NUMBER should be surrounded by quotes, particularly if wildcards are used
85    ///   - Wildcards are accepted, e.g. *, ?, [0-9]
86    ///   - Wildcards follow "glob" formatting: <https://docs.rs/glob/0.3.3/glob/struct.Pattern.html>
87    /// - SCRIPT-FORMAT can be "bash" or "powershell" or "python" or "applescript"
88    /// - PAUSE-AFTER-EACH
89    ///   - {"true","1"} to insert a format-specific wait after each command
90    ///   - {"false","0"} to script with no waiting
91    #[arg(short, long, use_value_delimiter = true, value_delimiter = ' ', num_args = 3, value_names = ["DOCUMENT-NUMBER","SCRIPT-FORMAT","PAUSE-AFTER-EACH"], verbatim_doc_comment)] 
92    scriptify_process: Vec<String>,
93
94    /// Only perform the archiving process, i.e. create a new archive in "previous"
95    /// Note: this operation is performed at the end of every "Process EBML" action
96    /// This option _only_ performs the archiving, not the processing.
97    /// - DOCUMENT-NUMBER should be surrounded by quotes, particularly if wildcards are used
98    ///   - Wildcards are accepted, e.g. *, ?, [0-9]
99    ///   - Wildcards follow "glob" formatting: <https://docs.rs/glob/0.3.3/glob/struct.Pattern.html>
100    #[arg(short, long, value_name = "DOCUMENT-NUMBER", default_value_t = String::from(""), verbatim_doc_comment)]
101    archive_process: String,
102
103    /// Inspect a document by reading its EBML and returning information (no file generation)
104    /// - DOCUMENT-NUMBER should be surrounded by quotes, particularly if wildcards are used
105    ///   - Wildcards are accepted, e.g. *, ?, [0-9]
106    ///   - Wildcards follow "glob" formatting: <https://docs.rs/glob/0.3.3/glob/struct.Pattern.html>
107    #[arg(short, long, value_name = "DOCUMENT-NUMBER", default_value_t = String::from(""), verbatim_doc_comment)]
108    inspect_process: String,
109
110    /// Delete all but one "previous" version for each Revision for a given process
111    ///   For example, for a document that has many "previous" copies across four
112    ///   Revisions (-,A,B,C), then this option deletes all subdirectories except
113    ///   four: the one with the latest timestamp for each Revision variant
114    #[arg(short, long, value_name = "DOCUMENT-NUMBER", default_value_t = String::from(""), verbatim_doc_comment)]
115    cleanup_previous: String,
116
117    /// Output more information to stdout as Stanhope executes (flag)
118    #[arg(short, long, default_value_t = false,)]
119    verbose: bool,
120
121    /// Display syntax help for Easy Button Markup Language (EBML)
122    #[arg(short, long, default_value_t = false,)]
123    ebml_help: bool,
124}
125
126/// Recursive copy of all contents in one folder to another
127fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
128    fs::create_dir_all(&dst)?;
129    for entry in fs::read_dir(src)? {
130        let entry = entry?;
131        let ty = entry.file_type()?;
132        if ty.is_dir() {
133            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
134        } else {
135            fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
136        }
137    }
138    Ok(())
139}
140
141/// Copy everything that ISN'T a directory... just the files
142fn copy_files_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
143    fs::create_dir_all(&dst)?;
144    for entry in fs::read_dir(src)? {
145        let entry = entry?;
146        let ty = entry.file_type()?;
147        if ty.is_dir() {
148            ();
149        } else {
150            fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
151        }
152    }
153    Ok(())
154}
155
156fn archive_single_process(new_proc: Process, verbose: &bool) {
157    // if it doesn't already exist, make a new folder called "previous"
158    //let previous_folder = new_proc.get_number().to_string()+"/previous"; // Old convention: process number is the process folder name. Now it's ( NUMBER - TITLE )
159    let previous_folder = get_process_folder_name(&new_proc).to_owned() + "/previous";
160    match Path::new(&previous_folder).exists() {
161        true => (),
162        false => { let _ = fs::create_dir(&previous_folder); },
163    };
164    let root_assets_folder = "./assets";
165    let previous_assets_folder = previous_folder.clone() + "/assets";
166    match Path::new(&previous_assets_folder).exists() {
167        true => (),
168        false => { let _ = fs::create_dir(&previous_assets_folder); },
169    };
170
171    // make a new folder to archive everything we just did in "previous"
172    let rev_string = new_proc.get_revision();
173    let right_now_string = Utc::now().format("UTC-%Y-%m-%d-T%H-%M-%S").to_string();
174    let timestamp_folder = previous_folder.clone() + "/Rev_" + new_proc.get_revision() + "_" + &right_now_string;
175    if *verbose { 
176        println!("Revision: {}",&rev_string);
177        println!("SystemTime::now() ...ish: {}",right_now_string);
178        println!("Trying to create this folder: {:?}",timestamp_folder);
179    }
180    let _ = fs::create_dir(&timestamp_folder);
181    // copy assets
182    let _ = copy_dir_all(root_assets_folder,previous_assets_folder);
183    // copy EBML, HTML, and PDF plus any other non-folder files in the process directory
184    let _ = copy_files_all(get_process_folder_name(&new_proc),timestamp_folder.clone());
185}
186
187/// File management: return a vector of directories eligible to delete as a "cleanup" operation
188/// 
189/// Eligibility criterion: any directory that *isn't* the most recent for its revision.
190/// 
191/// Example: If the contents of "previous" is the following list of directories:
192/// 1. Rev_-_UTC-2025-01-01-T00-00-00
193/// 1. Rev_-_UTC-2025-01-01-T00-00-01
194/// 1. Rev_A_UTC-2025-01-01-T00-05-00
195/// 
196/// The only eligible directory for deletion would be #1, because #2 is the latest Rev -, and #3 is the latest (only) Rev A.
197fn list_directories_to_cleanup(prev:String) -> Vec<String> {
198
199    let mut all_revs:Vec<String> = vec![];
200    let mut directories_to_cleanup:Vec<String> = vec![];
201    let all_archives = glob(&(prev.clone() + "/Rev_*_UTC-*-*-*-T*-*-*")).expect("Failed to read glob pattern");
202
203    for entry in all_archives {
204        match entry {
205            Ok(path) => {
206                let archive = path.file_name().expect("huh?").to_str().expect("huh?");
207                let pos = archive.find("_UTC-").expect("rust is hard");
208                let rev = String::from(&archive[4..pos]); 
209                all_revs.push(rev);
210            },
211            Err(e) => println!("{:?}",e),
212        }
213    }
214
215    all_revs.dedup();
216    println!("For this process, these are all the revisions: {:?}",&all_revs);
217
218    for rev in all_revs {
219        println!("\nHere are all of the archive folders for Rev {}:",rev);
220        let these = glob(&(prev.clone() + "/Rev_" + &rev + "_UTC-*-*-*-T*-*-*")).expect("Failed to read glob pattern");
221        let mut vec:Vec<String> = vec![];
222        for this in these {
223            match this {
224                Ok(path) => vec.push(path.file_name().expect("huh?").to_str().expect("huh?").to_string()),
225                Err(e) => println!("{:?}",e),    
226            }
227        }
228        vec.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
229        let keeper = vec.pop().expect("pop?");
230        for d in &vec {
231            println!("      {} \x1b[93mDELETE\x1b[0m",&d);
232        }
233        println!("   💾 {} 💾  << only one to be saved...",&keeper);
234        directories_to_cleanup.append(&mut vec);
235    }
236    directories_to_cleanup
237}
238
239fn globify_document_number(input:&String) -> Vec<String> {
240    let all_results = match input.chars().last().unwrap() {
241        '*' => glob(&(input.to_owned())).expect("Failed to read glob pattern"),
242        _ => glob(&(input.to_owned()+"*")).expect("Failed to read glob pattern"),
243    };
244    let mut matching_process_folders:Vec<String> = vec![];
245
246    for entry in all_results {
247        match entry {
248            Ok(path) => {
249                let process_folder = path.file_name().expect("huh?").to_str().expect("huh?");
250                matching_process_folders.push(String::from(process_folder));
251            },
252            Err(e) => println!("{:?}",e),
253        }
254    }
255    matching_process_folders
256}
257
258/// Run Stanhope in one of (or more!) of its modes, each with specific input and expected file generation output.
259/// 
260/// _Examples:_
261/// >    stanhope -vl "DocumentList.csv"
262/// >    stanhope --verbose --listgen "DocumentList.csv"
263/// >    stanhope -w
264/// >    stanhope --webmenu
265/// >    stanhope -vp "EB-WI-00*"
266/// >    stanhope --verbose --process-ebml "EB-WI-00*"
267/// >    stanhope -vs "EB-WI-0?00"
268/// >    stanhope --verbose --scriptify-process "EB-WI-0?00"
269fn main() {
270
271    let args = StanhopeArgs::parse();
272
273    // /////////////////////////////////////////////////////////////////////////////////////////////
274    // If user specified a CSV file to import, then process it appropriately 
275    if !(&args.listgen=="") {
276
277        // Read each line of the CSV file
278        let needed_processes_list = read_csv(&args.listgen,args.verbose.clone());
279        if args.verbose { println!("Stanhope found {} EBML files to create in the CSV file.",needed_processes_list.len()); }
280        // Write to new files
281        for new_proc_to_create in needed_processes_list {
282            if args.verbose { new_proc_to_create.display_process_to_stdout() };
283            write_ebml(&new_proc_to_create,args.verbose.clone());
284        }
285
286    }
287
288    // /////////////////////////////////////////////////////////////////////////////////////////////
289    // If user specified a single process to "scriptify" along with a scripting format
290    if !(args.scriptify_process.len()==0) {
291        // First, we'll allow wildcards and look into every matching Document Number
292        let matching_processes = globify_document_number(&args.scriptify_process[0]);
293        if args.verbose { println!("{:?}",&matching_processes) };
294
295        if matching_processes.len() > 0 {
296
297            for process in matching_processes {
298
299
300                let file_to_read = "./".to_owned() + &process + "/" + &process + ".ebml";
301
302                if args.verbose {
303                    println!("");
304                    println!("=== Stanhope Scriptification =========================================================");
305                    println!("===");
306                    println!("=== >> Process:          {}",&process);
307                    println!("=== >> Script Format:    {}",&args.scriptify_process[1]);
308                    println!("=== >> Pause after each? {}",&args.scriptify_process[2]);
309                    println!("===");
310                    println!("======================================================================================");
311                    println!("");
312                }
313                if args.verbose { print!("[A] Attempting to read EBML -> {} ...",&file_to_read); }
314                let new_proc = read_ebml(&file_to_read);
315                if args.verbose { print!("success!\n"); }
316                let all_commands = new_proc.get_all_command_lines();
317                if args.verbose { print!("[B] Stanhope found {} lines of type \"Command\" in the file.\n",all_commands.len()); }
318
319                if args.verbose { print!("[C] Processing script format \"{}\"\n",&args.scriptify_process[1]); }
320                match args.scriptify_process[1].as_str() {
321                    "bash" => {
322                        let suffix = ".sh".to_string();
323                        let all_script_lines = write_script::bash_file_from_commands(all_commands,&process,&args.scriptify_process[2],&suffix);
324                        write_script::write_script(all_script_lines,&process,&suffix);
325                    },
326                    "powershell" => {
327                        let suffix = ".ps1".to_string();
328                        let all_script_lines = write_script::powershell_file_from_commands(all_commands,&process,&args.scriptify_process[2],&suffix);
329                        write_script::write_script(all_script_lines,&process,&suffix);
330                    },
331                    "python" => {
332                        let suffix = ".py".to_string();
333                        let all_script_lines = write_script::python_file_from_commands(all_commands,&process,&args.scriptify_process[2],&suffix);
334                        write_script::write_script(all_script_lines,&process,&suffix);
335                    }
336                    "applescript" => {
337                        let suffix = ".command".to_string();
338                        let all_script_lines = write_script::bash_file_from_commands(all_commands,&process,&args.scriptify_process[2],&suffix);
339                        write_script::write_script(all_script_lines,&process,&suffix);                
340                    }
341                    _ => {
342                        println!("That type of <SCRIPT FORMAT> is not yet implemented in stanhope.");
343                        println!("Case-sensitive options are: \x1b[93mbash\x1b[0m, \x1b[93mpowershell\x1b[0m, \x1b[93mpython\x1b[0m, \x1b[93mapplescript\x1b[0m");
344                    }
345                } // End Match of type
346            } // End For loop through matching_processes
347        }; // End conditional to check that more than zero processes are requested
348    } // End Scriptify Process(es)
349
350    // /////////////////////////////////////////////////////////////////////////////////////////////
351    // If user specified an EBML file, then convert it to HTML and subsequently PDF 
352    if !(&args.process_ebml=="") {
353        // First, we'll allow wildcards and look into every matching Document Number
354        let matching_processes = globify_document_number(&args.process_ebml);
355        if args.verbose { println!("{:?}",&matching_processes) };
356
357        if matching_processes.len() > 0 {
358
359            for process in matching_processes {
360
361                let file_to_read = "./".to_owned() + &process + "/" + &process + ".ebml";
362
363                if args.verbose { println!("Attempting to process this file: {}",&file_to_read) };
364
365                let new_proc = read_ebml(&file_to_read);
366                if args.verbose { new_proc.display_process_to_stdout() }; 
367
368                // first, delete old html file, then generate a new one
369                if args.verbose { println!("Attempting to delete old HTML file: {:?}",fs::remove_file(&file_to_read.replace(".ebml",".html"))); } else { let _ = fs::remove_file(&file_to_read.replace(".ebml",".html")); }
370                
371                generate_complete_html(&String::from(&file_to_read.replace(".ebml",".html")),&new_proc);
372
373                let _output = if cfg!(target_os = "windows") {
374                    // Client-specific use of Google Chrome application
375                    let chrome_path = "& 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' --headless --no-pdf-header-footer --print-to-pdf=\"";
376                    let process_library = "$PWD\\";
377                    //let process_library = "";
378                    let string_to_throw = &(chrome_path.to_string() + process_library + &file_to_read.replace(".ebml",".pdf") + "\" \"" + process_library + &file_to_read.replace(".ebml",".html\""));
379                    if args.verbose { println!("Here's my command string to chrome:\n\n{}\n\n",string_to_throw); }
380                    Command::new("powershell.exe")
381                        .args(["-Command", string_to_throw])
382                        .output()
383                        .expect("failed to execute process")
384                } else {
385                    Command::new("sh")
386                        .arg("-c")
387                        .arg(&("chromium --headless --print-to-pdf=\"".to_string() + &file_to_read.replace(".ebml",".pdf") + "\" \"" + &file_to_read.replace(".ebml",".html") + "\" --no-pdf-header-footer"))
388                        .output()
389                        .expect("failed to execute process")
390                };
391
392                archive_single_process(new_proc,&args.verbose);
393
394            } // End For loop through matching_processes
395        } // End If condition for length of matching_processes
396    } // End PROCESS EBML
397
398    // /////////////////////////////////////////////////////////////////////////////////////////////
399    // If user specified a single process to archive, just do the archiving
400    if !(&args.archive_process=="") {
401        // First, we'll allow wildcards and look into every matching Document Number
402        let matching_processes = globify_document_number(&args.archive_process);
403        if args.verbose { println!("{:?}",&matching_processes) };
404
405        if matching_processes.len() > 0 {
406
407            for process in matching_processes {
408
409                let file_to_read = "./".to_owned() + &process + "/" + &process + ".ebml";
410
411                let new_proc = read_ebml(&file_to_read);
412                archive_single_process(new_proc,&args.verbose);
413            }
414        }
415    }
416
417    // /////////////////////////////////////////////////////////////////////////////////////////////
418    // If user specified to clean up a single process
419    if !(&args.cleanup_previous=="") {
420        // First, we'll allow wildcards and look into every matching Document Number
421        let matching_processes = globify_document_number(&args.cleanup_previous);
422        if args.verbose { println!("{:?}",&matching_processes) };
423
424        if matching_processes.len() > 0 {
425
426            for process in matching_processes {
427
428                let prevs = "./".to_owned() + &process + "/previous";
429
430                let delete_these = list_directories_to_cleanup(prevs);
431
432                for dir in delete_these {
433                    println!("Attempting to delete {} ... ",&dir);
434                    let delete_this = "./".to_owned() + &process + "/previous/" + &dir;
435                    let _ = fs::remove_dir_all(delete_this);
436                }
437            }
438        }
439    }
440
441    // /////////////////////////////////////////////////////////////////////////////////////////////
442    // If user specified a single process to inspect
443    if !(&args.inspect_process=="") {
444        // First, we'll allow wildcards and look into every matching Document Number
445        let matching_processes = globify_document_number(&args.inspect_process);
446        if args.verbose { println!("{:?}",&matching_processes) };
447
448        if matching_processes.len() > 0 {
449
450            for process in matching_processes {
451
452                let file_to_read = "./".to_owned() + &process + "/" + &process + ".ebml";
453
454                let new_proc = read_ebml(&file_to_read);
455                new_proc.display_process_to_stdout();
456            }
457        }
458    }
459
460    // /////////////////////////////////////////////////////////////////////////////////////////////
461    // If user requested WebMenu generation
462    if args.webmenu {
463        generate_complete_webmenu(&args.verbose);
464    }
465
466    // /////////////////////////////////////////////////////////////////////////////////////////////
467    // If user requested EBML help, show some text to stdout
468    if args.ebml_help {
469        println!("
470
471Note: Process files passed into --process-ebml are specifically-formatted EBML files (Easy Button Markup Language).
472
473==================================================
474==   Easy Button Markup Language Cheat Sheet   ===
475==================================================
476
477// <COMMENT>
478Template | <CSS FILENAME FOUND IN ASSETS FOLDER>
479
480Title        | <PROCESS TITLE>
481Number       | <PROCESS NUMBER>
482Author       | <PROCESS AUTHOR>
483Reviewer     | <PROCESS REVIEWER>
484Process Type | <CATEGORY>, e.g. Test Procedure
485
486Subject       | <THING TO WHICH THE PROCESS APPLIES> 
487Subject Image | <FILENAME OF IMAGE OF SUBJECT>
488Product       | <THING PRODUCED BY THIS PROCESS> - optional
489Product Image | <FILENAME OF IMAGE OF PRODUCT> - optional
490
491Revision | <LATEST REV NUM>       | <DESCRIPTION OF CHANGE>
492Revision | <INTERMEDIATE REV NUM> | <DESCRIPTION OF CHANGE>
493Revision | <FIRST REV NUM>        | <INITIAL DESCTIPTION>
494
495Section  | <TITLE OF SECTION>
496    Step | <TITLE OF STEP>
497        
498        // These all must occur within a STEP
499        
500        Context      | <PLAIN TEXT STATEMENTS FOR CONTEXT>
501        Command      | <VERBATIM COMPUTER CODE>
502        Warning      | <BIG BOLD TEXT TO CAPTURE ATTENTION>
503        Image        | <FILENAME> | <CAPTION TEXT>
504        Resource     | <THING YOU NEED TO COMPLETE THIS STEP>
505        Objective    | <ACHIEVED PURPOSE OF THIS PROCESS>
506        Out of Scope | <NOTE ABOUT WHAT NOT TO DO HERE>
507
508        // Verification methods can be single letter: A, I, D, T, S
509        // They can also be full words: Analysis, Inspection, Demonstration, Test, Sampling
510        Verification | <REQUIREMENT ID> | <FULL TEXT OF REQUIREMENT> | <VERIFICATION METHOD>
511
512        // Action lines can be grouped together to form consecutive table rows
513        // To optionally include Two-Party Verification, <TPV> can be \"TPV\" 
514        // To NOT make an action Two-Party Verified, stop at the <EXPECTED RESULT>
515        Action   | <THING TO DO> | <EXPECTED RESULT> | <TPV?> - optional
516        Action   | <THING TO DO> | <EXPECTED RESULT> | <TPV?> - optional
517
518For rendered examples, see https://stanhope.strativusgroup.com/latest/stylesheetviewer.html");
519    }
520
521}