diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index b5683dc..dbadc29 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -44,7 +44,17 @@ from pygitweb.git_helpers import (
 from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
 from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
 from pygitweb.projects import git_get_project_owner
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import (
+	POSTAMBLE,
+	PREAMBLE,
+	Cell,
+	env,
+	html_format,
+	html_join,
+	html_link,
+	jinja_escape,
+	trusted_html,
+)
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
 
 
@@ -199,7 +209,7 @@ def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
 		return ""
 
 
-def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list:
+def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list[Cell]:
 	"""Format a single commit as a table row."""
 	oid = commit.get("oid", "")
 	oid_short = oid[:7] if oid else ""
@@ -215,26 +225,15 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
 	else:
 		age_str = ""
 
-	commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
-	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
-	author_display = jinja_escape(author) or "unknown"
+	commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
+	diff_href = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
+	author_display = author or "unknown"
+	commit_cell = html_link(commit_href, oid_short)
+	diff_cell = html_link(diff_href, "diff")
 
 	if short:
-		return [
-			f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
-			jinja_escape(subject),
-			author_display,
-			jinja_escape(age_str),
-			f'<a href="{diff_link}">diff</a>',
-		]
-	return [
-		f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
-		jinja_escape(subject),
-		author_display,
-		jinja_escape(date_str),
-		jinja_escape(age_str),
-		f'<a href="{diff_link}">diff</a>',
-	]
+		return [commit_cell, subject, author_display, age_str, diff_cell]
+	return [commit_cell, subject, author_display, date_str, age_str, diff_cell]
 
 
 def _render_pagination(
@@ -301,19 +300,23 @@ def _render_tree_table(project: str, ref: str | None, f: str | None) -> str:
 	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")
 	blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
-	rows: list[list[str]] = []
+	rows: list[list[Cell]] = []
 	for name, _, _ in dirs:
+		if name is None:
+			continue
 		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}">{jinja_escape(name)}/</a>', "tree", ""])
+		href = _tree_url(project, ref_oid, sub_path)
+		rows.append([html_link(href, f"{name}/"), "tree", ""])
 	for name, _, _ in blobs:
+		if name is None:
+			continue
 		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
-		link = _tree_url(project, ref_oid, sub_path, a="blob")
-		blame_link = _tree_url(project, ref_oid, sub_path, a="blame")
+		href = _tree_url(project, ref_oid, sub_path, a="blob")
+		blame_href = _tree_url(project, ref_oid, sub_path, a="blame")
 		rows.append([
-			f'<a href="{link}">{jinja_escape(name)}</a>',
+			html_link(href, name),
 			"blob",
-			f'<a href="{blame_link}">blame</a>',
+			html_link(blame_href, "blame"),
 		])
 	if not rows:
 		return '<p class="text-muted mb-0">Empty directory.</p>'
@@ -570,26 +573,28 @@ def git_search_page(project: str) -> HTMLResponse:
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
-def _hooks_summary_cell(project: str, project_enc: str) -> str:
+def _hooks_summary_cell(project: str, project_enc: str) -> Cell:
 	"""Render the Hooks row: installed samples with versions and an install dropdown."""
 	tpl = env.get_template("hooks_summary_cell.html")
 	try:
 		installed = list_installed_hooks(project)
 		available = list_installable_hooks(project)
 	except (KeyError, OSError):
-		return tpl.render(mode="unavailable")
-	return tpl.render(
-		mode="manage",
-		project_enc=project_enc,
-		installed=installed,
-		available=available,
+		return trusted_html(tpl.render(mode="unavailable"))
+	return trusted_html(
+		tpl.render(
+			mode="manage",
+			project_enc=project_enc,
+			installed=installed,
+			available=available,
+		)
 	)
 
 
-def _merge_request_summary_cell(project: str) -> str:
+def _merge_request_summary_cell(project: str) -> Cell:
 	heads = git_get_heads_list(project)
 	if not heads:
-		return '<span class="text-muted">No branches.</span>'
+		return html_format('<span class="text-muted">{}</span>', "No branches.")
 	refs = [h[1] for h in heads]
 	branches = [{"short": h[0], "ref": h[1]} for h in heads]
 	if "refs/heads/main" in refs:
@@ -599,56 +604,72 @@ def _merge_request_summary_cell(project: str) -> str:
 	else:
 		ours_default_ref = refs[0]
 	theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
-	return env.get_template("merge_request_summary_cell.html").render(
-		project=project,
-		branches=branches,
-		theirs_default_ref=theirs_default_ref,
-		ours_default_ref=ours_default_ref,
+	return trusted_html(
+		env.get_template("merge_request_summary_cell.html").render(
+			project=project,
+			branches=branches,
+			theirs_default_ref=theirs_default_ref,
+			ours_default_ref=ours_default_ref,
+		)
 	)
 
 
-def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTMLResponse:
+def _project_has_boards(project: str) -> bool:
+	try:
+		return len(git_get_references(project, "refs/tags/boards")) > 0
+	except Exception:
+		return False
+
+
+def create_board_link(project: str) -> Cell:
+	href = "/board/create?project=" + quote(project, safe="")
+	if settings.AUTH:
+		return html_link(href, "create board", style="color: #999; cursor: not-allowed;")
+	return html_link(href, "create board")
+
+
+def project_board_link(project: str) -> Cell:
+	if _project_has_boards(project):
+		return html_link(f"/project/{quote(project, safe='/')}/board/", "project board")
+	return create_board_link(project)
+
+
+def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTMLResponse:
 	"""Project summary page. Port of git_summary."""
 	descr = git_get_project_description(project) or ""
 	owner = git_get_project_owner(project) or ""
 	initial_ref_state = summary_ref_state(project, "HEAD")
 	project_enc = quote(project, safe="/")
 
-	try:
-		board_refs = git_get_references(project, "refs/tags/boards")
-		has_boards = len(board_refs) > 0
-	except Exception:
-		has_boards = False
-	if has_boards:
-		board_value = f'<a href="/project/{project_enc}/board/">project board</a>'
-	else:
-		grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
-		proj_q = quote(project, safe="")
-		board_value = f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
-
-	rows: list[list[str]] = [
-		["Owner", jinja_escape(owner) or ""],
+	rows: list[list[Cell]] = [
+		["Owner", owner],
 		[
-			(
+			trusted_html(
 				'<label for="summary-ref-select">Ref</label> '
 				'<select id="summary-ref-select">'
 				'<option value="HEAD" selected>HEAD</option>'
 				"</select>"
 			),
-			(
-				f'<a id="summary-ref-commit-link" href="{initial_ref_state["commit_href"]}">'
-				f"{initial_ref_state['display']}</a>: "
-				f'<a id="summary-ref-browse-link" href="{initial_ref_state["browse_href"]}">browse</a> - '
-				f'<a id="summary-ref-log-link" href="{initial_ref_state["log_href"]}">log</a> - '
-				f'<a id="summary-ref-shortlog-link" href="{initial_ref_state["shortlog_href"]}">shortlog</a>'
-			),
+			html_join([
+				html_link(
+					initial_ref_state["commit_href"],
+					initial_ref_state["display"],
+					id="summary-ref-commit-link",
+				),
+				": ",
+				html_link(initial_ref_state["browse_href"], "browse", id="summary-ref-browse-link"),
+				" - ",
+				html_link(initial_ref_state["log_href"], "log", id="summary-ref-log-link"),
+				" - ",
+				html_link(initial_ref_state["shortlog_href"], "shortlog", id="summary-ref-shortlog-link"),
+			]),
 		],
-		["Branches", f"<a href='/project/{project}?a=heads'>view branches</a>"],
+		["Branches", html_link(f"/project/{project}?a=heads", "view branches")],
 		["Merge request", _merge_request_summary_cell(project)],
-		["Tags", f"<a href='/project/{project}?a=tags'>view tags</a>"],
-		["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
-		["Board", board_value],
-		["Search", f"<a href='/project/{project_enc}/search'>search files</a>"],
+		["Tags", html_link(f"/project/{project}?a=tags", "view tags")],
+		["Remotes", html_link(f"/project/{project}?a=remotes", "view remotes")],
+		["Board", project_board_link(project)],
+		["Search", html_link(f"/project/{project_enc}/search", "search files")],
 		["Hooks", _hooks_summary_cell(project, project_enc)],
 	]
 	if extra_rows:
@@ -684,12 +705,7 @@ def git_remotes(project: str) -> HTMLResponse:
 	if not remotes:
 		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 = jinja_escape(r["name"])
-		url = jinja_escape(r["url"] or "—")
-		push_url = jinja_escape(r["push_url"] or "—")
-		rows.append([name, url, push_url])
+	rows = [[r["name"], r["url"] or "—", r["push_url"] or "—"] for r in remotes]
 	table = env.get_template("table.html").render(
 		cols=["Name", "URL", "Push URL"],
 		rows=rows,
@@ -986,15 +1002,15 @@ def git_heads(project: str, token: str | None) -> HTMLResponse:
 		visible_heads = heads_list
 	if not visible_heads:
 		raise HTTPException(status_code=404, detail="No heads found")
-	rows = []
+	rows: list[list[Cell]] = []
 	for name, _ref, oid in visible_heads:
 		oid_short = oid[:7] if oid else ""
-		commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
-		tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
+		commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
+		tree_href = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
 		rows.append([
-			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>',
+			html_link(commit_href, name),
+			html_link(commit_href, oid_short),
+			html_link(tree_href, "tree"),
 		])
 	title = f"Heads - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
@@ -1043,11 +1059,9 @@ 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}">{jinja_escape(target_short)}</a>' if target_link else jinja_escape(target_short)
-		)
+		obj_cell: Cell = html_link(target_link, target_short) if target_link else target_short
 		rows.append([
-			f'<a href="{tag_link}">{jinja_escape(name)}</a>',
+			html_link(tag_link, name),
 			obj_cell,
 		])
 	title = f"Tags - {jinja_escape(project)}"
@@ -1114,23 +1128,25 @@ def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLRespo
 		)
 	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="")}">{jinja_escape(target_short)}</a>'
+		object_value: Cell = html_link(
+			f"/project/{project}?a=commit&h={quote(target_oid, safe='')}",
+			target_short,
 		)
 	elif target_type == "tree":
-		object_link = (
-			f'<a href="/project/{project}?a=tree&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
+		object_value = html_link(
+			f"/project/{project}?a=tree&h={quote(target_oid, safe='')}",
+			target_short,
 		)
 	else:
-		object_link = jinja_escape(target_short) or ""
-	table_rows = [
-		["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))],
+		object_value = target_short
+	table_rows: list[list[Cell]] = [
+		["Tag", tag_name or ""],
+		["Object", html_join([object_value, " (", target_type, ")"])],
+		["Tagger", tagger],
+		["Date", _format_date(tagger_epoch, tagger_tz)],
 	]
 	if message:
-		table_rows.append(["Message", f"<pre class='tag-message'>{jinja_escape(message)}</pre>"])
+		table_rows.append(["Message", html_format("<pre class='tag-message'>{}</pre>", message)])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
 	title = f"Tag {jinja_escape(tag_name)} - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
@@ -1167,21 +1183,21 @@ def git_commit(project: str, h: str | None) -> HTMLResponse:
 	tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
 	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}">{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>'],
+	table_rows: list[list[Cell]] = [
+		["commit", html_link(commit_link, oid)],
+		["Author", html_join([author, " <", author_email, ">"])],
+		["Date", author_date],
+		["tree", html_link(tree_link, tree_oid[:7])],
 	]
 	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='')}"
+		parent_href = 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}">{jinja_escape(parent_short)}</a>'])
+		table_rows.append([label, html_link(parent_href, parent_short)])
 	if message:
-		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_rows.append(["", html_format("<pre class='commit-message'>{}</pre>", message)])
+	table_rows.append(["", html_join([html_link(diff_link, "diff"), " · ", html_link(patch_link, "patch")])])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
 	title = f"Commit {jinja_escape(oid_short)} - {jinja_escape(project)}"
 	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 59bbd7e..6a75c33 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -26,6 +26,7 @@ from pygitweb.actions import (
 	SearchSortQuery,
 	SummaryRefsPayload,
 	SummaryRefState,
+	create_board_link,
 	git_blame,
 	git_blame_raw,
 	git_blob,
@@ -48,6 +49,7 @@ from pygitweb.actions import (
 	git_tags,
 	git_tree,
 	parse_pagination,
+	project_board_link,
 	summary_ref_options,
 	summary_ref_state,
 )
@@ -74,7 +76,7 @@ from pygitweb.dependencies import (
 	require_loopback_client,
 )
 from pygitweb.formatting import age_string
-from pygitweb.git_helpers import git_get_references, git_get_type
+from pygitweb.git_helpers import git_get_type
 from pygitweb.hooks_install import (
 	HookStatus,
 	bundle_status,
@@ -120,7 +122,7 @@ from pygitweb.tasks import (
 	task_router,
 )
 from pygitweb.team import team_router
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_join, html_link, jinja_escape, trusted_html
 from pygitweb.timeline_cache import clear_timeline_cache_async, get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_pathname
 
@@ -230,21 +232,18 @@ async def loadavg_middleware(request: Request, call_next):
 	return await call_next(request)
 
 
-def _project_subpage_rows(project: str) -> list[list[str]]:
+def _project_subpage_rows(project: str) -> list[list[Cell]]:
 	project_enc = quote(project, safe="/")
-	rows: list[list[str]] = []
+	rows: list[list[Cell]] = []
 	for subpage in SUBPAGES:
 		link_label = subpage.link_name(project)
 		if not link_label:
 			continue
 		subpage_name = subpage.subpage_name()
-		view = f'<a href="/project/{project_enc}/subpage/{quote(subpage_name, safe="")}">view</a>'
+		view = html_link(f"/project/{project_enc}/subpage/{quote(subpage_name, safe='')}", "view")
 		suffix = subpage.summary_value_suffix_html(project, project_enc)
-		value = f"{view}{suffix}" if suffix else view
-		rows.append([
-			jinja_escape(link_label) or "",
-			value,
-		])
+		value = html_join([view, trusted_html(suffix)]) if suffix else view
+		rows.append([link_label, value])
 	return rows
 
 
@@ -277,28 +276,16 @@ def git_project_list(
 		elif not all_projects:
 			visibility_notice = "<p>No projects found.</p>"
 
-	def board_cell(pr: dict) -> str:
-		path = pr.get("path", "")
-		path_enc = quote(path, safe="/")
-		try:
-			board_refs = git_get_references(path, "refs/tags/boards")
-			has_boards = len(board_refs) > 0
-		except Exception:
-			has_boards = False
-		if has_boards:
-			return f'<a href="/project/{path_enc}/board/">project board</a>'
-		grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
-		proj_q = quote(path, safe="")
-		return f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
-
-	# We need better escaping logic here but can wait until we harden the templates
 	table = env.get_template("table.html").render(
 		cols=["Project", "Description", "Board"],
 		rows=[
 			[
-				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),
+				html_link(
+					f"/project/{quote(pr.get('path', ''), safe='/')}",
+					pr.get("path", "") or "",
+				),
+				pr.get("descr") or pr.get("path", "") or "",
+				project_board_link(pr.get("path", "") or ""),
 			]
 			for pr in list_[:50]
 		],
@@ -348,16 +335,18 @@ def activity_page(
 	has_next = len(events_page) > pc
 	if has_next:
 		events_page = events_page[:pc]
-	rows: list[list[str]] = []
+	rows: list[list[Cell]] = []
 	for item in events_page:
 		project_name = item["project"]
 		event = item["event"]
 		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='')}'>{jinja_escape(oid[:7])}</a>"
+			event_label: Cell = html_link(
+				f"/project/{quote(project_name, safe='/')}?a=commit&h={quote(oid, safe='')}",
+				oid[:7],
+			)
 		else:
-			event_label = jinja_escape(event["kind"]) or ""
+			event_label = event["kind"] or ""
 		description_raw = event.get("description")
 		if not description_raw:
 			description = ""
@@ -373,10 +362,10 @@ def activity_page(
 		except (ValueError, OSError):
 			activity_time = ""
 		rows.append([
-			jinja_escape(activity_time) or "",
-			f"<a href='/project/{quote(project_name, safe='/')}'>{jinja_escape(project_name)}</a>",
+			activity_time,
+			html_link(f"/project/{quote(project_name, safe='/')}", project_name),
 			event_label,
-			jinja_escape(description) or "",
+			description,
 		])
 	table = env.get_template("table.html").render(
 		cols=["Time", "Project Name", "Event", "Description"],
@@ -411,7 +400,7 @@ def boards_overview_page(
 		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
 		token,
 	)
-	rows: list[list[str]] = []
+	rows: list[list[Cell]] = []
 	for pr in projects:
 		project = pr.get("path", "")
 		if not project:
@@ -419,19 +408,17 @@ def boards_overview_page(
 		project_url = f"/project/{quote(project, safe='/')}"
 		boards = list_project_boards(project)
 		if not boards:
-			proj_q = quote(project, safe="")
-			grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
 			rows.append([
-				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
-				f"<a href='/board/create?project={proj_q}'{grey_style}>create board</a>",
+				html_link(project_url, project),
+				create_board_link(project),
 				"",
 			])
 			continue
 		for b in boards:
 			board_url = f"{project_url}/board/?b={quote(b['name'], safe='')}"
 			rows.append([
-				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
-				f"<a href='{board_url}'>{jinja_escape(b['name'])}</a>",
+				html_link(project_url, project),
+				html_link(board_url, b["name"]),
 				str(b.get("task_count", 0)),
 			])
 	table = env.get_template("table.html").render(cols=["Project", "Board", "Tasks"], rows=rows)
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index b4b85ee..77072ff 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -22,7 +22,7 @@ from pygitweb.permissions import Permission
 from pygitweb.plugin_loader import apply_plugin_reload, list_available_plugins
 from pygitweb.settings_config import settings_batch_update
 from pygitweb.shutdown import request_graceful_shutdown
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_format
 
 
 def _quote_path(path: str) -> str:
@@ -271,19 +271,23 @@ def _get_project_config_entries(project: str) -> list[tuple[str, str]]:
 	return [(k, v) for k, v in sorted(by_name.items())]
 
 
-def _project_config_table_row(key: str, value: str, index: int) -> list[str]:
-	"""One table row as [key_cell_html, value_cell_html] for project config form."""
+def _project_config_table_row(key: str, value: str, index: int) -> list[Cell]:
+	"""One table row of config inputs. Values are escaped by html_format."""
 	key_name = f"config_key_{index}"
 	val_name = f"config_value_{index}"
-	key_cell = (
+	key_cell = html_format(
 		'<input type="text" class="form-control form-control-sm" '
-		f'id="{key_name}" name="{key_name}" '
-		f'value="{jinja_escape(key)}" placeholder="e.g. user.name">'
+		'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
+		name=key_name,
+		value=key,
+		placeholder="e.g. user.name",
 	)
-	val_cell = (
+	val_cell = html_format(
 		'<input type="text" class="form-control form-control-sm" '
-		f'id="{val_name}" name="{val_name}" '
-		f'value="{jinja_escape(value)}" placeholder="value">'
+		'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
+		name=val_name,
+		value=value,
+		placeholder="value",
 	)
 	return [key_cell, val_cell]
 
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index 1cc51b1..0654710 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -41,7 +41,7 @@ from pygitweb.dependencies import ValidatedReadableQueryProject, filter_projects
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
 from pygitweb.projects import git_get_projects_list
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_link
 
 # Well-known empty tree OID for boards/tasks when repo has no HEAD
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
@@ -330,7 +330,7 @@ def all_tasks_page(
 		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
 		token,
 	)
-	rows: list[list[str]] = []
+	rows: list[list[Cell]] = []
 	for pr in projects:
 		project = pr.get("path", "")
 		if not project:
@@ -347,12 +347,12 @@ def all_tasks_page(
 			board_url = f"{project_url}/board/?b={quote(t['board'], safe='')}"
 			task_url = f"{board_url}&task={quote(t['ref'], safe='')}"
 			rows.append([
-				f"<a href='{task_url}'>{jinja_escape(t.get('title') or '(untitled)')}</a>",
-				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
-				jinja_escape(t.get("board") or "") or "",
-				jinja_escape(task_status.replace("_", " ").title()) or "",
-				jinja_escape((t.get("priority") or "LOW").title()) or "",
-				jinja_escape(t.get("assignee") or "Unassigned") or "",
+				html_link(task_url, t.get("title") or "(untitled)"),
+				html_link(project_url, project),
+				t.get("board") or "",
+				task_status.replace("_", " ").title(),
+				(t.get("priority") or "LOW").title(),
+				t.get("assignee") or "Unassigned",
 				str(t.get("points") if t.get("points") is not None else ""),
 			])
 	table = env.get_template("table.html").render(
diff --git a/pygitweb/team.py b/pygitweb/team.py
index 0c0aa7f..e15c0e3 100644
--- a/pygitweb/team.py
+++ b/pygitweb/team.py
@@ -25,7 +25,7 @@ from pygitweb.dependencies import filter_projects_by_read_access
 from pygitweb.permissions import Permission
 from pygitweb.projects import git_get_projects_list
 from pygitweb.tasks import get_all_project_tasks
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_link
 
 team_router = APIRouter(tags=["team"])
 
@@ -92,7 +92,7 @@ def team_page(
 		is_unassigned = assignee == "Unassigned"
 		capacity = None if is_unassigned else get_user_week_capacity(assignee, iso_week)
 
-		rows: list[list[str]] = []
+		rows: list[list[Cell]] = []
 		for t in tasks:
 			project_enc = quote(t["project"], safe="/")
 			board_enc = quote(t["board"], safe="")
@@ -100,11 +100,11 @@ def team_page(
 			project_url = f"/project/{project_enc}"
 			task_url = f"{project_url}/board/?b={board_enc}&task={task_enc}"
 			rows.append([
-				f"<a href='{task_url}'>{jinja_escape(t.get('title') or '(untitled)')}</a>",
-				f"<a href='{project_url}'>{jinja_escape(t['project'])}</a>",
-				jinja_escape(t.get("board") or "") or "",
-				jinja_escape((t.get("status") or "").replace("_", " ").title()) or "",
-				jinja_escape((t.get("priority") or "").title()) or "",
+				html_link(task_url, t.get("title") or "(untitled)"),
+				html_link(project_url, t["project"]),
+				t.get("board") or "",
+				(t.get("status") or "").replace("_", " ").title(),
+				(t.get("priority") or "").title(),
 				str(t.get("points") if t.get("points") is not None else ""),
 			])
 		table_html = env.get_template("table.html").render(
@@ -114,7 +114,7 @@ def team_page(
 		can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
 		capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
 		groups.append({
-			"assignee": jinja_escape(assignee),
+			"assignee": assignee,
 			"points_total": points_total,
 			"capacity": capacity,
 			"over_capacity": capacity is not None and points_total > capacity,
@@ -146,8 +146,8 @@ def team_capacity_form(
 	current_points = get_user_week_capacity(target_user, iso_week)
 	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Set capacity", site_name=settings.SITE_NAME)
 	body = env.get_template("team_capacity_form.html").render(
-		target_user=jinja_escape(target_user),
-		iso_week=jinja_escape(iso_week),
+		target_user=target_user,
+		iso_week=iso_week,
 		current_points=current_points,
 	)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
diff --git a/pygitweb/templates/table.html b/pygitweb/templates/table.html
index 45544b7..a9df378 100644
--- a/pygitweb/templates/table.html
+++ b/pygitweb/templates/table.html
@@ -7,7 +7,7 @@
 </thead>
 <tbody>{% for row in rows %}
 <tr>{% for cell in row %}
-<td>{{ cell | safe }}</td>
+<td>{{ cell }}</td>
 {% endfor %}
 </tr>
 {% endfor %}
diff --git a/pygitweb/templates_env.py b/pygitweb/templates_env.py
index 1e3709a..3a6886a 100644
--- a/pygitweb/templates_env.py
+++ b/pygitweb/templates_env.py
@@ -1,6 +1,9 @@
 """Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
 
+from collections.abc import Iterable
+
 from jinja2 import Environment, PackageLoader, select_autoescape
+from markupsafe import Markup
 
 env = Environment(
 	loader=PackageLoader("pygitweb", "templates"),
@@ -11,6 +14,8 @@ env = Environment(
 PREAMBLE = env.get_template("preamble.html")
 POSTAMBLE = """</div></div></div></body></html>"""
 
+# A table cell is plain text, which the template escapes, or Markup, which it does not.
+Cell = str | Markup
 
 _ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
 
@@ -19,3 +24,26 @@ def jinja_escape(value: str | None) -> str | None:
 	if value is None:
 		return None
 	return _ESCAPE_TEMPLATE.render(value=value)
+
+
+def html_link(href: str, text: str, **attrs: str) -> Markup:
+	"""Anchor tag. href, text, and attribute values are escaped."""
+	attr_html = Markup("").join(
+		Markup(' {}="{}"').format(name.replace("_", "-"), value) for name, value in attrs.items()
+	)
+	return Markup('<a href="{}"{}>{}</a>').format(href, attr_html, text)
+
+
+def html_format(template: str, /, *args: object, **kwargs: object) -> Markup:
+	"""Format an HTML snippet, escaping interpolated values. The template itself is trusted."""
+	return Markup(template).format(*args, **kwargs)
+
+
+def html_join(parts: Iterable[str | Markup], sep: str = "") -> Markup:
+	"""Join fragments. Plain strings are escaped; Markup is inserted as-is."""
+	return Markup(sep).join(parts)
+
+
+def trusted_html(value: str) -> Markup:
+	"""Mark HTML that a template or plugin already escaped. Do not pass raw text."""
+	return Markup(value)
diff --git a/pygitweb/templates_env_test.py b/pygitweb/templates_env_test.py
new file mode 100644
index 0000000..164da21
--- /dev/null
+++ b/pygitweb/templates_env_test.py
@@ -0,0 +1,59 @@
+from pygitweb.actions import project_board_link
+from pygitweb.templates_env import env, html_format, html_join, html_link, trusted_html
+
+
+def test_table_escapes_plain_text() -> None:
+	html = env.get_template("table.html").render(
+		cols=["Name"],
+		rows=[["<script>alert(1)</script>"]],
+	)
+	assert "<script>" not in html
+	assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
+
+
+def test_table_renders_link_without_double_escaping() -> None:
+	cell = html_link("/p?a=commit&h=abc", "a<b", id="commit-link")
+	html = env.get_template("table.html").render(cols=["Commit"], rows=[[cell]])
+	assert 'href="/p?a=commit&amp;h=abc"' in html
+	assert 'id="commit-link"' in html
+	assert ">a&lt;b</a>" in html
+	assert "&amp;amp;" not in html
+
+
+def test_html_join_escapes_text_and_keeps_markup() -> None:
+	joined = html_join([html_link("/a", "x"), " <", "a&b", ">"])
+	assert str(joined) == '<a href="/a">x</a> &lt;a&amp;b&gt;'
+
+
+def test_trusted_html_is_not_escaped_in_table() -> None:
+	html = env.get_template("table.html").render(
+		cols=["V"],
+		rows=[[trusted_html("<em>ok</em>")]],
+	)
+	assert "<em>ok</em>" in html
+
+
+def test_html_format_escapes_values() -> None:
+	assert str(html_format("<pre>{}</pre>", "<hi>")) == "<pre>&lt;hi&gt;</pre>"
+
+
+def test_project_board_link_points_at_existing_board(monkeypatch) -> None:
+	monkeypatch.setattr(
+		"pygitweb.actions.git_get_references",
+		lambda _project, _prefix: [("Tasks", "refs/tags/boards/Tasks")],
+	)
+	assert str(project_board_link("demo")) == '<a href="/project/demo/board/">project board</a>'
+
+
+def test_project_board_link_offers_create_when_missing(monkeypatch) -> None:
+	monkeypatch.setattr("pygitweb.actions.git_get_references", lambda _project, _prefix: [])
+	monkeypatch.setattr("pygitweb.actions.settings.AUTH", True)
+	html = str(project_board_link("my proj"))
+	assert 'href="/board/create?project=my%20proj"' in html
+	assert 'style="color: #999; cursor: not-allowed;"' in html
+	assert ">create board</a>" in html
+
+	monkeypatch.setattr("pygitweb.actions.settings.AUTH", False)
+	plain = str(project_board_link("my proj"))
+	assert "style=" not in plain
+	assert ">create board</a>" in plain
