From 5c924cae60a1743c0c82f2f1736df38145b6df7e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 5 Jul 2026 19:59:23 -0400 Subject: [PATCH] Add Gemini renderer and semantic audit --- README.md | 1 + ampg/audit.py | 125 ++++++++++++++++++++++++++++ ampg/build.py | 53 +++++++++++- ampg/cli.py | 26 ++++++ ampg/metadata.py | 22 +++++ ampg/plan.py | 20 +++++ ampg/renderers.py | 132 ++++++++++++++++++++++++++++++ docs/architecture.md | 11 +++ docs/configuration.md | 6 +- docs/daemon-management.md | 2 +- docs/generated/daemon-adapters.md | 10 +++ docs/generated/render-profiles.md | 13 +++ docs/wownero-integration.md | 2 +- examples/wownero.gateway.toml | 3 +- tests/test_audit.py | 45 ++++++++++ tests/test_gemini_build.py | 70 ++++++++++++++++ tests/test_gemtext_renderer.py | 36 ++++++++ tests/test_plan_artifacts.py | 13 ++- 18 files changed, 583 insertions(+), 7 deletions(-) create mode 100644 ampg/audit.py create mode 100644 tests/test_audit.py create mode 100644 tests/test_gemini_build.py create mode 100644 tests/test_gemtext_renderer.py diff --git a/README.md b/README.md index 7e01cc2..338fc19 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ The current implementation is dependency-free Python: python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/wownero.gateway.toml audit python3 -m ampg --config examples/i2p-only.gateway.toml build python3 -m ampg docs generate python3 -m unittest discover -s tests diff --git a/ampg/audit.py b/ampg/audit.py new file mode 100644 index 0000000..e305304 --- /dev/null +++ b/ampg/audit.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from html.parser import HTMLParser +from pathlib import Path +import re + +from .build import _iter_source_files +from .config import GatewayConfig, SiteConfig + + +@dataclass(frozen=True) +class AuditIssue: + site_id: str + path: Path + severity: str + code: str + message: str + + +def audit_gateway(config: GatewayConfig) -> list[AuditIssue]: + issues: list[AuditIssue] = [] + for site in config.sites: + issues.extend(audit_site(site)) + return issues + + +def audit_site(site: SiteConfig) -> list[AuditIssue]: + issues: list[AuditIssue] = [] + for path in _iter_source_files(site.source.path): + if path.suffix.lower() not in {".html", ".htm"}: + continue + html = path.read_text(encoding="utf-8", errors="replace") + rel_path = path.relative_to(site.source.path) + page_issues = SemanticHtmlAuditor(site.id, rel_path).audit(html) + issues.extend(page_issues) + return issues + + +class SemanticHtmlAuditor(HTMLParser): + def __init__(self, site_id: str, path: Path) -> None: + super().__init__(convert_charrefs=True) + self.site_id = site_id + self.path = path + self.issues: list[AuditIssue] = [] + self.last_heading_level = 0 + self.h1_count = 0 + self.anchor_stack: list[tuple[str, list[str], bool]] = [] + self.skip_stack: list[str] = [] + + def audit(self, html: str) -> list[AuditIssue]: + self.feed(html) + self.close() + if self.h1_count == 0: + self._warn("missing_h1", "page has no h1 heading") + elif self.h1_count > 1: + self._warn("multiple_h1", f"page has {self.h1_count} h1 headings") + return self.issues + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + attrs_dict = {name.lower(): value for name, value in attrs} + if tag in {"script", "style"}: + self.skip_stack.append(tag) + return + if self.skip_stack: + return + if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: + self._handle_heading(int(tag[1])) + elif tag == "img": + alt = attrs_dict.get("alt") + if alt is None or not _normalize_text(alt): + self._warn("missing_alt", "image is missing meaningful alt text") + if self.anchor_stack: + href, text, _ = self.anchor_stack.pop() + self.anchor_stack.append((href, text, True)) + elif tag == "a": + href = attrs_dict.get("href") + if href: + self.anchor_stack.append((href, [], False)) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if self.skip_stack: + if self.skip_stack[-1] == tag: + self.skip_stack.pop() + return + if tag == "a" and self.anchor_stack: + href, text_parts, has_image = self.anchor_stack.pop() + text = _normalize_text("".join(text_parts)) + if not text and not has_image and not href.startswith("#"): + self._warn("empty_link_text", f"link to {href!r} has no text") + + def handle_data(self, data: str) -> None: + if self.skip_stack: + return + if self.anchor_stack: + href, text_parts, has_image = self.anchor_stack.pop() + text_parts.append(data) + self.anchor_stack.append((href, text_parts, has_image)) + + def _handle_heading(self, level: int) -> None: + if level == 1: + self.h1_count += 1 + if self.last_heading_level and level > self.last_heading_level + 1: + self._warn( + "heading_level_skip", + f"heading jumps from h{self.last_heading_level} to h{level}", + ) + self.last_heading_level = level + + def _warn(self, code: str, message: str) -> None: + self.issues.append( + AuditIssue( + site_id=self.site_id, + path=self.path, + severity="warn", + code=code, + message=message, + ) + ) + + +def _normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() diff --git a/ampg/build.py b/ampg/build.py index b85f3ba..10c7b87 100644 --- a/ampg/build.py +++ b/ampg/build.py @@ -5,7 +5,7 @@ from pathlib import Path import shutil from .config import GatewayConfig, ProtocolConfig, SiteConfig -from .renderers import PrivacyRenderStats, render_privacy_html +from .renderers import PrivacyRenderStats, render_gemtext, render_privacy_html IGNORED_DIRS = {".git", ".hg", ".svn", ".vscode", "__pycache__"} @@ -41,6 +41,8 @@ def build_protocol(site: SiteConfig, protocol: ProtocolConfig) -> BuildResult: return _copy_clearnet(site, protocol, output_root) if protocol.renderer == "privacy-html": return _render_privacy_html_tree(site, protocol, output_root) + if protocol.renderer == "gemtext": + return _render_gemtext_tree(site, protocol, output_root) raise ValueError(f"{site.id}/{protocol.name}: unsupported renderer {protocol.renderer!r}") @@ -100,6 +102,39 @@ def _render_privacy_html_tree( ) +def _render_gemtext_tree( + site: SiteConfig, protocol: ProtocolConfig, output_root: Path +) -> BuildResult: + files_written = 0 + files_skipped = 0 + max_asset_bytes = int(protocol.options.get("max_asset_bytes", 1024 * 1024)) + for source_path in _iter_source_files(site.source.path): + if source_path.suffix.lower() in {".html", ".htm"}: + target_path = _target_with_suffix(site.source.path, output_root, source_path, ".gmi") + target_path.parent.mkdir(parents=True, exist_ok=True) + html = source_path.read_text(encoding="utf-8", errors="replace") + target_path.write_text(render_gemtext(html, rewrite_link=_rewrite_gemtext_link), encoding="utf-8") + elif source_path.suffix.lower() in {".js", ".css", ".map"}: + files_skipped += 1 + continue + elif source_path.stat().st_size > max_asset_bytes: + files_skipped += 1 + continue + else: + target_path = output_root / source_path.relative_to(site.source.path) + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + files_written += 1 + return BuildResult( + site_id=site.id, + protocol=protocol.name, + renderer=protocol.renderer, + output_root=output_root, + files_written=files_written, + files_skipped=files_skipped, + ) + + def _iter_source_files(root: Path): for path in sorted(root.rglob("*")): if not path.is_file(): @@ -131,3 +166,19 @@ def _prepare_output_root(output_root: Path) -> None: shutil.rmtree(output_root) output_root.mkdir(parents=True, exist_ok=True) marker.write_text("generated by AMPG\n", encoding="utf-8") + + +def _target_with_suffix(source_root: Path, output_root: Path, source_path: Path, suffix: str) -> Path: + rel_path = source_path.relative_to(source_root) + return (output_root / rel_path).with_suffix(suffix) + + +def _rewrite_gemtext_link(href: str) -> str: + if href.startswith("#"): + return href + if href.startswith(("http://", "https://", "gemini://", "mailto:")): + return href + path, sep, fragment = href.partition("#") + if path.endswith(".html") or path.endswith(".htm"): + path = path.rsplit(".", 1)[0] + ".gmi" + return path + (sep + fragment if sep else "") diff --git a/ampg/cli.py b/ampg/cli.py index 3117610..3388797 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -4,6 +4,7 @@ import argparse from pathlib import Path import sys +from .audit import audit_gateway from .build import build_gateway from .config import load_config from .docsgen import generate_docs @@ -26,6 +27,12 @@ def main(argv: list[str] | None = None) -> int: help="Write generated config snippets to the configured plan root.", ) subcommands.add_parser("build", help="Build enabled protocol outputs.") + audit_parser = subcommands.add_parser("audit", help="Audit source HTML quality.") + audit_parser.add_argument( + "--fail-on-warn", + action="store_true", + help="Exit non-zero when warnings are found.", + ) docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.") docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True) docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.") @@ -45,6 +52,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_plan(config, write_artifacts=args.write_artifacts) if args.command == "build": return _cmd_build(config) + if args.command == "audit": + return _cmd_audit(config, fail_on_warn=args.fail_on_warn) except Exception as exc: # noqa: BLE001 - CLI should print concise failures. print(f"AMPG status=error message={exc}", file=sys.stderr) return 1 @@ -96,6 +105,23 @@ def _cmd_build(config) -> int: return 0 +def _cmd_audit(config, *, fail_on_warn: bool) -> int: + issues = audit_gateway(config) + for issue in issues: + print( + "AMPG_AUDIT " + f"site={issue.site_id} " + f"severity={issue.severity} " + f"code={issue.code} " + f"path={issue.path} " + f"message=\"{issue.message}\"" + ) + print(f"AMPG_AUDIT_SUMMARY issues={len(issues)}") + if issues and fail_on_warn: + return 1 + return 0 + + def _cmd_docs(args) -> int: if args.docs_command == "generate": changed = generate_docs(Path.cwd(), check=args.check) diff --git a/ampg/metadata.py b/ampg/metadata.py index 6ba8741..04c11cd 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -132,6 +132,18 @@ RENDER_PROFILES = ( "Removes remote src/poster/link asset references.", ), ), + RenderProfile( + name="gemtext", + summary="Text-first Gemini output from semantic HTML.", + output="Gemtext plus linked/downloadable assets", + defaults=( + "Converts HTML headings to Gemtext heading lines.", + "Converts paragraphs and list items to flowing text.", + "Converts anchors and images to Gemtext link lines.", + "Rewrites local .html links to .gmi links.", + "Skips JavaScript, CSS, source maps, and oversized assets.", + ), + ), ) @@ -166,4 +178,14 @@ DAEMON_ADAPTERS = ( "Existing RPC/P2P tunnel keys are not reused for web publishing.", ), ), + DaemonAdapter( + daemon=DEFAULT_DAEMONS["gemini"], + protocols=("gemini",), + default_policy=DEFAULT_POLICIES["gemini"], + generated_artifacts=("Agate plan values",), + notes=( + "Gemini serves generated Gemtext output directly.", + "Certificate and key paths are plan values until install/apply support exists.", + ), + ), ) diff --git a/ampg/plan.py b/ampg/plan.py index a4215cb..367c87e 100644 --- a/ampg/plan.py +++ b/ampg/plan.py @@ -74,6 +74,10 @@ def plan_artifacts(site: SiteConfig, protocol: ProtocolConfig) -> list[PlanArtif artifacts.append(_i2pd_artifact(site, protocol)) artifacts.append(_nginx_loopback_artifact(site, protocol)) return artifacts + if protocol.name == "gemini": + if protocol.daemon == "agate": + return [_agate_artifact(site, protocol)] + return [] return [] @@ -155,3 +159,19 @@ port = {port} keys = {key_file} """ return PlanArtifact(path=path, content=content) + + +def _agate_artifact(site: SiteConfig, protocol: ProtocolConfig) -> PlanArtifact: + port = int(protocol.options.get("port", 1965)) + cert_path = protocol.options.get("certificate_path", f"/var/lib/ampg/gemini/{site.id}.crt") + key_path = protocol.options.get("key_path", f"/var/lib/ampg/gemini/{site.id}.key") + path = _protocol_plan_dir(site, protocol) / "agate-plan.txt" + content = f"""# Generated by AMPG. Review before installing. +daemon = agate +hostname = {site.domain} +listen = [::]:{port} +content_root = {site.outputs.root / protocol.name} +certificate = {cert_path} +private_key = {key_path} +""" + return PlanArtifact(path=path, content=content) diff --git a/ampg/renderers.py b/ampg/renderers.py index 3ec6a7c..bc51b99 100644 --- a/ampg/renderers.py +++ b/ampg/renderers.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from html import escape from html.parser import HTMLParser +import re +from typing import Callable VOID_ELEMENTS = { @@ -158,6 +160,118 @@ def render_privacy_html(html: str) -> tuple[str, PrivacyRenderStats]: return PrivacyHtmlRenderer().render(html) +class GemtextRenderer(HTMLParser): + def __init__(self, rewrite_link: Callable[[str], str] | None = None) -> None: + super().__init__(convert_charrefs=True) + self.rewrite_link = rewrite_link or (lambda href: href) + self.lines: list[str] = [] + self.text_parts: list[str] = [] + self.links: list[tuple[str, str]] = [] + self.skip_stack: list[str] = [] + self.heading_level: int | None = None + self.list_depth = 0 + self.anchor_stack: list[tuple[str, list[str]]] = [] + self.in_pre = False + self.pre_parts: list[str] = [] + + def render(self, html: str) -> str: + self.feed(html) + self.close() + self._flush_block() + return _squash_blank_lines(self.lines) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + attrs_dict = {name.lower(): value for name, value in attrs} + if tag in ACTIVE_TAGS or tag in {"nav", "noscript", "svg"}: + self.skip_stack.append(tag) + return + if self.skip_stack: + return + if tag in {"p", "div", "section", "article", "header", "footer"}: + self._flush_block() + elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: + self._flush_block() + self.heading_level = min(int(tag[1]), 3) + elif tag == "li": + self._flush_block() + self.list_depth += 1 + elif tag == "br": + self._append_text(" ") + elif tag == "a": + href = attrs_dict.get("href") + if href: + self.anchor_stack.append((href, [])) + elif tag == "img": + src = attrs_dict.get("src") + alt = attrs_dict.get("alt") or "image" + if src: + self.links.append((self.rewrite_link(src), f"Image: {_normalize_text(alt)}")) + elif tag == "pre": + self._flush_block() + self.in_pre = True + self.pre_parts = [] + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if self.skip_stack: + if self.skip_stack[-1] == tag: + self.skip_stack.pop() + return + if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: + self._flush_block() + self.heading_level = None + elif tag in {"p", "div", "section", "article", "header", "footer"}: + self._flush_block() + elif tag == "li": + self._flush_block(prefix="- ") + self.list_depth = max(0, self.list_depth - 1) + elif tag == "a" and self.anchor_stack: + href, parts = self.anchor_stack.pop() + label = _normalize_text("".join(parts)) or href + self.links.append((self.rewrite_link(href), label)) + elif tag == "pre" and self.in_pre: + self.lines.append("```") + self.lines.extend("".join(self.pre_parts).strip("\n").splitlines()) + self.lines.append("```") + self.lines.append("") + self.in_pre = False + self.pre_parts = [] + + def handle_data(self, data: str) -> None: + if self.skip_stack: + return + if self.in_pre: + self.pre_parts.append(data) + return + self._append_text(data) + + def _append_text(self, data: str) -> None: + if self.anchor_stack: + self.anchor_stack[-1][1].append(data) + self.text_parts.append(data) + + def _flush_block(self, prefix: str = "") -> None: + text = _normalize_text("".join(self.text_parts)) + if text: + if self.heading_level is not None: + self.lines.append(f"{'#' * self.heading_level} {text}") + else: + self.lines.append(f"{prefix}{text}") + for href, label in self.links: + if href.startswith("#"): + continue + self.lines.append(f"=> {href} {label}") + if text or self.links: + self.lines.append("") + self.text_parts = [] + self.links = [] + + +def render_gemtext(html: str, rewrite_link: Callable[[str], str] | None = None) -> str: + return GemtextRenderer(rewrite_link=rewrite_link).render(html) + + def _format_attrs(attrs: list[tuple[str, str | None]]) -> str: if not attrs: return "" @@ -173,3 +287,21 @@ def _format_attrs(attrs: list[tuple[str, str | None]]) -> str: def _is_remote_url(value: str) -> bool: lower = value.lower() return lower.startswith("http://") or lower.startswith("https://") or lower.startswith("//") + + +def _normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def _squash_blank_lines(lines: list[str]) -> str: + output: list[str] = [] + previous_blank = False + for line in lines: + blank = not line.strip() + if blank and previous_blank: + continue + output.append(line.rstrip()) + previous_blank = blank + while output and not output[-1]: + output.pop() + return "\n".join(output) + "\n" diff --git a/docs/architecture.md b/docs/architecture.md index 523e705..4f28906 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,17 @@ source site : Reads the built output of another static site generator. AMPG does not own that generator; it adapts the generated files. +## Source quality + +AMPG gets better results from semantic, accessible source documents. Authors should use +one `h1`, ordered heading levels, meaningful link text, alt text for images, and ordinary +flowing document structure before decorative layout. This keeps clearnet accessible and +gives constrained renderers a clean outline for Gemtext, Micron, and other text-first +targets. + +`ampg audit` reports source-quality warnings. The audit is advisory by default and can be +made strict in CI with `--fail-on-warn`. + ## Content graph The internal graph is intentionally small: diff --git a/docs/configuration.md b/docs/configuration.md index 80d9f0d..c935805 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,11 +59,12 @@ max_asset_bytes = 1048576 script_policy = "strip" [site.protocols.gemini] -enabled = false +enabled = true renderer = "gemtext" daemon = "agate" daemon_policy = "auto" port = 1965 +max_asset_bytes = 1048576 [site.protocols.reticulum] enabled = false @@ -103,6 +104,8 @@ workflow. - one `.gmi` page per HTML or Markdown page. - inline links emitted after the paragraph that referenced them. - images and PDFs emitted as links with file size metadata when known. +- local `.html` links rewritten to `.gmi`. +- JavaScript, CSS, source maps, and oversized assets skipped. `micron` defaults: @@ -216,6 +219,7 @@ Generated snippets are not installed by `plan`: - nginx server blocks for static HTTP roots. - Tor hidden-service snippets for loopback HTTP targets. - i2pd server tunnel snippets for I2P web publishing. +- Agate plan values for Gemini publishing. I2P web tunnels should use a web-specific key file such as `wownero-web.dat`; AMPG does not reuse daemon RPC/P2P tunnel keys for web publishing. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 18384b0..f027d78 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -65,7 +65,7 @@ and include backup warnings in the plan. Default daemon: Agate. Gemini is static-friendly and can be managed safely. AMPG generates `.gmi` output, -certificates when requested, and an Agate config pointing at the Gemini output root. +certificates when requested, and Agate plan values pointing at the Gemini output root. ### Reticulum diff --git a/docs/generated/daemon-adapters.md b/docs/generated/daemon-adapters.md index 7b2f908..6b0d919 100644 --- a/docs/generated/daemon-adapters.md +++ b/docs/generated/daemon-adapters.md @@ -34,3 +34,13 @@ Notes: Notes: - Web tunnels should use web-specific key files. - Existing RPC/P2P tunnel keys are not reused for web publishing. + +## agate + +- Protocols: gemini +- Default policy: `auto` +- Generated artifacts: Agate plan values + +Notes: +- Gemini serves generated Gemtext output directly. +- Certificate and key paths are plan values until install/apply support exists. diff --git a/docs/generated/render-profiles.md b/docs/generated/render-profiles.md index c90a462..576bae1 100644 --- a/docs/generated/render-profiles.md +++ b/docs/generated/render-profiles.md @@ -29,3 +29,16 @@ Defaults: - Skips JavaScript and source-map files. - Skips oversized assets above max_asset_bytes. - Removes remote src/poster/link asset references. + +## gemtext + +Text-first Gemini output from semantic HTML. + +- Output: Gemtext plus linked/downloadable assets + +Defaults: +- Converts HTML headings to Gemtext heading lines. +- Converts paragraphs and list items to flowing text. +- Converts anchors and images to Gemtext link lines. +- Rewrites local .html links to .gmi links. +- Skips JavaScript, CSS, source maps, and oversized assets. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index f222093..c958b10 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -122,7 +122,7 @@ daemon_policy = "auto" ## Acceptance Criteria - `ampg plan` identifies the Wownero source tree and selected protocols. -- `ampg build` creates clearnet and Tor output without requiring Markdown. +- `ampg build` creates clearnet, Tor, I2P, and Gemini output without requiring Markdown. - Tor output contains no `', + encoding="utf-8", + ) + (source / "about.html").write_text("

