diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index f012c9a..4f0dfb2 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -17,7 +17,7 @@ from fastapi import HTTPException, Request
 from fastapi.responses import HTMLResponse, PlainTextResponse, Response
 
 from pygitweb.config import BLOB_LANG, settings
-from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
+from pygitweb.formatting import age_string, sanitize, to_utf8
 from pygitweb.git_helpers import (
 	get_blob_at_ref_path,
 	get_blob_unified_diff,
@@ -37,7 +37,7 @@ from pygitweb.git_helpers import (
 	parse_tag,
 )
 from pygitweb.projects import git_get_project_owner
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
 
 
@@ -115,22 +115,22 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
 
 	commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
 	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
-	author_display = esc_html(author) or "unknown"
+	author_display = jinja_escape(author) or "unknown"
 
 	if short:
 		return [
-			f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
-			esc_html(subject),
+			f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
+			jinja_escape(subject),
 			author_display,
-			esc_html(age_str),
+			jinja_escape(age_str),
 			f'<a href="{diff_link}">diff</a>',
 		]
 	return [
-		f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
-		esc_html(subject),
+		f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
+		jinja_escape(subject),
 		author_display,
-		esc_html(date_str),
-		esc_html(age_str),
+		jinja_escape(date_str),
+		jinja_escape(age_str),
 		f'<a href="{diff_link}">diff</a>',
 	]
 
@@ -201,7 +201,7 @@ def _render_readme_card(
 	return (
 		'<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
 		+ '<div class="card-body"><pre class="readme-plain"><code>'
-		+ (esc_html(sanitize(readme_content)) or "")
+		+ (jinja_escape(sanitize(readme_content)) or "")
 		+ "</code></pre></div></div>"
 	)
 
@@ -313,24 +313,24 @@ def git_object(project: str, h: str | None) -> Response:
 			raise HTTPException(status_code=404, detail="Not a blob")
 		data = obj.data
 		text = to_utf8(data) or ""
-		body = esc_html(sanitize(text) or "") or ""
+		body = jinja_escape(sanitize(text) or "") or ""
 		lines = text.split("\n")
 		num_lines = max(1, len(lines))
 		line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
 		oid_short = str(obj.id)[:7]
 		blob_html = (
 			f'<div class="blob-view">'
-			f'<div class="blob-line-nums" aria-hidden="true">{esc_html(line_nums)}</div>'
+			f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
 			f'<pre class="blob-content"><code class="hljs">{body}</code></pre>'
 			f"</div>"
 		)
 		pre = PREAMBLE.render(
-			title=f"Blob {oid_short} - {esc_html(project)}",
+			title=f"Blob {oid_short} - {project}",
 			site_name=settings.SITE_NAME,
 		)
 		return HTMLResponse(
-			f"{pre}<h1>Blob {esc_html(oid_short)}</h1>"
-			f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
+			f"{pre}<h1>Blob {jinja_escape(oid_short)}</h1>"
+			f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
 			f"{blob_html}{POSTAMBLE}"
 		)
 	raise HTTPException(status_code=404, detail="Unknown object type")
@@ -356,7 +356,7 @@ def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTML
 		board_value = f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
 
 	rows: list[list[str]] = [
-		["Owner", esc_html(owner) or ""],
+		["Owner", jinja_escape(owner) or ""],
 		[
 			(
 				'<label for="summary-ref-select">Ref</label> '
@@ -383,12 +383,12 @@ def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTML
 		cols=["Field", "Value"],
 		rows=rows,
 	)
-	pre = PREAMBLE.render(title=f"{esc_html(settings.SITE_NAME)} - {project}", site_name=settings.SITE_NAME)
-	body_parts = [f"{pre}<h1>{esc_html(project)}</h1>"]
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - {project}", site_name=settings.SITE_NAME)
+	body_parts = [f"{pre}<h1>{jinja_escape(project)}</h1>"]
 	if descr:
-		body_parts.append(f"<p>{esc_html(descr)}</p>")
+		body_parts.append(f"<p>{jinja_escape(descr)}</p>")
 	body_parts.append(table)
-	body_parts.append(f'<div id="summary-readme-container" data-project="{esc_html(project)}">')
+	body_parts.append(f'<div id="summary-readme-container" data-project="{jinja_escape(project)}">')
 	body_parts.append(f"{initial_ref_state['readme_html']}</div>")
 	body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')
 
@@ -400,19 +400,19 @@ def git_remotes(project: str) -> HTMLResponse:
 	"""Remotes page: list configured remotes (name, url, push_url)."""
 	remotes = git_get_remotes_info(project)
 	if not remotes:
-		pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=settings.SITE_NAME)
+		pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
 		return HTMLResponse(f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}")
 	rows = []
 	for r in remotes:
-		name = esc_html(r["name"])
-		url = esc_html(r["url"] or "—")
-		push_url = esc_html(r["push_url"] or "—")
+		name = jinja_escape(r["name"])
+		url = jinja_escape(r["url"] or "—")
+		push_url = jinja_escape(r["push_url"] or "—")
 		rows.append([name, url, push_url])
 	table = env.get_template("table.html").render(
 		cols=["Name", "URL", "Push URL"],
 		rows=rows,
 	)
-	pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=settings.SITE_NAME)
+	pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
 
 
@@ -437,7 +437,7 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 				media_type = "application/octet-stream"
 		return Response(content=data, media_type=media_type)
 	text = to_utf8(data) or ""
-	body = esc_html(sanitize(text) or "") or ""
+	body = jinja_escape(sanitize(text) or "") or ""
 	lines = text.split("\n")
 	num_lines = max(1, len(lines))
 	line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
@@ -446,12 +446,12 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 	lang_attr = f" language-{lang}" if lang else ""
 	blob_html = (
 		f'<div class="blob-view">'
-		f'<div class="blob-line-nums" aria-hidden="true">{esc_html(line_nums)}</div>'
+		f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
 		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
 		f"</div>"
 	)
 	blob_script = '<script src="/static/blob-view.js"></script>'
-	pre = PREAMBLE.render(title=f"{esc_html(f)} - {esc_html(project)}", site_name=settings.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{f} - {project}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
 
 
@@ -464,12 +464,12 @@ def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 		raise HTTPException(status_code=404, detail="Tree or path not found")
 	tree, ref_oid = result
 	base = f"/project/{project}"
-	breadcrumbs = [f'<a href="{base}">{esc_html(project)}</a>']
+	breadcrumbs = [f'<a href="{base}">{jinja_escape(project)}</a>']
 	if f:
 		parts = f.strip("/").split("/")
 		for i, seg in enumerate(parts):
 			prefix = "/".join(parts[: i + 1])
-			breadcrumbs.append(f' / <a href="{_tree_url(project, ref_oid, prefix)}">{esc_html(seg)}</a>')
+			breadcrumbs.append(f' / <a href="{_tree_url(project, ref_oid, prefix)}">{jinja_escape(seg)}</a>')
 	breadcrumb_html = "".join(breadcrumbs)
 	entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
 	dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
@@ -478,18 +478,18 @@ def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	for name, _, _ in dirs:
 		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
 		link = _tree_url(project, ref_oid, sub_path)
-		rows.append([f'<a href="{link}">{esc_html(name)}/</a>', "tree"])
+		rows.append([f'<a href="{link}">{jinja_escape(name)}/</a>', "tree"])
 	for name, _, _ in blobs:
 		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
 		link = _tree_url(project, ref_oid, sub_path, a="blob")
-		rows.append([f'<a href="{link}">{esc_html(name)}</a>', "blob"])
+		rows.append([f'<a href="{link}">{jinja_escape(name)}</a>', "blob"])
 	title_path = f" / {f}" if f else ""
 	pre = PREAMBLE.render(
-		title=f"{esc_html(project)}{esc_html(title_path)} - Tree",
+		title=f"{jinja_escape(project)}{jinja_escape(title_path)} - Tree",
 		site_name=settings.SITE_NAME,
 	)
 	table = env.get_template("table.html").render(cols=["Name", "Type"], rows=rows)
-	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{esc_html(title_path)}</h1>{table}"]
+	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{jinja_escape(title_path)}</h1>{table}"]
 	readme = get_readme_at_ref_path(project, ref_oid, f or "")
 	if readme:
 		readme_filename, readme_content = readme
@@ -511,7 +511,7 @@ def git_log(project: str, h: str | None, request: Request, page: int, pagecount:
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"Log - {esc_html(project)} @ {esc_html(ref_display)}"
+	title = f"Log - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"], rows=rows
@@ -533,7 +533,7 @@ def git_shortlog(project: str, h: str | None, request: Request, page: int, pagec
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=True))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"Shortlog - {esc_html(project)} @ {esc_html(ref_display)}"
+	title = f"Shortlog - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(cols=["Commit", "Subject", "Author", "Age", "Diff"], rows=rows)
 	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
@@ -564,7 +564,7 @@ def git_history(
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"History - {esc_html(f)} - {esc_html(project)} @ {esc_html(ref_display)}"
+	title = f"History - {jinja_escape(f)} - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"], rows=rows
@@ -572,7 +572,7 @@ def git_history(
 	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
 	return HTMLResponse(
 		f"{pre}<h1>{title}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
+		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
 		f"{table}{pagination_html}{POSTAMBLE}"
 	)
 
@@ -588,18 +588,20 @@ def git_heads(project: str) -> HTMLResponse:
 		commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
 		tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
 		rows.append([
-			f'<a href="{commit_link}">{esc_html(name)}</a>',
-			f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
+			f'<a href="{commit_link}">{jinja_escape(name)}</a>',
+			f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
 			f'<a href="{tree_link}">tree</a>',
 		])
-	title = f"Heads - {esc_html(project)}"
+	title = f"Heads - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Head", "Commit", ""],
 		rows=rows,
 	)
 	return HTMLResponse(
-		f"{pre}<h1>{title}</h1><p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+		f"{pre}<h1>{title}</h1>"
+		+ f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>{table}"
+		+ POSTAMBLE
 	)
 
 
@@ -637,12 +639,14 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
 			target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
 		else:
 			target_link = None
-		obj_cell = f'<a href="{target_link}">{esc_html(target_short)}</a>' if target_link else esc_html(target_short)
+		obj_cell = (
+			f'<a href="{target_link}">{jinja_escape(target_short)}</a>' if target_link else jinja_escape(target_short)
+		)
 		rows.append([
-			f'<a href="{tag_link}">{esc_html(name)}</a>',
+			f'<a href="{tag_link}">{jinja_escape(name)}</a>',
 			obj_cell,
 		])
-	title = f"Tags - {esc_html(project)}"
+	title = f"Tags - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Tag", "Object"],
@@ -653,7 +657,7 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
 	)
 	return HTMLResponse(
 		f"{pre}<h1>{title}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
+		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
 		f"{table}{pagination_html}{POSTAMBLE}"
 	)
 
