diff --git a/docs/genhtml.py b/docs/genhtml.py index c0e2d9f6..cc59627c 100755 --- a/docs/genhtml.py +++ b/docs/genhtml.py @@ -37,7 +37,7 @@ def generate_output_from_typst(code, output_path, format="svg", cetz_path=DEFAUL if result.returncode != 0: print(f"Error generating {format.upper()}: {result.stderr}") return False - + return True except Exception as e: print(f"Exception generating {format.upper()}: {e}") @@ -58,7 +58,7 @@ def convert_typst_to_html_content(text): """ tmp.write(typst_content) tmp.flush() - + with tempfile.NamedTemporaryFile(suffix='.html', delete=True) as html_tmp: result = subprocess.run( ["typst", "compile", tmp.name, html_tmp.name, @@ -69,11 +69,11 @@ def convert_typst_to_html_content(text): if result.returncode != 0: print(f"Error generating HTML: {result.stderr}") return None - + # Read the generated HTML and extract body content with open(html_tmp.name, 'r') as f: html_content = f.read() - + # Extract content between tags import re body_match = re.search(r']*>(.*?)', html_content, re.DOTALL) @@ -81,7 +81,7 @@ def convert_typst_to_html_content(text): return body_match.group(1).strip() else: return html_content - + except Exception as e: print(f"Exception converting Typst to HTML: {e}") return None @@ -91,14 +91,14 @@ def html_to_mdx(html_content): """Convert HTML content to MDX-compatible format.""" if not html_content: return "" - + # Clean up the HTML for MDX compatibility: # - Remove style attributes # - Convert self closing to closing html_content = re.sub(r'\s+style="[^"]*"', '', html_content) html_content = re.sub(r'
', '
', html_content) html_content = re.sub(r'
', '
', html_content) - + return html_content.strip() @@ -125,10 +125,10 @@ def generate_mdx_file(func_data, output_path, cetz_path=DEFAULT_CETZ_VERSION): comment = func_data.get("comment", {}) signature = func_data.get("signature", {}) func_name = signature.get("name", "unknown") if signature else "unknown" - + # Start MDX content mdx_lines = [] - + # Build parameters object for Function component parameters = {} arguments = comment.get("arguments", []) @@ -142,24 +142,24 @@ def generate_mdx_file(func_data, output_path, cetz_path=DEFAULT_CETZ_VERSION): default_value = default_value[2:] param_info["default"] = default_value parameters[name] = param_info - + # Function component import json parameters_json = json.dumps(parameters) mdx_lines.append(f''.replace('null', 'undefined')) - + # Convert main text through Typst -> HTML -> MDX main_text = comment.get("text", "") if main_text: # Remove example blocks from main text for separate processing text_without_examples = re.sub(r'```(?:typc?\s+)?(?:example|example-vertical)\s*\n.*?```', '', main_text, flags=re.DOTALL) - + html_content = convert_typst_to_html_content(text_without_examples) if html_content: mdx_content = html_to_mdx(html_content) mdx_lines.append(mdx_content) mdx_lines.append('') - + # Add example blocks examples = extract_example_blocks(main_text) if examples: @@ -168,7 +168,7 @@ def generate_mdx_file(func_data, output_path, cetz_path=DEFAULT_CETZ_VERSION): mdx_lines.append(example_code) mdx_lines.append('```') mdx_lines.append('') - + # Add parameter documentation if arguments: for arg in arguments: @@ -176,7 +176,7 @@ def generate_mdx_file(func_data, output_path, cetz_path=DEFAULT_CETZ_VERSION): types = format_types(arg.get("types", [])) default_value = arg.get("default-value", "") text = arg.get("text", "") - + if name: param_attrs = [f'name="{name}"'] if types: @@ -185,19 +185,19 @@ def generate_mdx_file(func_data, output_path, cetz_path=DEFAULT_CETZ_VERSION): if default_value.startswith("= "): default_value = default_value[2:] param_attrs.append(f'default_value="{escape_html_attr(default_value)}"') - + mdx_lines.append(f'') - + if text: # Convert parameter description through Typst -> HTML -> MDX html_content = convert_typst_to_html_content(text) if html_content: mdx_content = html_to_mdx(html_content) mdx_lines.append(mdx_content) - + mdx_lines.append('') mdx_lines.append('') - + try: with open(output_path, 'w') as f: f.write('\n'.join(mdx_lines)) @@ -211,33 +211,33 @@ def extract_example_blocks(text): """Extract all example code blocks from text with proper indentation.""" if not text: return [] - + # Find all ```example or ```example-vertical blocks pattern = r'```(?:typc?\s+)?(?:example|example-vertical)\s*\n(.*?)```' matches = re.findall(pattern, text, re.DOTALL) - + cleaned_blocks = [] for match in matches: lines = match.split('\n') if not lines: continue - + # Remove leading/trailing empty lines while lines and not lines[0].strip(): lines.pop(0) while lines and not lines[-1].strip(): lines.pop() - + if not lines: continue - + # Find the minimum indentation (excluding empty lines) min_indent = float('inf') for line in lines: if line.strip(): # Skip empty lines indent = len(line) - len(line.lstrip()) min_indent = min(min_indent, indent) - + # Remove the common indentation from all lines if min_indent != float('inf') and min_indent > 0: dedented_lines = [] @@ -249,7 +249,7 @@ def extract_example_blocks(text): cleaned_blocks.append('\n'.join(dedented_lines)) else: cleaned_blocks.append('\n'.join(lines)) - + return cleaned_blocks @@ -259,7 +259,7 @@ def main(): description='Generate MDX documentation from CeTZ docs with optional SVG examples' ) parser.add_argument( - 'json_file', + 'json_file', nargs='?', help='Path to docs.json file (reads from stdin if not provided)' ) @@ -281,16 +281,16 @@ def main(): const='svg_output', help='Generate SVG files from examples. Optional: specify output directory (default: svg_output)' ) - + args = parser.parse_args() - + # Load JSON data if args.json_file: json_file = Path(args.json_file) if not json_file.exists(): print(f"Error: {json_file} not found.") sys.exit(1) - + with open(json_file, 'r') as f: data = json.load(f) else: @@ -300,32 +300,32 @@ def main(): except: print("Error: Could not read from stdin.") sys.exit(1) - + mdx_dir = Path(args.output) mdx_dir.mkdir(exist_ok=True) svg_dir = None if args.svg: svg_dir = Path(args.svg) svg_dir.mkdir(exist_ok=True) - + if isinstance(data, list) and len(data) > 0: data = data[0] - + # Process each file in the JSON for file_path, functions in data.items(): print(f"\nProcessing {file_path}") - + # Create directory structure matching source # e.g., "src/draw/shapes.typ" -> "draw/shapes/" path_parts = file_path.replace("src/", "").replace(".typ", "").split("/") - + current_mdx_dir = mdx_dir for part in path_parts: current_mdx_dir = current_mdx_dir / part current_mdx_dir.mkdir(exist_ok=True) - + file_base = file_path.replace("src/", "").replace("/", "_").replace(".typ", "") - + # Track functions for combined file generation function_names = [] for func_data in functions: @@ -333,39 +333,39 @@ def main(): func_name = signature.get("name", "unknown") if signature else "unknown" comment = func_data.get("comment", {}) text = comment.get("text", "") - + if not func_name.startswith("_"): function_names.append(func_name) - + # Generate MDX files mdx_filename = f"{func_name}.mdx" mdx_path = current_mdx_dir / mdx_filename - + if generate_mdx_file(func_data, mdx_path, args.cetz): print(f"[ OK] Generated MDX: {'/'.join(path_parts)}/{mdx_filename}") else: print(f"[ERROR] Failed to generate MDX: {'/'.join(path_parts)}/{mdx_filename}") - + # Generate SVGs if svg_dir: examples = extract_example_blocks(text) - + if examples: for i, example_code in enumerate(examples): svg_filename = f"{file_base}_{func_name}_{i}.svg" svg_path = svg_dir / svg_filename - + if generate_output_from_typst(example_code, svg_path, "svg", args.cetz): print(f"[ OK] Generated SVG: {svg_filename} ({i+1}/{len(examples)})") else: print(f"[ERROR] Failed to generate SVG: {svg_filename}") - + # Generate combined MDX file if function_names: #combined_filename = f"{path_parts[-1]}-combined.mdx" combined_filename = f"-combined.mdx" combined_path = current_mdx_dir / combined_filename - + combined_imports = [] combined_lines = [] for func_name in function_names: @@ -373,7 +373,7 @@ def main(): combined_imports.append(f'import {import_name} from "./{func_name}.mdx"') combined_lines.append(f'## {func_name}') combined_lines.append(f'<{import_name}/>\n') - + try: with open(combined_path, 'w') as f: f.write('\n'.join(combined_imports) + '\n\n' + '\n'.join(combined_lines) + '\n') diff --git a/docs/typlodocus/extractor.typ b/docs/typlodocus/extractor.typ index 69adc7f9..44cd70be 100644 --- a/docs/typlodocus/extractor.typ +++ b/docs/typlodocus/extractor.typ @@ -14,17 +14,16 @@ let i = 0 while i < lines.len() { - let line = lines.at(i).trim() + let line = lines.at(i).trim(at: end) if line.starts-with("///") { in-comment = true current-comment += line.slice(3) + "\n" } else if in-comment { - if line.starts-with(regex("\#?let\s+")) { - let function = parser.parse-function-signature(lines.slice(i)) + if line.starts-with(regex(`#let\s+`.text)) { comments.push(( - comment: parser.parse-docstring(current-comment), - signature: function, + comment: parser.parse-docstring-alt(current-comment), + signature: parser.parse-function-signature(lines.slice(i)), )) } diff --git a/docs/typlodocus/parser.typ b/docs/typlodocus/parser.typ index 6092e2f8..55f10c02 100644 --- a/docs/typlodocus/parser.typ +++ b/docs/typlodocus/parser.typ @@ -1,137 +1,476 @@ -#let parse-argument-list(string) = { +// TODO: see if we can perform documentation diagnostics: +// (1) the diagnostics would include: +// - default argument type validation (would have to look into how do +// this with custom types but it's feasible,) +// - argument name validation (feasible considering we preprocess the +// function signature prior to the docstring, though it would +// require further rework,) +// (2) the diagnostics should panic such that querying fails and the error +// is reported during the doc building process (to better diagnose +// issues whenever this happens in CI, it would be best if the errors +// were also written to some log file or alternatively replaced the +// query operation, and the Python script reported the actual error +// and wrote to file if a specific json schema in the expected query +// results is detected.) + +#let parse-function-signature(lines) = { + let ident = lines + .first() + .replace( + regex(`#let\s+([_[:alnum:]-]+).*`.text), + match => match.captures.first(), + count: 1, + ) + + lines.first() = lines + .first() + .trim( + regex(`#let\s+[_[:alnum:]-]+\(?`.text), + at: start, + repeat: false, + ) + + if lines.first().starts-with(regex(`\s*=`.text)) { + return ( + name: ident, + arguments: (), + ) + } + + let escape-hit = false + let delim-stack = ("(",) + let delim-lut = ( + start: ( + "\"", + "(", + "[", + "{", + ), + end: ( + "\"", + ")", + "]", + "}", + ), + ) let arguments = () - let current-arg = "" - let current-default = "" - let named-arg = false - - let depth = 0 - let in-str = false - let in-content = false - let escape-next = false - - for char in string.codepoints() { - if in-content and not escape-next { - if char == "]" { in-content = false } - } else if char == "\"" and not escape-next { - in-str = not in-str - } else if not in-str { - if char == "(" { depth += 1 } - else if char == ")" { depth -= 1 } - else if char == "[" { in-content = true } - else if char == "," and depth == 0 { - arguments.push(( - name: current-arg.trim(), - default-value: current-default.trim(), - has-default: named-arg, - )) - - current-arg = "" - current-default = "" - named-arg = false + let current-arg = ( + name: "", + default-value: "", + has-default: false, + ) + let marker-lut = ( + ":", + ",", + ) + let in-named-arg = false + let indent-ws + let indent-re = regex(`^(\s+).*`.text) + + for line in lines { + if delim-stack.len() == 1 { + let tmp = line.match(indent-re) + if tmp != none { indent-ws = tmp.captures.first().len() } + + line = line.trim(at: start) + } + if in-named-arg { + let stack-top = delim-stack.last() + + if stack-top == delim-lut.start.at(1) or stack-top == delim-lut.start.at(3) { + let tmp = line.match(regex(`^(\s{`.text + str(indent-ws) + `}).*`.text)) + if tmp != none { line = line.slice(2) } + } + + current-arg.default-value += "\n" + } + + for char in line.codepoints() { + if char == "\\" { + escape-hit = true continue - } else if char == ":" and depth == 0 { - named-arg = true + } + if escape-hit { + escape-hit = false continue } - } - if named-arg { - current-default += char - } else { - current-arg += char + let stack-top = delim-stack.last() + + if char in delim-lut.start or char in delim-lut.end { + if stack-top == delim-lut.start.at(0) { + if char == delim-lut.end.at(0) { delim-stack.pop() } + if in-named-arg { current-arg.default-value += char } + } else if stack-top == delim-lut.start.at(2) { + if char == delim-lut.end.at(2) { delim-stack.pop() } + if in-named-arg { current-arg.default-value += char } + } else if stack-top == delim-lut.start.at(3) { + if char == delim-lut.end.at(3) { delim-stack.pop() } + if in-named-arg { current-arg.default-value += char } + } else { + if char in delim-lut.start { + delim-stack.push(char) + if in-named-arg { current-arg.default-value += char } + } else if char in delim-lut.end { + if in-named-arg and delim-stack.len() > 1 { current-arg.default-value += char } + delim-stack.pop() + } + } + + if delim-stack.len() == 0 { + if current-arg.name.len() != 0 { + current-arg.name = current-arg.name.trim() + if current-arg.has-default { + current-arg.default-value = current-arg.default-value.trim() + } + + arguments.push(current-arg) + } + break + } + } else if delim-stack.len() == 1 { + if char not in marker-lut and not in-named-arg { + current-arg.name += char + } else if char == marker-lut.first() { + in-named-arg = true + current-arg.has-default = true + } else if char == marker-lut.last() { + in-named-arg = false + + current-arg.name = current-arg.name.trim() + if current-arg.has-default { + current-arg.default-value = current-arg.default-value.trim() + } + arguments.push(current-arg) + + current-arg = ( + name: "", + default-value: "", + has-default: false, + ) + } else if in-named-arg { + current-arg.default-value += char + } + } else if in-named-arg { + current-arg.default-value += char + } } - } - if current-arg.trim() != "" { - arguments.push(( - name: current-arg.trim(), - default-value: current-default.trim(), - has-default: named-arg, - )) + if delim-stack.len() == 0 { break } } - return arguments + return ( + name: ident, + arguments: arguments, + ) } -#let parse-function-signature(lines) = { - let let-re = regex("^#?let\s+") - let identifier-re = regex("^([_a-zA-Z]+[_\w-]*)") - - let combined-line = lines.join(" ") - combined-line = combined-line.trim() - - // Cut off #let or let - let let-m = combined-line.match(let-re) - if let-m == none { - return none - } - combined-line = combined-line.slice(let-m.end) - - // Parse function name - let identifier-m = combined-line.match(identifier-re) - if identifier-m == none { - return none - } - - let name = identifier-m.captures.at(0) - combined-line = combined-line.slice(identifier-m.end) - - // Check if we have an argument list - let paren-match = combined-line.match(regex("^\s*\(")) - if paren-match == none { - return none - } - - // Parse the entire argument list - let args-start = paren-match.end - let paren-depth = 1 - let in-str = false - let in-content = false - let escape-next = false - let args-end = none - - // Find the argument list bounds - for ((i, char)) in combined-line.slice(paren-match.end).codepoints().enumerate() { - if escape-next { - escape-next = false - } else if in-content { - if char == "]" { - in-content = false +// TODO: change the `symbol` key in the corresponding dictionaries with the +// `token` key. +// TODO: see if repetitive code blocks at different stages of the parsing +// process can be refactored into a set of functions that handle such +// functionality in an isolated manner. +// NOTE: a current limitation of this parser is that Typst code that would get +// parsed as some output 'A', will get parsed as an output 'B' instead +// because whitespace removal is performed eagerly at the start of all +// lines. As an example, take Typst's lists; They use whitespace to denote +// offsetting from top-level items, but this won't be the case after this +// gets parsed. +#let parse-docstring-alt(string) = { + let indent-ws = 0 + let example-fields = ( + symbol: ( + open: "```example", + close: "```", + ), + inside: false, + ) + let parameter-fields = ( + symbol: ( + param: "-", + param-list-open: "(", + param-list-close: ")", + ), + inside: false, + inside-param-list: false, + re: ( + line: regex( + // TODO: rework the part of the regex parsing the optional type list to + // make it accept only comma-optional separated sequences. + `^-(\s+)((?:\.{2})?[[:alnum:]_-]+)((?:\s+)(?:\()([[:alnum:][:blank:]-_\.,]+)(?:\)))?((?:\s+=\s+)([^:]+))?(:\s+(.+))?`.text, + ), + parameter-list: regex(`([[:alnum:]-_\.]+)(?:,){0,1}`.text), + default-param: regex(`\)(\s+=\s+([^:]+))?:(?:\s*)?(.*)?`.text), + ), + indent-ws: 0, + ) + let result-fields = ( + symbol: "->", + inside: false, + re: regex(`^->(\s+)\(?([[:alnum:]-_]+)\)?((?:\s+)(.+))?`.text), + indent-ws: 0, + ) + // NOTE: this matches the empty string on non-matching haystacks, so it's + // never `none`. + let comment-ws-re = regex(`^([[:blank:]]*).*`.text) + let arguments = () + let result = () + let text = () + // NOTE: this is meant to store the contents of (possibly) parameter + // documentation in case it turns out to be Typst native list syntax + // instead of a multiline parameter type list. + let tmp-buffer = none + + for line in string.split("\n") { + if not (example-fields.inside or parameter-fields.inside or result-fields.inside) { + indent-ws = line.match(comment-ws-re).captures.first().len() + line = line.slice(indent-ws) + + if line.len() == 0 and text.last().len() == 0 { + continue + } else if line.starts-with(example-fields.symbol.open) { + example-fields.inside = true + text.push(line.trim(at: end)) + } else if line.starts-with(result-fields.symbol) { + let re-result = line.match(result-fields.re) + if re-result != none { + result-fields.indent-ws = re-result.captures.first().len() + result-fields.inside = true + + result.push(( + type: re-result.captures.at(1), + text: if re-result.captures.at(3) != none { + (re-result.captures.at(3).trim(at: end),) + } else { + () + }, + )) + + continue + } + } else if line.starts-with(parameter-fields.symbol.param) { + let param = line.match(parameter-fields.re.line) + if param != none { + parameter-fields.indent-ws = param.captures.first().len() + parameter-fields.inside = true + + if ( + param.captures.at(2) == none + and param.captures.at(3) == none + and param.captures.at(4) == none + ) { tmp-buffer = (line,) } + + arguments.push(( + name: param.captures.at(1), + types: if param.captures.at(3) != none { + param + .captures + .at(3) + .matches(parameter-fields.re.parameter-list) + .map(it => it.captures.first()) + } else { + () + }, + default-value: if param.captures.at(5) != none { param.captures.at(5) } else { none }, + text: if param.captures.at(7) != none { + (param.captures.at(7).trim(at: end),) + } else { + () + }, + )) + + continue + } + } else { + text.push(line.trim(at: end)) } - } else if char == "\\" { - escape-next = true - } else if char == "\"" { - in-str = not in-str - } else if not in-str { - if char == "(" { - paren-depth += 1 - } else if char == ")" { - paren-depth -= 1 - if paren-depth == 0 { - args-end = args-start + i - break + } else if example-fields.inside { + let tmp-ws = line.match(comment-ws-re).captures.first().len() + line = line.slice(tmp-ws) + + if line.starts-with(example-fields.symbol.close) { example-fields.inside = false } + text.push(line) + } else if parameter-fields.inside { + let tmp-ws = line.match(comment-ws-re).captures.first().len() + line = line.slice(tmp-ws) + + if tmp-ws < indent-ws + parameter-fields.indent-ws + parameter-fields.symbol.param.len() { + parameter-fields.inside = false + indent-ws = tmp-ws + + if tmp-buffer != none { + arguments.pop() + for buf in tmp-buffer { text.push(buf) } + + tmp-buffer = none + } + + if line.starts-with(result-fields.symbol) { + let re-result = line.match(result-fields.re) + if re-result != none { + result-fields.indent-ws = re-result.captures.first().len() + result-fields.inside = true + + result.push(( + type: re-result.captures.at(1), + text: if re-result.captures.at(3) != none { + (re-result.captures.at(3).trim(at: end),) + } else { + () + }, + )) + } + } else if line.starts-with(parameter-fields.symbol.param) { + let param = line.match(parameter-fields.re.line) + if param != none { + parameter-fields.indent-ws = param.captures.first().len() + parameter-fields.inside = true + + if ( + param.captures.at(2) == none + or param.captures.at(3) == none + or param.captures.at(4) == none + ) { tmp-buffer = (line,) } + + arguments.push(( + name: param.captures.at(1), + types: if param.captures.at(3) != none { + param + .captures + .at(3) + .matches(parameter-fields.re.parameter-list) + .map(it => it.captures.first()) + } else { + () + }, + default-value: if param.captures.at(5) != none { param.captures.at(5) } else { none }, + text: if param.captures.at(7) != none { + (param.captures.at(7).trim(at: end),) + } else { + () + }, + )) + } + } else { + if text.last().len() == 0 and line.len() == 0 { continue } + text.push(line.trim(at: end)) + } + } else { + if parameter-fields.inside-param-list { + if line.starts-with(parameter-fields.symbol.param-list-close) { + parameter-fields.inside-param-list = false + + let result = line.match(parameter-fields.re.default-param) + if result != none { arguments.last().text.push(result.captures.at(1)) } + } else { + let result = line.match(parameter-fields.re.parameter-list) + if result != none { arguments.last().types.push(result.captures.first()) } + } + } else if arguments.last().types.len() == 0 { + if tmp-buffer.len() == 1 and line.first() == parameter-fields.symbol.param-list-open { + parameter-fields.inside-param-list = true + } else { + tmp-buffer.push(line) + } + } else { + arguments.last().text.push(line.trim(at: end)) } - } else if char == "[" { - in-content = true + } + } else if result-fields.inside { + let tmp-ws = line.match(comment-ws-re).captures.first().len() + line = line.slice(tmp-ws) + + if tmp-ws < indent-ws + result-fields.indent-ws + result-fields.symbol.len() { + result-fields.inside = false + indent-ws = tmp-ws + + if line.starts-with(result-fields.symbol) { + let re-result = line.match(result-fields.re) + if re-result != none { + result-fields.indent-ws = re-result.captures.first().len() + result-fields.inside = true + + result.push(( + type: re-result.captures.at(1), + text: if re-result.captures.at(3) != none { + (re-result.captures.at(3).trim(at: end),) + } else { + () + }, + )) + } + } else if line.starts-with(parameter-fields.symbol.param) { + let param = line.match(parameter-fields.re.line) + if param != none { + parameter-fields.indent-ws = param.captures.first().len() + parameter-fields.inside = true + + if ( + param.captures.at(2) == none + or param.captures.at(3) == none + or param.captures.at(4) == none + ) { tmp-buffer = (line,) } + + arguments.push(( + name: param.captures.at(1), + types: if param.captures.at(3) != none { + param + .captures + .at(3) + .matches(parameter-fields.re.parameter-list) + .map(it => it.captures.first()) + } else { + () + }, + default-value: if param.captures.at(5) != none { param.captures.at(5) } else { none }, + text: if param.captures.at(7) != none { + (param.captures.at(7).trim(at: end),) + } else { + () + }, + )) + } + } else if line.starts-with(example-fields.symbol.open) { + example-fields.inside = true + text.push(line.trim(at: end)) + } else { + if text.last().len() == 0 and line.len() == 0 { continue } + text.push(line.trim(at: end)) + } + } else { + result.last().text.push(line.trim(at: end)) } } } - - if args-end == none { - return none - } - - // Parse arguments - let arguments = () - let args-str = combined-line.slice(args-start, args-end) - if args-str.trim() != "" { - arguments = parse-argument-list(args-str) + if tmp-buffer != none { + for buf in tmp-buffer { text.push(buf) } + arguments.pop() } - + return ( - name: name, - arguments: arguments + raw: string, + text: text.join("\n", default: "").trim(), + arguments: arguments.map(it => ( + ..it, + text: it + .text + .join( + "\n", + default: "", + ) + .trim(), + )), + result: result.map(it => ( + ..it, + text: it + .text + .join( + "\n", + default: "", + ) + .trim(), + )), ) } @@ -192,7 +531,7 @@ in-result = true result.push(( type: result-m.captures.at(0).trim(), - text: result-m.captures.at(1).trim() + text: result-m.captures.at(1).trim(), )) } else { text += line + "\n"