About

", encoding="utf-8") + (source / "app.js").write_text("bad()", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + """ +[[site]] +id = "gemini_only" +domain = "example.test" + +[site.source] +kind = "static-html" +path = "./source" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +[site.protocols.gemini] +enabled = true +renderer = "gemtext" +daemon = "agate" +daemon_policy = "auto" +port = 1965 +max_asset_bytes = 1024 +""", + encoding="utf-8", + ) + + config = load_config(config_path) + plan = plan_gateway(config) + artifacts = write_plan_artifacts(config) + results = build_gateway(config) + + self.assertEqual([line.protocol for line in plan], ["gemini"]) + self.assertEqual([result.protocol for result in results], ["gemini"]) + self.assertEqual(len(artifacts), 1) + self.assertTrue((root / "out/gemini/index.gmi").exists()) + self.assertTrue((root / "out/gemini/about.gmi").exists()) + self.assertFalse((root / "out/gemini/app.js").exists()) + self.assertFalse((root / "out/clearnet").exists()) + gemtext = (root / "out/gemini/index.gmi").read_text(encoding="utf-8") + self.assertIn("# Home", gemtext) + self.assertIn("=> about.gmi About", gemtext) + self.assertNotIn("bad", gemtext) + agate_plan = (root / "plan/gemini_only/gemini/agate-plan.txt").read_text() + self.assertIn("daemon = agate", agate_plan) + self.assertIn("content_root =", agate_plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gemtext_renderer.py b/tests/test_gemtext_renderer.py new file mode 100644 index 0000000..379461c --- /dev/null +++ b/tests/test_gemtext_renderer.py @@ -0,0 +1,36 @@ +import unittest + +from ampg.renderers import render_gemtext + + +class GemtextRendererTest(unittest.TestCase): + def test_converts_basic_html_to_gemtext(self): + html = """ + + + + +