@@ -691,26 +695,28 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
 	target_short = target_oid[:7] if target_oid else ""
 	if target_type == "commit":
 		object_link = (
-			f'<a href="/project/{project}?a=commit&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+			f'<a href="/project/{project}?a=commit&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
 		)
 	elif target_type == "tree":
-		object_link = f'<a href="/project/{project}?a=tree&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+		object_link = (
+			f'<a href="/project/{project}?a=tree&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
+		)
 	else:
-		object_link = esc_html(target_short) or ""
+		object_link = jinja_escape(target_short) or ""
 	table_rows = [
-		["Tag", esc_html(tag_name)],
-		["Object", f"{object_link} ({esc_html(target_type)})"],
-		["Tagger", esc_html(tagger)],
-		["Date", esc_html(_format_date(tagger_epoch, tagger_tz))],
+		["Tag", jinja_escape(tag_name)],
+		["Object", f"{object_link} ({jinja_escape(target_type)})"],
+		["Tagger", jinja_escape(tagger)],
+		["Date", jinja_escape(_format_date(tagger_epoch, tagger_tz))],
 	]
 	if message:
-		table_rows.append(["Message", f"<pre class='tag-message'>{esc_html(message)}</pre>"])
+		table_rows.append(["Message", f"<pre class='tag-message'>{jinja_escape(message)}</pre>"])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-	title = f"Tag {esc_html(tag_name)} - {esc_html(project)}"
+	title = f"Tag {jinja_escape(tag_name)} - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	return HTMLResponse(
-		f"{pre}<h1>Tag {esc_html(tag_name)}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
+		f"{pre}<h1>Tag {jinja_escape(tag_name)}</h1>"
+		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
 		f"{table}{POSTAMBLE}"
 	)
 
