Initial commit

This commit is contained in:
Sergey Filkin
2026-04-17 23:39:57 +03:00
commit 0fe596383e
13 changed files with 943 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import threading
import uuid
from collections import OrderedDict
from html.parser import HTMLParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import sys
import streamlit as st
try:
from app.converter import convert
except ModuleNotFoundError:
sys.path.append(str(Path(__file__).resolve().parent.parent))
from app.converter import convert
MAX_PREVIEW_STORE_ITEMS = 20
class BodyInnerHTMLParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=False)
self._inside_body = False
self._depth = 0
self._parts: list[str] = []
def handle_starttag(self, tag: str, attrs) -> None:
rendered = self.get_starttag_text()
if tag == "body":
self._inside_body = True
self._depth = 0
return
if self._inside_body and rendered is not None:
self._parts.append(rendered)
self._depth += 1
def handle_endtag(self, tag: str) -> None:
if tag == "body" and self._inside_body:
self._inside_body = False
self._depth = 0
return
if self._inside_body:
self._parts.append(f"</{tag}>")
if self._depth > 0:
self._depth -= 1
def handle_startendtag(self, tag: str, attrs) -> None:
if self._inside_body:
rendered = self.get_starttag_text()
if rendered is not None:
self._parts.append(rendered)
def handle_data(self, data: str) -> None:
if self._inside_body:
self._parts.append(data)
def handle_entityref(self, name: str) -> None:
if self._inside_body:
self._parts.append(f"&{name};")
def handle_charref(self, name: str) -> None:
if self._inside_body:
self._parts.append(f"&#{name};")
def handle_comment(self, data: str) -> None:
if self._inside_body:
self._parts.append(f"<!--{data}-->")
def body_html(self) -> str:
return "".join(self._parts).strip()
def extract_body_html(document_html: str) -> str:
parser = BodyInnerHTMLParser()
parser.feed(document_html)
parser.close()
return parser.body_html()
@st.cache_resource
def get_preview_runtime() -> dict[str, object]:
store: OrderedDict[str, str] = OrderedDict()
lock = threading.Lock()
class PreviewHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
prefix = "/preview/"
if not self.path.startswith(prefix):
self.send_error(404)
return
preview_id = self.path[len(prefix) :].split("?", 1)[0]
with lock:
document_html = store.get(preview_id)
if document_html is None:
self.send_error(404)
return
payload = document_html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(payload)
def log_message(self, format: str, *args) -> None:
return
server = ThreadingHTTPServer(("127.0.0.1", 0), PreviewHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return {
"base_url": f"http://127.0.0.1:{server.server_port}",
"store": store,
"lock": lock,
}
def register_preview(document_html: str) -> str:
runtime = get_preview_runtime()
preview_id = uuid.uuid4().hex
store = runtime["store"]
lock = runtime["lock"]
with lock:
store[preview_id] = document_html
while len(store) > MAX_PREVIEW_STORE_ITEMS:
store.popitem(last=False)
return f"{runtime['base_url']}/preview/{preview_id}"
st.set_page_config(
page_title="Markdown to HTML",
page_icon=":material/description:",
layout="centered",
)
if "html_result" not in st.session_state:
st.session_state["html_result"] = None
if "output_name" not in st.session_state:
st.session_state["output_name"] = "document.html"
if "preview_url" not in st.session_state:
st.session_state["preview_url"] = None
st.title("Markdown → HTML")
st.caption("Загрузите markdown-файл, проверьте превью и скачайте готовый HTML.")
uploaded_file = st.file_uploader(
"Загрузите .md файл",
type=["md", "markdown"],
)
html_result = st.session_state["html_result"]
with st.container(border=True):
action_col, preview_col, download_col = st.columns(
[1.1, 1, 1],
vertical_alignment="center",
)
with action_col:
convert_clicked = st.button(
"Конвертировать",
disabled=uploaded_file is None,
type="primary",
icon=":material/auto_awesome:",
use_container_width=True,
)
with preview_col:
if html_result and st.session_state["preview_url"] is not None:
st.link_button(
"Открыть превью",
url=st.session_state["preview_url"],
icon=":material/open_in_new:",
use_container_width=True,
)
else:
st.button(
"Открыть превью",
disabled=True,
icon=":material/open_in_new:",
use_container_width=True,
)
with download_col:
if html_result:
st.download_button(
"Скачать HTML",
data=html_result,
file_name=st.session_state["output_name"],
mime="text/html",
icon=":material/download:",
use_container_width=True,
)
else:
st.button(
"Скачать HTML",
disabled=True,
icon=":material/download:",
use_container_width=True,
)
if html_result:
st.caption(":green-badge[Результат готов]")
else:
st.caption("После конвертации здесь появятся действия с готовым файлом.")
if convert_clicked and uploaded_file is not None:
markdown_bytes = uploaded_file.getvalue()
markdown_text = markdown_bytes.decode("utf-8")
output_name = f"{Path(uploaded_file.name).stem}.html"
try:
st.session_state["html_result"] = convert(
markdown_text,
fallback_title=Path(uploaded_file.name).stem or "Document",
)
st.session_state["output_name"] = output_name
st.session_state["preview_url"] = register_preview(st.session_state["html_result"])
st.rerun()
except RuntimeError as exc:
st.session_state["html_result"] = None
st.session_state["preview_url"] = None
st.error(str(exc))
html_result = st.session_state["html_result"]
if html_result:
body_html = extract_body_html(html_result)
with st.container(border=True):
st.caption(
"Inline-превью без стилей. Для точного вида — «Открыть превью» или скачайте файл."
)
st.markdown(body_html, unsafe_allow_html=True)
with st.expander("Показать исходный HTML", icon=":material/code:"):
st.code(html_result, language="html")