Compare commits

...

2 Commits

Author SHA1 Message Date
1706b8240e Add flask website 2025-08-09 19:20:45 +01:00
3f0650dac7 New README for latest program 2025-08-09 16:25:41 +01:00
3 changed files with 1081 additions and 0 deletions

View File

@@ -1,6 +1,13 @@
<<<<<<< HEAD
# mojofmt
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
=======
# Mojolicious Template Formatter # Mojolicious Template Formatter
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep) Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
>>>>>>> 59bfbde88e2686d25b7f6a14692814da8f440e05
mojofmt formats HTML and Mojolicious EP templates without breaking embedded Perl. It understands line directives (% ...), inline tags (<% ... %>), raw HTML blocks, and can reformat multi-line Perl blocks inside <% ... %> using perltidy (with a safe fallback if perltidy isnt available). mojofmt formats HTML and Mojolicious EP templates without breaking embedded Perl. It understands line directives (% ...), inline tags (<% ... %>), raw HTML blocks, and can reformat multi-line Perl blocks inside <% ... %> using perltidy (with a safe fallback if perltidy isnt available).
## Features ## Features

801
mojofmt.py.bak Normal file
View File

@@ -0,0 +1,801 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mojofmt: Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
Features (Phase 1 + additions):
- Indent HTML structure and Mojolicious line directives consistently
- Preserve chomp markers (<%- ... -%>) and do not alter newline semantics
- Handle helper begin/end blocks and Perl brace-based indentation for directives
- Treat pre/script/style/textarea content as opaque (unchanged)
- Optionally normalize spacing inside <% %> delimiters and after % directives
- Integrate with perltidy for Perl code formatting (if available on PATH)
- CLI with --write/--check/--diff, --out, --stdin/--stdout modes
- --self-test for sanity checks (includes perltidy probe)
- Logging: --log-level error|info|debug (and --verbose as shorthand for info)
- Optional --perl-keyword-spacing to aggressively insert spaces after Perl keywords
"""
from __future__ import annotations
import argparse
import difflib
import logging
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, replace as dc_replace
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
VERSION = "0.1.7"
DEFAULT_EXTENSIONS = (".ep", ".htm.ep", ".html.ep")
VOID_ELEMENTS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
RAW_ELEMENTS = {"pre", "script", "style", "textarea"}
logger = logging.getLogger("mojofmt")
TAG_RE = re.compile(
r"""
<
(?P<slash>/)?
(?P<name>[A-Za-z][\w:-]*)
(?P<attrs>(?:\s+[^<>]*?)?)
(?P<self>/)?
>
""",
re.VERBOSE,
)
# Mojolicious inline tags on a single line: <%...%>
TPL_TAG_RE = re.compile(
r"""
<%
(?P<leftchomp>-)? # optional left chomp
(?P<kind>==|=|\#)? # kind: ==, =, or #
(?P<body>.*?) # inner code/comment (non-greedy, no newlines)
(?P<rightchomp>-)? # optional right chomp
%>
""",
re.VERBOSE,
)
# Line directives: starts with % (possibly %= %== %#) after indentation
LINE_DIR_RE = re.compile(r"^(?P<indent>\s*)%(?P<kind>==|=|\#)?(?P<body>.*)$")
# Whitespace condensing for single-line normalization
WS_RE = re.compile(r"[ \t]+")
# begin/end detection (heuristic)
BEGIN_RE = re.compile(r"\bbegin\b")
END_LINE_RE = re.compile(r"^\s*%\s*end\b")
END_TAG_ONLY_RE = re.compile(r"^\s*<%-?\s*end\s*-?%>\s*$")
# leading } in a directive (e.g., % } or % }} )
LEADING_RBRACE_COUNT_RE = re.compile(r"^\s*%\s*(?P<braces>\}+)")
# <% } %> alone
TAG_CLOSING_BRACE_ONLY_RE = re.compile(r"^\s*<%-?\s*\}+\s*-?%>\s*$")
# Detect raw element opening/closing (as standalone lines)
RAW_OPEN_RE = re.compile(r"^\s*<(?P<name>pre|script|style|textarea)\b[^>]*>\s*$", re.I)
RAW_CLOSE_RE = re.compile(r"^\s*</(?P<name>pre|script|style|textarea)\s*>\s*$", re.I)
@dataclass
class Config:
indent_width: int = 2
eol: str = "lf" # lf|crlf|preserve
normalize_delimiter_spacing: bool = True
perltidy_path: Optional[str] = None # if None, use PATH
perltidy_options: Optional[List[str]] = None
extensions: Tuple[str, ...] = DEFAULT_EXTENSIONS
respect_gitignore: bool = True
verbose: bool = False # kept for shorthand with --verbose
perl_keyword_spacing: bool = False # optional post-pass
def load_config(cli_args: argparse.Namespace) -> Config:
cfg = Config()
if cli_args.indent is not None:
cfg.indent_width = cli_args.indent
if cli_args.eol is not None:
cfg.eol = cli_args.eol
if cli_args.no_space_in_delims:
cfg.normalize_delimiter_spacing = False
if cli_args.perltidy:
cfg.perltidy_path = cli_args.perltidy
cfg.verbose = cli_args.verbose
cfg.perl_keyword_spacing = getattr(cli_args, "perl_keyword_spacing", False)
return cfg
def setup_logging(level_name: Optional[str], verbose_flag: bool) -> None:
# Determine level
if level_name:
name = level_name.lower()
elif verbose_flag:
name = "info"
else:
name = "error"
level = {
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"critical": logging.CRITICAL,
}.get(name, logging.ERROR)
fmt = "mojofmt: %(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt)
def detect_eol(text: str) -> str:
if "\r\n" in text:
return "crlf"
return "lf"
def normalize_eol(text: str, eol: str) -> str:
if eol == "preserve":
return text
s = text.replace("\r\n", "\n").replace("\r", "\n")
if eol == "lf":
return s
elif eol == "crlf":
return s.replace("\n", "\r\n")
else:
return s
_PERLTIDY_WARNED = False # avoid spamming logs if perltidy missing repeatedly
def run_perltidy(code: str, cfg: Config) -> Tuple[int, str, str]:
global _PERLTIDY_WARNED
exe = cfg.perltidy_path or shutil.which("perltidy")
if not exe:
if not _PERLTIDY_WARNED:
logger.error("perltidy not found; Perl inside template will not be reformatted")
_PERLTIDY_WARNED = True
return (127, code, "perltidy not found")
args = [exe]
default_opts = ["-i=2", "-ci=2", "-l=100", "-q", "-se", "-nbbc", "-noll"]
if cfg.perltidy_options:
args += cfg.perltidy_options
else:
args += default_opts
logger.debug("Running perltidy: %s", " ".join(args))
try:
proc = subprocess.run(
args,
input=code,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if proc.returncode != 0:
logger.debug("perltidy non-zero exit %s: %s", proc.returncode, (proc.stderr or "").strip())
return (proc.returncode, proc.stdout, proc.stderr)
except FileNotFoundError:
if not _PERLTIDY_WARNED:
logger.error("perltidy not found while executing")
_PERLTIDY_WARNED = True
return (127, code, "perltidy not found")
def perltidy_probe(cfg: Config) -> Tuple[bool, str]:
exe = cfg.perltidy_path or shutil.which("perltidy")
if not exe:
return (False, "perltidy not found on PATH (install Perl::Tidy or pass --perltidy)")
snippet = "my $x= {a=>1,b =>2 };"
rc, out, err = run_perltidy(snippet, cfg)
if rc != 0:
return (False, f"perltidy exit {rc}: {(err or '').strip()}")
want = ["my $x = {", "a => 1", "b => 2"]
if all(w in out for w in want):
return (True, f"perltidy OK: {exe}")
if out and out.strip() and out.strip() != snippet:
return (True, f"perltidy OK (non-default style): {exe}")
return (False, "perltidy produced unexpected output")
def tidy_perl_statement_oneline(code: str, cfg: Config) -> str:
rc, out, _ = run_perltidy(code, cfg)
if rc != 0:
out = code
out = out.strip()
out = " ".join(out.splitlines())
out = WS_RE.sub(" ", out).strip()
out = enforce_perl_keyword_spacing(out, cfg.perl_keyword_spacing)
return out
def tidy_perl_expression(code: str, cfg: Config) -> str:
wrapped = f"do {{ {code} }}"
rc, out, _ = run_perltidy(wrapped, cfg)
if rc != 0:
inner = code.strip()
return enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
text = out
try:
start = text.index("{")
depth = 0
end_idx = None
for i in range(start, len(text)):
ch = text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx is None:
inner = code.strip()
else:
inner = text[start + 1 : end_idx]
except ValueError:
inner = code.strip()
inner = " ".join(line.strip() for line in inner.splitlines())
inner = WS_RE.sub(" ", inner).strip()
inner = enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
return inner
def _split_code_and_strings(s: str):
chunks = []
buf: List[str] = []
in_single = in_double = False
i = 0
while i < len(s):
ch = s[i]
if not in_single and not in_double:
if ch == "'":
if buf:
chunks.append(("code", "".join(buf)))
buf = []
in_single = True
buf.append(ch)
elif ch == '"':
if buf:
chunks.append(("code", "".join(buf)))
buf = []
in_double = True
buf.append(ch)
else:
buf.append(ch)
elif in_single:
buf.append(ch)
if ch == "\\":
if i + 1 < len(s):
buf.append(s[i + 1]); i += 1
elif ch == "'":
chunks.append(("str", "".join(buf))); buf = []; in_single = False
elif in_double:
buf.append(ch)
if ch == "\\":
if i + 1 < len(s):
buf.append(s[i + 1]); i += 1
elif ch == '"':
chunks.append(("str", "".join(buf))); buf = []; in_double = False
i += 1
if buf:
chunks.append(("code" if not (in_single or in_double) else "str", "".join(buf)))
return chunks
def _split_unquoted_comment(code_chunk: str):
idx = code_chunk.find("#")
if idx == -1:
return code_chunk, None
return code_chunk[:idx], code_chunk[idx:]
def enforce_perl_keyword_spacing(s: str, enable: bool) -> str:
if not enable or not s:
return s
# Add space after control keywords before '('
ctrl_paren = re.compile(r"\b(?P<kw>if|elsif|unless|while|until|for|foreach|given|when)\s*\(")
# Add space after declarators before sigils/paren
decl = re.compile(r"\b(?P<kw>my|our|state|local)\s*(?=[\$\@\%\*\&\\\(])")
# sub name spacing and brace spacing
sub_named = re.compile(r"\bsub\s*([A-Za-z_]\w*)")
sub_named_brace = re.compile(r"\bsub\s+([A-Za-z_]\w*)\s*\{")
sub_anon = re.compile(r"\bsub\s*\{")
# Calls which often appear without space
call_paren = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*\(")
call_space = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*(?=\S)")
# else/continue/do/eval blocks
else_brace = re.compile(r"\b(?P<kw>else|continue|do|eval)\s*\{")
# Ensure space before a brace after a closing paren: "){" -> ") {"
brace_after_paren = re.compile(r"\)\s*\{")
# NEW: ensure space between '}' and a following keyword: "}else" -> "} else"
brace_then_kw = re.compile(r"\}\s*(?=\b(?:else|elsif|continue|when)\b)")
out: List[str] = []
for kind, chunk in _split_code_and_strings(s):
if kind != "code":
out.append(chunk)
continue
code, comment = _split_unquoted_comment(chunk)
# Apply transforms on code only
code = ctrl_paren.sub(lambda m: f"{m.group('kw')} (", code)
code = decl.sub(lambda m: f"{m.group('kw')} ", code)
code = sub_named.sub(lambda m: f"sub {m.group(1)}", code)
code = sub_named_brace.sub(lambda m: f"sub {m.group(1)} {{", code)
code = sub_anon.sub("sub {", code)
code = call_paren.sub(lambda m: f"{m.group('kw')} (", code)
code = call_space.sub(lambda m: f"{m.group('kw')} ", code)
code = brace_then_kw.sub("} ", code) # <- add space after closing brace before keyword
code = else_brace.sub(lambda m: f"{m.group('kw')} {{", code)
code = brace_after_paren.sub(") {", code)
out.append(code + (comment or ""))
return "".join(out)
def normalize_tpl_tag(
leftchomp: Optional[str],
kind: Optional[str],
body: str,
rightchomp: Optional[str],
cfg: Config,
) -> Tuple[str, str, str, str, str]:
if not cfg.normalize_delimiter_spacing or (kind == "#"):
return ("<%", leftchomp or "", kind or "", body, (rightchomp or "") + "%>")
body = body.strip()
left_space = " "
right_space = " " if rightchomp == "" else ""
open_part = "<%" + (leftchomp or "") + (kind or "") + left_space
close_part = right_space + (rightchomp or "") + "%>"
return (open_part, "", "", body, close_part)
def substitute_tpl_tags_in_line(line: str, cfg: Config) -> str:
parts: List[str] = []
last = 0
for m in TPL_TAG_RE.finditer(line):
parts.append(line[last : m.start()])
leftchomp = m.group("leftchomp") or ""
kind = m.group("kind") or ""
body = m.group("body")
rightchomp = m.group("rightchomp") or ""
open_part, _, _, new_body, close_part = normalize_tpl_tag(
leftchomp, kind, body, rightchomp, cfg
)
if kind == "#":
inner = body
else:
if kind in ("=", "=="):
inner = tidy_perl_expression(body, cfg)
else:
inner = tidy_perl_statement_oneline(body, cfg)
parts.append(open_part + inner + close_part)
last = m.end()
parts.append(line[last:])
return "".join(parts)
def derive_html_tag_deltas(line_wo_tpl: str) -> Tuple[int, int, Optional[str], Optional[str]]:
"""
Return (pre_dedent, net_total, raw_open, raw_close):
- pre_dedent: end tags at beginning of line (dedent before printing)
- net_total: total start tags (+1) minus end tags (-1) across the line for non-void, non-self-closing tags
- raw_open, raw_close: raw elements opened/closed on this line if they match exactly
"""
s = line_wo_tpl
raw_open = None
raw_close = None
m_open = RAW_OPEN_RE.match(s)
if m_open:
raw_open = m_open.group("name").lower()
m_close = RAW_CLOSE_RE.match(s)
if m_close:
raw_close = m_close.group("name").lower()
pre_dedent = 0
i = 0
while i < len(s) and s[i].isspace():
i += 1
while True:
m = TAG_RE.match(s, i)
if not m:
break
if m.group("slash"):
pre_dedent += 1
i = m.end()
while i < len(s) and s[i].isspace():
i += 1
continue
else:
break
net = 0
for m in TAG_RE.finditer(s):
slash = m.group("slash")
name = (m.group("name") or "").lower()
selfclose = bool(m.group("self"))
if slash:
net -= 1
else:
if selfclose or name in VOID_ELEMENTS:
pass
else:
net += 1
return pre_dedent, net, raw_open, raw_close
def strip_tpl_tags(line: str) -> str:
return TPL_TAG_RE.sub(lambda m: " " * (m.end() - m.start()), line)
def is_standalone_statement_tag(line: str) -> bool:
s = line.strip()
if not (s.startswith("<%") and s.endswith("%>")):
return False
if s.startswith("<%=") or s.startswith("<%=="):
return False
return True
def compute_perl_deltas(line: str) -> Tuple[int, int]:
"""
Return (perl_dedent_before, perl_delta_after_for_next_line).
Only line directives (starting with %) and standalone <% ... %> statement lines
affect Perl depth. Also account for % end / <% end %> and begin blocks.
"""
dedent_before = 0
delta_after = 0
if END_LINE_RE.match(line) or END_TAG_ONLY_RE.match(line):
dedent_before += 1
m = LEADING_RBRACE_COUNT_RE.match(line)
if m:
braces = m.group("braces") or ""
dedent_before += len(braces)
if TAG_CLOSING_BRACE_ONLY_RE.match(line):
dedent_before += 1
is_dir = bool(LINE_DIR_RE.match(line))
is_stmt_tag_only = is_standalone_statement_tag(line)
if is_dir:
body = LINE_DIR_RE.match(line).group("body")
open_count = body.count("{")
close_count = body.count("}")
delta_after += (open_count - close_count)
if BEGIN_RE.search(line):
delta_after += 1
elif is_stmt_tag_only:
bodies = [m.group("body") or "" for m in TPL_TAG_RE.finditer(line)]
open_count = sum(b.count("{") for b in bodies)
close_count = sum(b.count("}") for b in bodies)
delta_after += (open_count - close_count)
if BEGIN_RE.search(line):
delta_after += 1
return dedent_before, delta_after
def format_line_directive(line: str, cfg: Config) -> Optional[str]:
"""
If the line is a Mojolicious line directive (% ...), return a formatted
directive string WITHOUT leading indentation (indent applied separately).
Otherwise return None.
"""
m = LINE_DIR_RE.match(line)
if not m:
return None
kind = m.group("kind") or ""
body = m.group("body")
if kind == "#":
if cfg.normalize_delimiter_spacing:
trimmed = body.strip()
return "%#" + ((" " + trimmed) if trimmed else "")
else:
return "%#" + body
if kind in ("=", "=="):
inner = tidy_perl_expression(body, cfg)
else:
inner = tidy_perl_statement_oneline(body, cfg)
if cfg.normalize_delimiter_spacing:
return "%" + kind + ((" " + inner) if inner else "")
else:
return "%" + kind + ((" " + inner) if inner else "")
def rstrip_trailing_ws(line: str) -> str:
return line.rstrip(" \t")
def format_string(src: str, cfg: Config) -> str:
original_eol = detect_eol(src)
text = src.replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
html_depth = 0
perl_depth = 0
in_raw: Optional[str] = None
out_lines: List[str] = []
for orig_line in lines:
line = orig_line
if in_raw:
m_close = RAW_CLOSE_RE.match(line)
if m_close and m_close.group("name").lower() == in_raw:
indent_level = max(0, html_depth - 1) + perl_depth
indent = " " * (cfg.indent_width * indent_level)
new_line = indent + line.lstrip()
out_lines.append(rstrip_trailing_ws(new_line))
html_depth = max(0, html_depth - 1)
in_raw = None
else:
out_lines.append(line)
continue
perl_dedent_before, perl_delta_after = compute_perl_deltas(line)
line_wo_tpl = strip_tpl_tags(line)
html_pre_dedent, html_net, raw_open, raw_close = derive_html_tag_deltas(line_wo_tpl)
base_html_depth = max(0, html_depth - html_pre_dedent)
base_perl_depth = max(0, perl_depth - perl_dedent_before)
indent_level = max(0, base_html_depth + base_perl_depth)
indent = " " * (cfg.indent_width * indent_level)
formatted_directive = format_line_directive(line, cfg)
if formatted_directive is not None:
content = formatted_directive
else:
content = substitute_tpl_tags_in_line(line, cfg).lstrip()
new_line = indent + content.lstrip()
out_lines.append(rstrip_trailing_ws(new_line))
html_depth = max(0, base_html_depth + html_net + html_pre_dedent)
if raw_open and (raw_open.lower() in RAW_ELEMENTS):
in_raw = raw_open.lower()
perl_depth = max(0, base_perl_depth + perl_delta_after)
result = "\n".join(out_lines)
if not result.endswith("\n"):
result += "\n"
eol_mode = cfg.eol if cfg.eol != "preserve" else original_eol
result = normalize_eol(result, eol_mode)
return result
def read_text(path: Path) -> str:
with path.open("rb") as f:
raw = f.read()
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode(errors="replace")
def write_text(path: Path, text: str) -> None:
with path.open("wb") as f:
f.write(text.encode("utf-8"))
def is_supported_file(path: Path, exts: Tuple[str, ...]) -> bool:
name = path.name.lower()
return any(name.endswith(ext) for ext in exts)
def iter_files(paths: List[str], exts: Tuple[str, ...]) -> Iterable[Path]:
for p in paths:
pth = Path(p)
if pth.is_dir():
for root, _, files in os.walk(pth):
for fn in files:
fp = Path(root) / fn
if is_supported_file(fp, exts):
logger.debug("Found file: %s", fp)
yield fp
else:
if is_supported_file(pth, exts):
logger.debug("Found file: %s", pth)
yield pth
def unified_diff(a: str, b: str, path: Path) -> str:
a_lines = a.splitlines(keepends=True)
b_lines = b.splitlines(keepends=True)
return "".join(
difflib.unified_diff(
a_lines, b_lines, fromfile=str(path), tofile=str(path) + " (formatted)"
)
)
def process_file(path: Path, cfg: Config, write: bool, show_diff: bool, backup: bool = False) -> Tuple[bool, str]:
original = read_text(path)
formatted = format_string(original, cfg)
changed = original != formatted
if changed:
logger.info("Formatted: %s", path)
if show_diff:
sys.stdout.write(unified_diff(original, formatted, path))
if write:
if backup:
bak_path = path.with_name(path.name + ".bak")
write_text(bak_path, original)
logger.info("Backup written: %s", bak_path)
write_text(path, formatted)
logger.info("Overwritten: %s", path)
else:
logger.info("Unchanged: %s", path)
return changed, formatted
def process_stdin_stdout(cfg: Config) -> int:
data = sys.stdin.read()
formatted = format_string(data, cfg)
sys.stdout.write(formatted)
logger.info("Formatted stdin to stdout")
return 0
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Format Mojolicious templates (.ep, .htm.ep, .html.ep)")
p.add_argument("paths", nargs="*", help="Files or directories")
p.add_argument("-w", "--write", action="store_true", help="Overwrite files in place (writes a .bak backup)")
p.add_argument("-o", "--out", help="Write formatted output to this file (single input file or --stdin). Conflicts with --write/--check/--diff")
p.add_argument("--check", action="store_true", help="Exit non-zero if any file would change")
p.add_argument("--diff", action="store_true", help="Print unified diff for changes")
p.add_argument("--stdin", action="store_true", help="Read from stdin")
p.add_argument("--stdout", action="store_true", help="Write to stdout (with --stdin)")
p.add_argument("--perltidy", help="Path to perltidy executable (defaults to PATH)")
p.add_argument("--indent", type=int, help="Indent width (spaces, default 2)")
p.add_argument("--eol", choices=["lf", "crlf", "preserve"], default="lf", help="EOL handling (default lf)")
p.add_argument("--no-space-in-delims", action="store_true", help="Do not normalize spaces inside <%% %%> delimiters")
p.add_argument("--perl-keyword-spacing", action="store_true", help="Aggressively insert a space after Perl keywords (if(...)->if (...), my$->my $, return(...)->return (...), etc.)")
p.add_argument("--self-test", dest="self_test", action="store_true", help="Run internal sanity checks and exit 0/1")
p.add_argument("--log-level", choices=["error", "info", "debug"], help="Logging level (default error)")
p.add_argument("--verbose", action="store_true", help="Shorthand for --log-level info")
p.add_argument("--version", action="store_true", help="Print version and exit")
return p
def self_test(cfg: Config) -> int:
failures: List[str] = []
def check(name: str, cond: bool, detail: Optional[str] = None):
if not cond:
failures.append(name + (": " + detail if detail else ""))
# T0: perltidy availability and behavior
ok, msg = perltidy_probe(cfg)
if not ok:
failures.append("perltidy: " + msg)
else:
logger.info(msg)
# T1: idempotence on a mixed template
src_a = "% if (1) {\n<ul>\n% for my $i (1..2) {\n<li><%= $i %></li>\n% }\n</ul>\n% }\n"
fmt_a1 = format_string(src_a, cfg)
fmt_a2 = format_string(fmt_a1, cfg)
check("idempotence", fmt_a1 == fmt_a2)
# T2: chomp markers preserved
src_b = "<li><%= $title -%>\n<%= $sub %></li>\n"
fmt_b = format_string(src_b, cfg)
check("chomp presence", "-%>" in fmt_b)
check("no-left-chomp-added", "<%-" not in fmt_b)
# T3: raw element inner content unchanged
src_c = "<script>\n var x=1; // keep spacing\nif(true){console.log(x)}\n</script>\n"
fmt_c = format_string(src_c, cfg)
c_lines = src_c.splitlines()
f_lines = fmt_c.splitlines()
if len(c_lines) >= 3 and len(f_lines) >= 3:
check("raw inner unchanged", c_lines[1:-1] == f_lines[1:-1], detail=f"got {f_lines[1:-1]!r}")
else:
check("raw structure", False, "unexpected line count")
# T4: delimiter spacing normalization for <% %>
src_d = "<%my $x=1;%>\n"
fmt_d = format_string(src_d, cfg)
check("delimiter spacing", "<% " in fmt_d and "%>" in fmt_d)
# T5: keyword spacing with flag on
cfg_kw = dc_replace(cfg, perl_keyword_spacing=True)
fmt_k1 = format_string("<% if($x){ %>\n", cfg_kw)
check("kw if(...)", "if (" in fmt_k1 and " {" in fmt_k1)
fmt_k2 = format_string("<%= return(1) %>\n", cfg_kw)
check("kw return(...)", "return (" in fmt_k2)
fmt_k3 = format_string('<% say"hi"; %>\n', cfg_kw)
check("kw say \"...\"", 'say "' in fmt_k3)
fmt_k4 = format_string("<% my($x,$y)=@_; %>\n", cfg_kw)
check("kw my $", "my (" in fmt_k4 and " = @_" in fmt_k4)
fmt_k5 = format_string("<% sub foo{ %>\n", cfg_kw)
check("kw sub foo {", "sub foo {" in fmt_k5)
if failures:
logger.error("SELF-TEST FAILURES:")
for f in failures:
logger.error(" - %s", f)
return 1
logger.info("Self-test passed")
return 0
def main(argv: Optional[List[str]] = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
setup_logging(args.log_level, args.verbose)
if args.version:
print(f"mojofmt {VERSION}")
return 0
if args.self_test:
cfg = load_config(args)
return self_test(cfg)
# Validate --out usage
if args.out:
if args.write or args.check or args.diff:
parser.error("--out conflicts with --write/--check/--diff")
cfg = load_config(args)
out_path = Path(args.out)
if args.stdin:
data = sys.stdin.read()
formatted = format_string(data, cfg)
write_text(out_path, formatted)
logger.info("Wrote %s (from stdin)", out_path)
return 0
# must be exactly one input file
if not args.paths or len(args.paths) != 1:
parser.error("--out requires exactly one input file (or use --stdin)")
in_path = Path(args.paths[0])
original = read_text(in_path)
formatted = format_string(original, cfg)
write_text(out_path, formatted)
logger.info("Wrote %s (from %s)", out_path, in_path)
return 0
cfg = load_config(args)
if args.stdin:
return process_stdin_stdout(cfg)
if not args.paths:
parser.error("No input paths provided (or use --stdin).")
any_changed = False
any_error = False
for path in iter_files(args.paths, cfg.extensions):
try:
changed, _ = process_file(path, cfg, write=args.write, show_diff=args.diff, backup=args.write)
any_changed = any_changed or changed
except Exception as e:
any_error = True
logger.error("Error processing %s: %s", path, e)
if args.check and any_changed:
return 1
return 1 if any_error else 0
if __name__ == "__main__":
sys.exit(main())

273
website.py Normal file
View File

@@ -0,0 +1,273 @@
import os
import tempfile
import subprocess
from flask import (
Flask, request, render_template_string,
jsonify, send_file, redirect, url_for, flash
)
from werkzeug.utils import secure_filename
from flask_talisman import Talisman
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from functools import wraps
from dotenv import load_dotenv
# Load environment variables from .env (for local dev only)
load_dotenv()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py")
# Get secrets from environment variables
SECRET_KEY = os.environ.get('FLASK_SECRET_KEY')
API_TOKEN = os.environ.get('FLASK_API_TOKEN')
if not SECRET_KEY or not API_TOKEN:
raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set")
app = Flask(__name__)
app.secret_key = SECRET_KEY
# Secure cookies
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True, # requires HTTPS in production
SESSION_COOKIE_SAMESITE='Lax'
)
# Security headers with FlaskTalisman (CSP allowing only self + cdn.jsdelivr.net)
csp = {
'default-src': ["'self'"],
'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
}
Talisman(app, content_security_policy=csp)
# Rate limiting
limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["100/hour"])
# Token authentication decorator
def require_api_token(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer ') or auth[len('Bearer '):] != API_TOKEN:
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated
# ----------------------------- HTML TEMPLATE -----------------------------
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Mojolicious Template Code Formatter</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0;
background:linear-gradient(90deg, #fdeff9 0%, #ecb6ff 100%);
height:100vh; display:flex; flex-direction:column; }
header { background:#8247c2; color:#fff; display:flex; align-items:center;
justify-content:space-between; padding:1em; }
header h1 { margin:0; font-size:1.6em; }
.icon-links { display:flex; align-items:center; }
.icon-links a { display:inline-flex; align-items:center; margin-left:16px; color:white; text-decoration:none; }
.icon-links a svg { width:28px; height:28px; fill:white; }
.flash-messages ul { margin:0; padding:0; list-style:none; color:#d91454; text-align:center; }
form { flex:1; display:flex; flex-direction:column; margin:0; }
.container { display:flex; flex-direction:row; gap:16px; padding:20px; flex:1;
box-sizing:border-box; height:calc(100vh - 140px); }
.panel { background:#fff; border-radius:10px; box-shadow:0 8px 18px -7px #ac83ce44;
padding:22px; flex:1 1 50%; min-width:300px; display:flex; flex-direction:column; height:100%; }
label { font-weight:bold; margin-bottom:5px; }
.controls { display:flex; gap:8px; margin:14px 0 8px 0; }
button { background:#a950e6; border:none; color:#fff; border-radius:5px;
padding:9px 16px; font-size:15px; cursor:pointer; box-shadow:0 2px 7px -3px #bb76c1; }
button:hover { background:#7634a2; }
input[type="file"] { margin-bottom:10px; }
textarea { width:100%; flex:1 1 auto; min-height:0; font-family:'Fira Mono', monospace;
font-size:15px; border:2px solid #bdb0da; background:#f6eafe;
border-radius:7px; padding:8px; color:#432d67; resize:vertical; }
select { padding:6px 10px; border-radius:5px; border:1px solid #b993d6;
background:#eee0f6; color:#6d378d; font-size:15px; }
#output_block, #output_code, pre[class*="language-"], code[class*="language-"] {
font-family:'Fira Mono', monospace !important; font-size:15px !important; line-height:1 !important;
}
#output_block { background:#16151a !important; color:white !important; border-radius:8px; padding:1em;
margin-top:10px; overflow-y:auto; resize:vertical; white-space:pre-wrap;
border:2px solid #bdb0da; flex:1 1 auto; min-height:0; }
@media (max-width:800px) {
.container { flex-direction:column; height:auto; }
.panel { height:auto; min-width:0; }
}
</style>
<link href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-perl.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-markup.min.js"></script>
</head>
<body>
<header>
<h1>Mojolicious Template Code Formatter</h1>
<div class="icon-links">
<a href="https://github.com/brianread108" target="_blank" aria-label="GitHub">
<svg viewBox="0 0 16 16"><path fill-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8a8..."/></svg>
</a>
<a href="https://mojolicious.org" target="_blank" aria-label="Mojolicious">
<svg viewBox="0 0 64 64"><path d="M32 2C20 18..."/></svg>
</a>
</div>
</header>
<div class="flash-messages">
{% with messages = get_flashed_messages() %}
{% if messages %}<ul>{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% endwith %}
</div>
<form method="post" action="/" enctype="multipart/form-data">
<div class="container">
<div class="panel">
<label for="input_text">Input data:</label>
<textarea name="input_text" id="input_text">{{ input_text|default('') }}</textarea>
<label for="input_file">Upload a file:</label>
<input type="file" name="input_file" id="input_file" accept=".txt,.mojo,.pl,.html,.tmpl,.tt,.tt2,.template,text/plain">
<div class="controls">
<button type="submit" name="action" value="format">Format</button>
<button type="submit" name="action" value="download">Download</button>
<button type="button" onclick="clearFields()">Clear</button>
</div>
</div>
<div class="panel">
<label>Formatted Output:</label>
<div style="margin-top:8px;">
<label for="syntaxmode">Output Syntax:</label>
<select id="syntaxmode" onchange="highlightOutput()">
<option value="none">Plain Text</option>
<option value="perl">Perl</option>
<option value="html">HTML</option>
</select>
</div>
<pre id="output_block"><code id="output_code" class="language-none">{% if formatted_text is defined %}{{ formatted_text|e }}{% endif %}</code></pre>
</div>
</div>
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const inputTextEl = document.getElementById("input_text");
const inputFileEl = document.getElementById("input_file");
const outputCodeEl = document.getElementById("output_code");
function clearOutput() {
outputCodeEl.textContent = '';
Prism.highlightElement(outputCodeEl);
}
function clearFields() {
inputTextEl.value = '';
clearOutput();
}
window.clearFields = clearFields;
inputTextEl.addEventListener("input", clearOutput);
inputFileEl.addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
inputTextEl.value = ev.target.result;
clearOutput();
};
reader.readAsText(file);
});
highlightOutput();
});
function highlightOutput() {
const mode = document.getElementById("syntaxmode").value;
const code = document.getElementById("output_code");
code.className = "";
if (mode === "perl") code.classList.add("language-perl");
else if (mode === "html") code.classList.add("language-markup");
else code.classList.add("language-none");
Prism.highlightElement(code);
}
</script>
</body>
</html>
"""
# ----------------------------- Core logic -----------------------------
def run_mojofmt(input_text: str) -> str:
with tempfile.TemporaryDirectory() as tmpdir:
in_path = os.path.join(tmpdir, "input.txt")
out_path = os.path.join(tmpdir, "output.txt")
with open(in_path, 'w', encoding='utf-8') as f:
f.write(input_text)
result = subprocess.run(
['python3', MOJO_FMT_PATH, '-o', out_path, in_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if result.returncode != 0:
raise RuntimeError(f"mojofmt failed:\n{result.stderr.strip()}")
with open(out_path, 'r', encoding='utf-8') as f:
return f.read()
@app.route("/", methods=["GET", "POST"])
def index():
input_text, formatted_text = "", None
if request.method == "POST":
action = request.form.get("action")
f_obj = request.files.get("input_file")
input_text = request.form.get("input_text", "")
if f_obj and f_obj.filename:
try:
input_text = f_obj.read().decode('utf-8')
except Exception as e:
flash(f"Error reading uploaded file: {e}")
return render_template_string(HTML_TEMPLATE, input_text=input_text)
if action in ("format", "download"):
if not input_text.strip():
flash("No input data provided.")
return render_template_string(HTML_TEMPLATE, input_text=input_text)
try:
formatted_text = run_mojofmt(input_text)
except RuntimeError as e:
flash(str(e))
return render_template_string(HTML_TEMPLATE, input_text=input_text)
if action == "download":
tmpfile = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8', suffix='.txt')
tmpfile.write(formatted_text)
tmpfile.close()
return redirect(url_for('download_file', filename=os.path.basename(tmpfile.name)))
return render_template_string(HTML_TEMPLATE, input_text=input_text, formatted_text=formatted_text)
@app.route("/download/<filename>")
def download_file(filename):
safe_name = secure_filename(filename)
path = os.path.join(tempfile.gettempdir(), safe_name)
if not os.path.exists(path):
return "File not found", 404
resp = send_file(path, as_attachment=True, download_name="formatted_output.txt")
try:
os.unlink(path)
except Exception:
pass
return resp
@app.route("/api/format", methods=["POST"])
@limiter.limit("10/minute")
@require_api_token
def api_format():
if not request.is_json:
return jsonify({"error": "JSON body required"}), 400
data = request.get_json()
text = data.get("text", "")
if not text:
return jsonify({"error": "Missing 'text'"}), 400
try:
formatted = run_mojofmt(text)
return jsonify({"formatted_text": formatted})
except RuntimeError as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True) # debug=False in prod