Hello

+

Read the about page.

+ + Logo + + +""" + + gemtext = render_gemtext( + html, + rewrite_link=lambda href: href.replace(".html", ".gmi"), + ) + + self.assertIn("# Hello", gemtext) + self.assertIn("Read the about page.", gemtext) + self.assertIn("=> about.gmi about page", gemtext) + self.assertIn("- One", gemtext) + self.assertIn("- Two", gemtext) + self.assertIn("=> logo.png Image: Logo", gemtext) + self.assertNotIn("ignored", gemtext) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plan_artifacts.py b/tests/test_plan_artifacts.py index 6f032d6..805bcd1 100644 --- a/tests/test_plan_artifacts.py +++ b/tests/test_plan_artifacts.py @@ -44,6 +44,13 @@ daemon_policy = "auto" local_port = 19081 keys_file = "example-web.dat" tunnel_name = "example-web" + +[site.protocols.gemini] +enabled = true +renderer = "gemtext" +daemon = "agate" +daemon_policy = "auto" +port = 1965 """, encoding="utf-8", ) @@ -52,19 +59,21 @@ tunnel_name = "example-web" lines = plan_gateway(config) artifacts = write_plan_artifacts(config) - self.assertEqual(len(lines), 2) - self.assertEqual(len(artifacts), 4) + self.assertEqual(len(lines), 3) + self.assertEqual(len(artifacts), 5) torrc = root / "plan/example/tor/torrc-snippet.conf" tor_nginx = root / "plan/example/tor/nginx-loopback.conf" i2p_tunnel = root / "plan/example/i2p/i2pd-tunnel.conf" i2p_nginx = root / "plan/example/i2p/nginx-loopback.conf" + agate_plan = root / "plan/example/gemini/agate-plan.txt" self.assertIn("HiddenServicePort 80 127.0.0.1:19080", torrc.read_text()) self.assertIn("listen 127.0.0.1:19080;", tor_nginx.read_text()) self.assertIn("[example-web]", i2p_tunnel.read_text()) self.assertIn("keys = example-web.dat", i2p_tunnel.read_text()) self.assertIn("listen 127.0.0.1:19081;", i2p_nginx.read_text()) + self.assertIn("daemon = agate", agate_plan.read_text()) if __name__ == "__main__":