@@ -742,26 +748,26 @@ def git_commit(project: str, h: str | None) -> HTMLResponse:
 	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
 	patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
 	table_rows = [
-		["commit", f'<a href="{commit_link}">{esc_html(oid)}</a>'],
-		["Author", f"{esc_html(author)} &lt;{esc_html(author_email)}&gt;"],
-		["Date", esc_html(author_date)],
-		["tree", f'<a href="{tree_link}">{esc_html(tree_oid[:7])}</a>'],
+		["commit", f'<a href="{commit_link}">{jinja_escape(oid)}</a>'],
+		["Author", f"{jinja_escape(author)} &lt;{jinja_escape(author_email)}&gt;"],
+		["Date", jinja_escape(author_date)],
+		["tree", f'<a href="{tree_link}">{jinja_escape(tree_oid[:7])}</a>'],
 	]
 	for i, parent_id in enumerate(commit.parent_ids):
 		parent_oid = str(parent_id)
 		parent_short = parent_oid[:7]
 		parent_link = f"/project/{project}?a=commit&h={quote(parent_oid, safe='')}"
 		label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
-		table_rows.append([label, f'<a href="{parent_link}">{esc_html(parent_short)}</a>'])
+		table_rows.append([label, f'<a href="{parent_link}">{jinja_escape(parent_short)}</a>'])
 	if message:
-		table_rows.append(["", f"<pre class='commit-message'>{esc_html(message)}</pre>"])
+		table_rows.append(["", f"<pre class='commit-message'>{jinja_escape(message)}</pre>"])
 	table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-	title = f"Commit {esc_html(oid_short)} - {esc_html(project)}"
+	title = f"Commit {jinja_escape(oid_short)} - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	return HTMLResponse(
-		f"{pre}<h1>Commit {esc_html(oid_short)}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
+		f"{pre}<h1>Commit {jinja_escape(oid_short)}</h1>"
+		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
 		f"{table}{POSTAMBLE}"
 	)
 
@@ -846,7 +852,7 @@ def git_blobdiff(
 	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
 	blob_link = f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
 	pre = PREAMBLE.render(
-		title=f"Blob diff - {esc_html(path_new)} - {esc_html(project)}",
+		title=f"Blob diff - {jinja_escape(path_new)} - {jinja_escape(project)}",
 		site_name=settings.SITE_NAME,
 	)
 	body = env.get_template("blobdiff.html").render(
diff --git a/pygitweb/formatting.py b/pygitweb/formatting.py
index ddbca09..2d0cff1 100644
--- a/pygitweb/formatting.py
+++ b/pygitweb/formatting.py
@@ -1,6 +1,6 @@
 """
-Formatting: escaping (esc_param, esc_path_info, esc_url, esc_attr, esc_html, esc_path, sanitize),
-quot_cec, quot_upr, unquote, untabify, to_utf8, chop_str, chop_and_escape_str, age_class, age_string.
+Formatting: escaping (esc_param, esc_path_info, esc_url, esc_path, sanitize),
+quot_cec, quot_upr, unquote, untabify, to_utf8, age_class, age_string.
 Ported from gitweb/gitweb.perl.
 """
 
@@ -61,20 +61,6 @@ def esc_url(s: str | None) -> str | None:
 	return quote(s, safe="-_.~()/:@!")
 
 
-def esc_attr(s: str | None) -> str | None:
-	"""Escape for HTML attribute. Port of esc_attr."""
-	if s is None:
-		return None
-	return html.escape(s, quote=True)
-
-
-def esc_html(s: str | None) -> str | None:
-	"""Escape for HTML body. Port of esc_html."""
-	if s is None:
-		return None
-	return html.escape(s, quote=False)
-
-
 def quot_cec(char: str, nohtml: bool = False) -> str:
 	"""Printable representation of control char (CEC). Port of quot_cec."""
 	out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
@@ -166,71 +152,6 @@ def untabify(line: str, tabwidth: int = 8) -> str:
 	return "".join(result)
 
 
-def chop_str(
-	s: str,
-	length: int,
-	add_len: int = 10,
-	where: str = "right",
-) -> str:
-	"""Chop on word boundary between length and length+add_len. Port of chop_str."""
-	s = to_utf8(s) or ""
-	if where == "center":
-		if length + 5 >= len(s):
-			return s
-		half = length // 2
-		endre = re.compile(rf".{{{half}}}\w{{0,{add_len}}}", re.DOTALL)
-		begre = re.compile(rf"\w{{0,{add_len}}}.{{{half}}}$", re.DOTALL)
-		m1 = re.match(rf"^(.{{{half}}}\w{{0,{add_len}}})(.*)$", s, re.DOTALL)
-		if not m1:
-			return s[: length // 2] + " ... " + s[-(length // 2) :]
-		left, rest = m1.group(1), m1.group(2)
-		m2 = re.match(rf"^(.*?)(\w{{0,{add_len}}}.{{{half}}})$", rest, re.DOTALL)
-		if not m2:
-			return left + " ... " + rest[-half:]
-		mid, right = m2.group(1), m2.group(2)
-		if len(mid) > 5:
-			mid = " ... "
-		return left + mid + right
-	if length + 4 >= len(s):
-		return s
-	if where == "left":
-		begre = re.compile(rf"\w{{0,{add_len}}}.{{{length}}}$")
-		m = begre.search(s)
-		if m:
-			body = m.group(0)
-			lead = s[: m.start()]
-			if len(lead) > 4:
-				lead = " ..."
-			return lead + body
-		return s
-	# right
-	endre = re.compile(rf".{{{length}}}\w{{0,{add_len}}}")
-	m = endre.match(s)
-	if m:
-		body = m.group(0)
-		tail = s[m.end() :]
-		if len(tail) > 4:
-			tail = "... "
-		return body + tail
-	return s
-
-
-def chop_and_escape_str(
-	s: str,
-	length: int,
-	add_len: int = 10,
-	where: str = "right",
-) -> str:
-	"""Chop then HTML-escape; wrap in span with title if chopped. Port of chop_and_escape_str."""
-	chopped = chop_str(s, length, add_len, where)
-	s = to_utf8(s) or s
-	if chopped == s:
-		return esc_html(chopped) or ""
-	title = esc_attr(s.replace("\n", " ").replace("\r", "?"))
-	escaped = esc_html(chopped) or ""
-	return f'<span title="{title}">{escaped}</span>'
-
-
 def age_class(age_seconds: float | None) -> str:
 	"""CSS class for age. Port of age_class."""
 	if age_seconds is None:
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 93738e2..b50b3ef 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -43,7 +43,7 @@ from pygitweb.api.plugins.project_zip import ProjectZip
 from pygitweb.api.project import Project
 from pygitweb.api.subpage import Subpage
 from pygitweb.config import ACTIONS, get_loadavg, settings
-from pygitweb.formatting import age_string, esc_html
+from pygitweb.formatting import age_string
 from pygitweb.git_helpers import git_get_references, git_get_type
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.settings import router as settings_router
@@ -54,7 +54,7 @@ from pygitweb.tasks import (
 	get_board_tasks_grouped,
 	task_router,
 )
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.timeline_cache import get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_pathname, is_valid_project
 
@@ -181,7 +181,7 @@ def _project_subpage_rows(project: str) -> list[list[str]]:
 			continue
 		subpage_name = subpage.subpage_name()
 		rows.append([
-			esc_html(link_label),
+			jinja_escape(link_label) or "",
 			f'<a href="/project/{project_enc}/subpage/{quote(subpage_name, safe="")}">view</a>',
 		])
 	return rows
@@ -230,14 +230,14 @@ def git_project_list(
 		cols=["Project", "Description", "Board"],
 		rows=[
 			[
-				f"<a href='/project/{quote(pr.get('path', ''), safe='/')}'>{esc_html(pr.get('path', ''))}</a>",
-				esc_html(pr.get("descr") or pr.get("path", "")),
+				f"<a href='/project/{quote(pr.get('path', ''), safe='/')}'>{jinja_escape(pr.get('path', ''))}</a>",
+				jinja_escape(pr.get("descr") or pr.get("path", "")),
 				board_cell(pr),
 			]
 			for pr in list_[:50]
 		],
 	)
-	pre = PREAMBLE.render(title=f"{esc_html(settings.SITE_NAME)} - Projects", site_name=settings.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Projects", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<h1>Project List</h1>{table}{POSTAMBLE}")
 
 
@@ -285,9 +285,9 @@ def activity_page(
 		oid = event["oid"]
 		if event["kind"] == "commit":
 			event_label = f"<a href='/project/{quote(project_name, safe='/')}"
-			event_label += f"?a=commit&h={quote(oid, safe='')}'>{esc_html(oid[:7])}</a>"
+			event_label += f"?a=commit&h={quote(oid, safe='')}'>{jinja_escape(oid[:7])}</a>"
 		else:
-			event_label = esc_html(event["kind"]) or ""
+			event_label = jinja_escape(event["kind"]) or ""
 		description_raw = event.get("description")
 		if not description_raw:
 			description = ""
@@ -303,10 +303,10 @@ def activity_page(
 		except (ValueError, OSError):
 			activity_time = ""
 		rows.append([
-			esc_html(activity_time) or "",
-			f"<a href='/project/{quote(project_name, safe='/')}'>{esc_html(project_name)}</a>",
+			jinja_escape(activity_time) or "",
+			f"<a href='/project/{quote(project_name, safe='/')}'>{jinja_escape(project_name)}</a>",
 			event_label,
-			esc_html(description) or "",
+			jinja_escape(description) or "",
 		])
 	table = env.get_template("table.html").render(
 		cols=["Time", "Project Name", "Event", "Description"],
@@ -328,7 +328,7 @@ def activity_page(
 		total_pages=None,
 		page_links=None,
 	)
-	pre = PREAMBLE.render(title=f"{esc_html(settings.SITE_NAME)} - Activity", site_name=settings.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Activity", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<h1>Activity</h1>{table}{pagination_html}{POSTAMBLE}")
 
 
@@ -340,7 +340,7 @@ def git_opml():
 	lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
 	for pr in projects[:100]:
 		path = pr.get("path", "")
-		lines.append(f'<outline text="{esc_html(path)}" />')
+		lines.append(f'<outline text="{jinja_escape(path)}" />')
 	lines.append("</body></opml>")
 	return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
 
@@ -480,7 +480,7 @@ def project_board(
 		for t in col["tasks"]:
 			t["task_url"] = f"{board_url}?task={quote(t['ref'], safe='')}"
 	pre = PREAMBLE.render(
-		title=f"{esc_html(project)} - Board",
+		title=f"{jinja_escape(project)} - Board",
 		site_name=settings.SITE_NAME,
 	)
 	body = env.get_template("board.html").render(
@@ -523,7 +523,7 @@ def project_subpage(
 			raise HTTPException(status_code=404, detail="No content for subpage")
 		content_lc = content.lstrip().lower()
 		pre = PREAMBLE.render(
-			title=f"{esc_html(subpage.link_name(project) or subpage_name)} - {esc_html(project)}",
+			title=f"{jinja_escape(subpage.link_name(project) or subpage_name)} - {jinja_escape(project)}",
 			site_name=settings.SITE_NAME,
 		)
 		if content_lc.startswith("<!doctype html") or "<html" in content_lc:
@@ -537,11 +537,11 @@ def project_subpage(
 			subpage_enc = quote(subpage_name, safe="")
 			iframe_src = f"/project/{project_enc}/subpage/{subpage_enc}?raw=true"
 			return HTMLResponse(
-				f"{pre}<h1>{esc_html(project)}</h1>"
-				f'<iframe class="subpage-frame" src="{iframe_src}" title="{esc_html(subpage_name)}"></iframe>'
+				f"{pre}<h1>{jinja_escape(project)}</h1>"
+				f'<iframe class="subpage-frame" src="{iframe_src}" title="{jinja_escape(subpage_name)}"></iframe>'
 				f"{POSTAMBLE}"
 			)
-		return HTMLResponse(f"{pre}<h1>{esc_html(project)}</h1>{content}{POSTAMBLE}")
+		return HTMLResponse(f"{pre}<h1>{jinja_escape(project)}</h1>{content}{POSTAMBLE}")
 	raise HTTPException(status_code=404, detail="Unknown subpage")
 
 
@@ -631,8 +631,8 @@ def dispatch(
 	if action == "object":
 		return git_object(project, hash_param)
 	# Stub others with minimal response
-	pre = PREAMBLE.render(title=f"{esc_html(action)} - {esc_html(project)}", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<p>Action: {esc_html(action)}</p><p>Project: {esc_html(project)}</p>{POSTAMBLE}")
+	pre = PREAMBLE.render(title=f"{action} - {project}", site_name=settings.SITE_NAME)
+	return HTMLResponse(f"{pre}<p>Action: {jinja_escape(action)}</p><p>Project: {jinja_escape(project)}</p>{POSTAMBLE}")
 
 
 if __name__ == "__main__":
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index a6b88a5..ce73793 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -14,10 +14,9 @@ from fastapi import APIRouter, HTTPException, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.config import settings
-from pygitweb.formatting import esc_html
 from pygitweb.git_helpers import open_repo
 from pygitweb.projects import git_get_projects_list
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_project
 
 
@@ -56,7 +55,6 @@ def _render_form_field(name: str, value: Any, value_type: type, docstring: str, 
 	"""Generate HTML for a single form field based on type."""
 	field_name = f"{name_prefix}{name}" if name_prefix else name
 	safe_name = field_name.replace(".", "_")
-	hint = f'<small class="form-hint text-muted">{esc_html(docstring)}</small>'
 	tpl = env.get_template("settings_form_field.html")
 	if value_type is bool:
 		return tpl.render(
@@ -64,8 +62,8 @@ def _render_form_field(name: str, value: Any, value_type: type, docstring: str, 
 			safe_name=safe_name,
 			field_name=field_name,
 			name=name,
-			checked_attr=" checked" if value else "",
-			hint=hint,
+			checked=bool(value),
+			hint_text=docstring,
 		)
 	if value_type in (int, float):
 		raw = "" if value is None else str(value)
@@ -74,8 +72,8 @@ def _render_form_field(name: str, value: Any, value_type: type, docstring: str, 
 			safe_name=safe_name,
 			field_name=field_name,
 			name=name,
-			str_val=esc_html(raw) or "",
-			hint=hint,
+			str_val=raw,
+			hint_text=docstring,
 			number_step="any",
 		)
 	raw = "" if value is None else str(value)
@@ -84,8 +82,8 @@ def _render_form_field(name: str, value: Any, value_type: type, docstring: str, 
 		safe_name=safe_name,
 		field_name=field_name,
 		name=name,
-		str_val=esc_html(raw) or "",
-		hint=hint,
+		str_val=raw,
+		hint_text=docstring,
 	)
 
 
@@ -201,7 +199,6 @@ def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_
 	"""Generate HTML for a PyGit2 form field."""
 	field_name = f"{name_prefix}{name}"
 	safe_name = field_name.replace(".", "_")
-	hint = f'<small class="form-hint text-muted">{esc_html(docstring)}</small>'
 	tpl = env.get_template("settings_form_field.html")
 	if kind == "readonly":
 		raw = str(value) if value != "" else "(not set)"
@@ -210,8 +207,8 @@ def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_
 			safe_name=safe_name,
 			field_name=field_name,
 			name=name,
-			str_val=esc_html(raw) or "",
-			hint=hint,
+			str_val=raw,
+			hint_text=docstring,
 		)
 	if kind == "bool":
 		checked_attr = (
@@ -224,8 +221,8 @@ def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_
 			safe_name=safe_name,
 			field_name=field_name,
 			name=name,
-			checked_attr=checked_attr,
-			hint=hint,
+			checked=(checked_attr != ""),
+			hint_text=docstring,
 		)
 	if kind == "int":
 		raw = str(value) if value != "" and value is not None else ""
@@ -234,8 +231,8 @@ def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_
 			safe_name=safe_name,
 			field_name=field_name,
 			name=name,
-			str_val=esc_html(raw) or "",
-			hint=hint,
+			str_val=raw,
+			hint_text=docstring,
 		)
 	raw = str(value) if value is not None else ""
 	return tpl.render(
@@ -243,8 +240,8 @@ def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_
 		safe_name=safe_name,
 		field_name=field_name,
 		name=name,
-		str_val=esc_html(raw) or "",
-		hint=hint,
+		str_val=raw,
+		hint_text=docstring,
 	)
 
 
@@ -272,12 +269,12 @@ def _project_config_table_row(key: str, value: str, index: int) -> list[str]:
 	key_cell = (
 		'<input type="text" class="form-control form-control-sm" '
 		f'id="{key_name}" name="{key_name}" '
-		f'value="{esc_html(key)}" placeholder="e.g. user.name">'
+		f'value="{jinja_escape(key)}" placeholder="e.g. user.name">'
 	)
 	val_cell = (
 		'<input type="text" class="form-control form-control-sm" '
 		f'id="{val_name}" name="{val_name}" '
-		f'value="{esc_html(value)}" placeholder="value">'
+		f'value="{jinja_escape(value)}" placeholder="value">'
 	)
 	return [key_cell, val_cell]
 
@@ -394,15 +391,15 @@ def settings_project_page(request: Request, name: str):
 	rows.append(_project_config_table_row("", "", len(entries)))
 	table_html = env.get_template("table.html").render(cols=["Key", "Value"], rows=rows)
 	pre = PREAMBLE.render(
-		title=f"{settings.SITE_NAME} - Project settings: {esc_html(project)}",
+		title=f"{settings.SITE_NAME} - Project settings: {project}",
 		site_name=settings.SITE_NAME,
 	)
 	submit_url = f"/settings/project/{_quote_path(project)}/submit"
 	cancel_url = f"/project/{_quote_path(project)}"
 	body = env.get_template("project_settings.html").render(
-		project_title=esc_html(project),
-		submit_url=esc_html(submit_url),
-		cancel_url=esc_html(cancel_url),
+		project_title=project,
+		submit_url=submit_url,
+		cancel_url=cancel_url,
 		table_html=table_html,
 	)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
diff --git a/pygitweb/templates/project_settings.html b/pygitweb/templates/project_settings.html
index ce932ce..26bdb64 100644
--- a/pygitweb/templates/project_settings.html
+++ b/pygitweb/templates/project_settings.html
@@ -1,12 +1,12 @@
-<h1 class="page-title">Project settings: {{ project_title | safe }}</h1>
+<h1 class="page-title">Project settings: {{ project_title }}</h1>
 <p class="text-muted">Repository config (local and worktree only).</p>
 <div class="card">
   <div class="card-body">
-    <form action="{{ submit_url | safe }}" method="post" class="needs-validation" novalidate>
+    <form action="{{ submit_url }}" method="post" class="needs-validation" novalidate>
       {{ table_html | safe }}
       <div class="form-footer mt-3">
         <button type="submit" class="btn btn-primary">Save</button>
-        <a href="{{ cancel_url | safe }}" class="btn btn-ghost-secondary">Cancel</a>
+        <a href="{{ cancel_url }}" class="btn btn-ghost-secondary">Cancel</a>
       </div>
     </form>
   </div>
diff --git a/pygitweb/templates/settings_form_field.html b/pygitweb/templates/settings_form_field.html
index 01a6228..c557662 100644
--- a/pygitweb/templates/settings_form_field.html
+++ b/pygitweb/templates/settings_form_field.html
@@ -1,27 +1,27 @@
 {% if kind == "bool" %}
 <div class="mb-3">
   <div class="form-check">
-    <input type="checkbox" class="form-check-input" id="{{ safe_name }}" name="{{ field_name }}" value="on"{{ checked_attr | safe }}>
+    <input type="checkbox" class="form-check-input" id="{{ safe_name }}" name="{{ field_name }}" value="on"{% if checked %} checked{% endif %}>
     <label class="form-check-label" for="{{ safe_name }}">{{ name }}</label>
   </div>
-  {{ hint | safe }}
+  <small class="form-hint text-muted">{{ hint_text }}</small>
 </div>
 {% elif kind == "number" %}
 <div class="mb-3">
   <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
-  <input type="number" {% if number_step %}step="{{ number_step }}" {% endif %}class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val | safe }}">
-  {{ hint | safe }}
+  <input type="number" {% if number_step %}step="{{ number_step }}" {% endif %}class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
+  <small class="form-hint text-muted">{{ hint_text }}</small>
 </div>
 {% elif kind == "readonly" %}
 <div class="mb-3">
   <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
-  <input type="text" class="form-control bg-secondary" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val | safe }}" readonly>
-  {{ hint | safe }}
+  <input type="text" class="form-control bg-secondary" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}" readonly>
+  <small class="form-hint text-muted">{{ hint_text }}</small>
 </div>
 {% else %}
 <div class="mb-3">
   <label class="form-label" for="{{ safe_name }}">{{ name }}</label>
-  <input type="text" class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val | safe }}">
-  {{ hint | safe }}
+  <input type="text" class="form-control" id="{{ safe_name }}" name="{{ field_name }}" value="{{ str_val }}">
+  <small class="form-hint text-muted">{{ hint_text }}</small>
 </div>
 {% endif %}
diff --git a/pygitweb/templates_env.py b/pygitweb/templates_env.py
index 50cbd1c..1e3709a 100644
--- a/pygitweb/templates_env.py
+++ b/pygitweb/templates_env.py
@@ -1,11 +1,21 @@
 """Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
 
-from jinja2 import Environment, PackageLoader
+from jinja2 import Environment, PackageLoader, select_autoescape
 
 env = Environment(
 	loader=PackageLoader("pygitweb", "templates"),
+	autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True),
 	trim_blocks=True,
 	lstrip_blocks=True,
 )
 PREAMBLE = env.get_template("preamble.html")
 POSTAMBLE = """</div></div></div></body></html>"""
+
+
+_ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
+
+
+def jinja_escape(value: str | None) -> str | None:
+	if value is None:
+		return None
+	return _ESCAPE_TEMPLATE.render(value=value)
