phase0: archive Python implementation under archive/

This commit is contained in:
Sergey Filkin
2026-04-18 11:29:36 +03:00
parent 771169f93f
commit 425eae7170
20 changed files with 558 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.DS_Store
.claude/
.agents/
.review-sandboxes/
md/*.html
__pycache__/
*.pyc
venv/
.venv/
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3); u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "start.py"]
+1
View File
@@ -0,0 +1 @@
Архивная Python-реализация md-to-html v0.1.2. Для истории.
+5
View File
@@ -0,0 +1,5 @@
"""Application package for the md-to-html service."""
from app.version import __version__
__all__ = ["__version__"]
+189
View File
@@ -0,0 +1,189 @@
import os
from typing import Any
from urllib.error import URLError
from urllib.request import Request, urlopen
from fastapi import FastAPI, HTTPException, Request as FastAPIRequest, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, field_validator
from app.converter import convert, load_template_text
from app.version import __version__
DEFAULT_MAX_MARKDOWN_BYTES = 1_048_576
DEFAULT_MAX_REQUEST_BYTES = 1_200_000
def get_int_env(name: str, default: int) -> int:
raw_value = os.getenv(name)
if raw_value is None:
return default
try:
value = int(raw_value)
except ValueError as exc:
raise RuntimeError(f"{name} must be an integer.") from exc
if value <= 0:
raise RuntimeError(f"{name} must be positive.")
return value
def get_bool_env(name: str, default: bool = False) -> bool:
raw_value = os.getenv(name)
if raw_value is None:
return default
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
class ConvertRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
markdown: str
title: str | None = None
@field_validator("markdown")
@classmethod
def validate_markdown_size(cls, value: str) -> str:
max_markdown_bytes = get_int_env(
"MAX_MARKDOWN_BYTES", DEFAULT_MAX_MARKDOWN_BYTES
)
if len(value.encode("utf-8")) > max_markdown_bytes:
raise HTTPException(
status_code=413,
detail=f"markdown exceeds {max_markdown_bytes} bytes",
)
return value
class MaxRequestSizeMiddleware:
def __init__(self, app: Any, max_request_bytes: int) -> None:
self.app = app
self.max_request_bytes = max_request_bytes
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {
key.decode("latin1").lower(): value.decode("latin1")
for key, value in scope.get("headers", [])
}
content_length = headers.get("content-length")
if content_length:
try:
if int(content_length) > self.max_request_bytes:
await self._send_413(scope, receive, send)
return
except ValueError:
pass
body = bytearray()
while True:
message = await receive()
if message["type"] != "http.request":
if message["type"] == "http.disconnect":
return
continue
chunk = message.get("body", b"")
body.extend(chunk)
if len(body) > self.max_request_bytes:
await self._send_413(scope, receive, send)
return
if not message.get("more_body", False):
break
body_bytes = bytes(body)
body_sent = False
async def replay_receive():
nonlocal body_sent
if body_sent:
return {"type": "http.request", "body": b"", "more_body": False}
body_sent = True
return {"type": "http.request", "body": body_bytes, "more_body": False}
await self.app(scope, replay_receive, send)
async def _send_413(self, scope, receive, send) -> None:
response = JSONResponse(
status_code=413,
content={"detail": f"request exceeds {self.max_request_bytes} bytes"},
)
await response(scope, receive, send)
app = FastAPI(title="md-to-html")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET"],
allow_headers=["content-type"],
)
app.add_middleware(
MaxRequestSizeMiddleware,
max_request_bytes=get_int_env("MAX_REQUEST_BYTES", DEFAULT_MAX_REQUEST_BYTES),
)
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(
request: FastAPIRequest, exc: RequestValidationError
) -> JSONResponse:
return JSONResponse(status_code=400, content={"detail": exc.errors()})
@app.post("/convert")
async def convert_markdown(payload: ConvertRequest) -> Response:
if not payload.markdown.strip():
raise HTTPException(status_code=400, detail="markdown must not be empty")
fallback_title = payload.title or "Document"
try:
html_result = convert(payload.markdown, fallback_title=fallback_title)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return Response(content=html_result, media_type="text/html; charset=utf-8")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/version")
async def version() -> dict[str, str]:
return {"version": __version__}
@app.get("/ready")
async def ready() -> dict[str, Any]:
details: dict[str, Any] = {"status": "ok", "template_loaded": True}
try:
load_template_text()
except Exception as exc:
raise HTTPException(status_code=503, detail=f"Template load failed: {exc}") from exc
if get_bool_env("READY_CHECK_GITHUB", default=False):
request = Request(
"https://api.github.com",
headers={"User-Agent": "md-to-html-service-readiness"},
method="HEAD",
)
try:
with urlopen(request, timeout=5) as response:
details["github_status"] = response.status
except URLError as exc:
raise HTTPException(
status_code=503,
detail=f"GitHub readiness check failed: {exc.reason}",
) from exc
else:
details["github_status"] = "skipped"
return details
+103
View File
@@ -0,0 +1,103 @@
import json
import os
import re
from functools import lru_cache
from html.parser import HTMLParser
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
API_URL = "https://api.github.com/markdown"
API_VERSION = "2022-11-28"
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "template.html"
class FirstHeadingParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._capture = False
self._done = False
self._parts: list[str] = []
def handle_starttag(self, tag: str, attrs) -> None:
if self._done:
return
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self._capture = True
def handle_endtag(self, tag: str) -> None:
if self._capture and tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self._capture = False
self._done = True
def handle_data(self, data: str) -> None:
if self._capture and not self._done:
self._parts.append(data)
def title(self) -> str:
return "".join(self._parts).strip()
def render_markdown(markdown_text: str) -> str:
payload = json.dumps({"text": markdown_text}).encode("utf-8")
headers = {
"Accept": "text/html",
"Content-Type": "application/json",
"User-Agent": "md-to-html-service",
"X-GitHub-Api-Version": API_VERSION,
}
github_token = os.getenv("GITHUB_TOKEN")
if github_token:
headers["Authorization"] = f"Bearer {github_token}"
request = Request(API_URL, data=payload, headers=headers, method="POST")
try:
with urlopen(request, timeout=30) as response:
return response.read().decode("utf-8")
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"GitHub API error: {exc.code} {exc.reason}\n{error_body}"
) from exc
except URLError as exc:
raise RuntimeError(f"Failed to reach GitHub API: {exc.reason}") from exc
def extract_title(html_text: str, fallback: str) -> str:
parser = FirstHeadingParser()
parser.feed(html_text)
return parser.title() or fallback
def apply_template(template_text: str, html_text: str, title: str) -> str:
updated = re.sub(
r"<title>.*?</title>",
f"<title>{title}</title>",
template_text,
flags=re.DOTALL,
)
output_lines = []
inserted = False
html_lines = [f" {line}" if line else "" for line in html_text.splitlines()]
for line in updated.splitlines():
if not inserted and "Markdown -->" in line:
output_lines.extend(html_lines)
inserted = True
continue
output_lines.append(line)
if not inserted:
raise RuntimeError("Template placeholder not found.")
return "\n".join(output_lines) + "\n"
@lru_cache(maxsize=1)
def load_template_text() -> str:
return TEMPLATE_PATH.read_text(encoding="utf-8")
def convert(markdown_text: str, fallback_title: str = "Document") -> str:
html_text = render_markdown(markdown_text)
title = extract_title(html_text, fallback_title)
template_text = load_template_text()
return apply_template(template_text, html_text, title)
+270
View File
@@ -0,0 +1,270 @@
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
from app.version import __version__
except ModuleNotFoundError:
sys.path.append(str(Path(__file__).resolve().parent.parent))
from app.converter import convert
from app.version import __version__
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(
f"Версия {__version__}. Загрузите markdown-файл или вставьте текст, проверьте превью и скачайте готовый HTML."
)
input_mode = st.segmented_control(
"Источник Markdown",
options=["Файл", "Текст"],
default="Файл",
)
uploaded_file = None
pasted_markdown = ""
if input_mode == "Файл":
uploaded_file = st.file_uploader(
"Загрузите .md файл",
type=["md", "markdown"],
)
else:
pasted_markdown = st.text_area(
"Вставьте Markdown из буфера обмена",
placeholder="# Заголовок\n\nВставьте сюда markdown-текст.",
height=260,
)
html_result = st.session_state["html_result"]
is_convert_disabled = (
uploaded_file is None if input_mode == "Файл" else not pasted_markdown.strip()
)
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=is_convert_disabled,
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 not is_convert_disabled:
if input_mode == "Файл":
markdown_bytes = uploaded_file.getvalue()
markdown_text = markdown_bytes.decode("utf-8")
fallback_title = Path(uploaded_file.name).stem or "Document"
output_name = f"{fallback_title}.html"
else:
markdown_text = pasted_markdown
fallback_title = "Document"
output_name = "document.html"
try:
st.session_state["html_result"] = convert(
markdown_text,
fallback_title=fallback_title,
)
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")
+10
View File
@@ -0,0 +1,10 @@
from pathlib import Path
VERSION_FILE = Path(__file__).resolve().parent.parent / "VERSION"
def read_version() -> str:
return VERSION_FILE.read_text(encoding="utf-8").strip()
__version__ = read_version()
BIN
View File
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
Описание Github API конвертера markdown в HTML: https://docs.github.com/en/rest/markdown/markdown?apiVersion=2022-11-28
Пример вызова API:
```bash
curl -L \
-X POST \
-H "Accept: text/html" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/markdown \
-d '{"text":"## Title 2\nHello **world**"}'
```
Ответ:
```html
<div class="markdown-heading"><h2 class="heading-element">Title 2</h2><a id="user-content-title-2" class="anchor" aria-label="Permalink: Title 2" href="#title-2"><span aria-hidden="true" class="octicon octicon-link"></span></a></div>
<p>Hello <strong>world</strong></p>
```
Нужен простой python скрипт, который будет:
1. Принимать на вход путь к markdown файлу `..path/example.md`
2. Через Github API конвертировать его в html
3. Формировать новый html файл по шаблону `md\template.html` и сохранять результат рядом в `..path/example.html`
===
У меня есть готовый пайтон скрипт md_to_html.py, который умеет конвертировать markdown в html с помощью Github API.
Мне нужно переделать его в простое Streamlit приложение, которое будет иметь следующий интерфейс:
1. Поле для загрузки markdown файла
2. Кнопка для конвертации
3. Поле для отображения результата в виде HTML и возможность скачать результат в виде HTML файла
Так же хочу этот проект запускать в докере.
Было бы классно еще иметь один публичный API ендпоинт, который будет принимать markdown текст и возвращать html результат, чтобы можно было использовать этот сервис в других приложениях.
Задай вопросы, если что-то не понятно или есть неоднозночности или неопределенности.
Создай репозиторий на GitHub для этого проекта.
Введи версии релизов.
Настрой на гитхаб Actions для автоматической сборки и публикации докер образа при каждом релизе.
+226
View File
@@ -0,0 +1,226 @@
# План: md-to-html — Streamlit UI + публичный API в Docker
## Context
Сейчас в проекте есть CLI-скрипт `md_to_html.py`, который через GitHub Markdown API конвертирует `.md` файл в самодостаточный HTML (с CSS-шаблоном `md/template.html`). Нужно превратить его в сервис с двумя интерфейсами:
1. **Streamlit UI** — загрузить `.md`, нажать кнопку, увидеть превью HTML и скачать результат.
2. **Публичный REST API** — принимает markdown-текст, отдаёт готовый HTML. Для интеграции с другими приложениями.
Всё это должно упаковываться в Docker-образ.
Решения, зафиксированные по ходу обсуждения:
- FastAPI и Streamlit живут в **одном контейнере** (два процесса, запуск через стартовый скрипт).
- Возвращается **полная HTML-страница** с применённым `template.html` — и в API, и в превью Streamlit.
- Читаем `GITHUB_TOKEN` из env (опционально, для обхода лимита 60/час).
- API **без аутентификации и rate-limiting** — минимальный стартовый вариант.
## Архитектура
```
md-to-html/
├── md_to_html.py # оставить как есть (работающий CLI)
├── md/template.html # без изменений, используется как раньше
├── app/
│ ├── __init__.py
│ ├── converter.py # общая логика (вынесена из md_to_html.py)
│ ├── api.py # FastAPI приложение
│ └── streamlit_app.py # Streamlit UI
├── requirements.txt
├── Dockerfile
├── start.py # Python-супервизор: запускает uvicorn + streamlit
├── .dockerignore
└── README.md # короткая инструкция запуска
```
Порты в контейнере: FastAPI — 8000, Streamlit — 8501. Оба пробрасываются наружу.
## Что делать
### 1. `app/converter.py` — общий модуль
Вынести из `md_to_html.py` переиспользуемые функции (без изменения логики):
- `render_markdown(markdown_text: str) -> str` — вызов GitHub API. Добавить чтение `GITHUB_TOKEN` из env: если задан, слать `Authorization: Bearer <token>`.
- `FirstHeadingParser` + `extract_title(html_text, fallback) -> str`.
- `apply_template(template_text, html_text, title) -> str`.
- Хелпер `convert(markdown_text: str, fallback_title: str = "Document") -> str` — объединяет три шага и читает `md/template.html` один раз (кэшировать через `functools.lru_cache`).
Путь к шаблону: `Path(__file__).resolve().parent.parent / "md" / "template.html"`.
`md_to_html.py` переписать так, чтобы он импортировал `convert` из `app.converter` — убрать дублирование. CLI-поведение сохранить (входной путь → записать рядом `.html`).
### 2. `app/api.py` — FastAPI
Endpoints:
- `POST /convert`
- Тело: `{"markdown": "<text>", "title": "<optional>"}` — Pydantic-модель с `field_validator` на `markdown`, проверяющим `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (именно **байты UTF-8**, не `constr(max_length=...)` — тот считает символы). При превышении — `raise HTTPException(status_code=413, detail=...)`, чтобы обойти дефолтный `422` FastAPI-валидатора.
- **Приоритет title (зафиксировано явно):**
1. Первый `<h1..h6>` из отрендеренного HTML.
2. Если heading отсутствует — переданный `title` из запроса.
3. Если и его нет — `"Document"`.
- Ответ: `text/html` с полной страницей (`Response(content=..., media_type="text/html; charset=utf-8")`).
- Ошибки: пустой markdown → `400` (через отдельную проверку в роуте, до валидации); превышение размера → `413` (через ручной `HTTPException` в валидаторе, см. выше); `RuntimeError` от GitHub API → `502 Bad Gateway` с текстом исключения.
- Дополнительно: `exception_handler(RequestValidationError)` перехватывает Pydantic-422 и возвращает структурированный `400` — чтобы публичный API не отдавал разные коды на разные виды плохого ввода.
- `GET /health``{"status": "ok"}` для проверки.
- `GET /ready` → проверяет, что шаблон загружен и при желании пингует `https://api.github.com` (опционально).
**Лимит размера запроса (два уровня, defence-in-depth):**
1. **Hard guard — ASGI middleware до парсинга тела.** Читает `Content-Length` и, если превышает `MAX_REQUEST_BYTES = 1_200_000` (немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает `413` без чтения тела. Если `Content-Length` отсутствует (chunked) — аккуратно считать байты из `receive()` и обрывать при превышении. Это настоящий request-size guard, не post-parse.
2. **Soft guard — Pydantic `field_validator` на поле `markdown`,** проверяющий `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (`1_048_576`, 1 МБ) и поднимающий `HTTPException(413)`. Это вторая линия — на случай, если middleware обойдут (прокси, переписанные заголовки).
GitHub Markdown API сам ограничивает вход ~400 КБ, поэтому 1 МБ на стороне сервиса — безопасный запас с отсечкой абьюза. Значения вынести в env `MAX_MARKDOWN_BYTES` и `MAX_REQUEST_BYTES`.
CORS открыть для всех origin (`allow_origins=["*"]`, `allow_methods=["POST","GET"]`, `allow_headers=["content-type"]`).
### 3. `app/streamlit_app.py` — UI
Минимальный интерфейс:
1. `st.title("Markdown → HTML")`
2. `st.file_uploader("Загрузите .md файл", type=["md", "markdown"])`
3. Кнопка `st.button("Конвертировать")` — активна только когда файл загружен.
4. После клика: вызвать `convert()` из `app.converter` напрямую (не через HTTP — это тот же Python-импорт, один процесс).
5. Результат (три способа посмотреть, без deprecated API и без iframe-споров):
- **Inline-превью (approximate):** извлечь содержимое `<body>` из результата (простой regex/BeautifulSoup — фрагмент HTML без `<html>/<head>`, без CSS из template) и отрендерить через `st.markdown(body_html, unsafe_allow_html=True)`. Это приблизительный рендер без стилей template — нужен для быстрой проверки разметки. Явно подписать: *«Inline-превью без стилей. Для точного вида — «Открыть превью в новой вкладке» или скачайте файл.»*
- **Превью в новой вкладке:** `st.link_button("Открыть превью", url=f"data:text/html;charset=utf-8;base64,{b64(html_result)}")` — data-URL с полной страницей. Визуально идентично скачанному файлу, без component-API. **Fallback на большие документы:** если `len(html_result.encode()) > 1_500_000`, кнопка не рендерится, вместо неё показать `st.info("Документ слишком большой для превью в браузере. Скачайте файл.")` — браузеры ограничивают длину data-URL (~2 МБ в Chrome), а base64-кодирование раздувает payload в 1.33×.
- **Скачивание:** `st.download_button("Скачать HTML", data=html_result, file_name=f"{stem}.html", mime="text/html")`.
- **Сырой HTML:** `st.expander("Показать исходный HTML")` с `st.code(html_result, language="html")`.
Обоснование отказа от `st.components.v1.html` / `st.components.v2.*`: project skill `developing-with-streamlit` помечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (`st.components.v2.component()`), не для простого показа HTML. Для нашего случая native-решения (`st.markdown` + `st.link_button` с data-URL) достаточно.
6. Хранить результат в `st.session_state["html_result"]`, чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново.
Обработка ошибок: `try/except RuntimeError``st.error(str(e))`.
### 4. Зависимости и окружение (локальная разработка)
Локально использовать **`uv`** + виртуальное окружение, не системный `pip`:
```bash
uv venv .venv # создать venv в .venv/
source .venv/bin/activate # (или `uv run <cmd>` без активации)
uv pip install -r requirements.txt
```
Либо эквивалент через `uv pip sync requirements.txt`, либо (если решим сразу в pep-621-формате) — `pyproject.toml` + `uv sync`. Для данного плана достаточно плоского `requirements.txt` ради совместимости с Docker-образом, где `uv` не обязателен.
`.gitignore`/`.dockerignore` — исключить `.venv/`.
**`requirements.txt`:**
```
streamlit>=1.42
fastapi>=0.115
uvicorn[standard]>=0.32
pydantic>=2.9
```
Streamlit зафиксирован `>=1.42` (актуальная стабильная ветка на момент планирования). Никаких HTTP-клиентов: `render_markdown` использует стандартную `urllib`. Для извлечения `<body>` в inline-превью достаточно stdlib (`html.parser`) — ту же `HTMLParser`-базу, что уже применяется в `FirstHeadingParser`. Отдельная зависимость на BeautifulSoup не нужна.
**В Docker-образе** `uv` не используется — остаёмся на `pip install --no-cache-dir -r requirements.txt`, чтобы не тащить лишний бинарь в runtime-образ. `uv` — только для локального dev-цикла.
### 5. `Dockerfile`
- Базовый образ: `python:3.12-slim`.
- Установить `tini` через apt (`apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*`).
- `WORKDIR /app`, скопировать `requirements.txt`, `pip install --no-cache-dir -r requirements.txt`.
- Скопировать остальной код.
- `EXPOSE 8000 8501`.
- `ENTRYPOINT ["/usr/bin/tini", "--"]`, `CMD ["python", "start.py"]`.
- `HEALTHCHECK` — см. секцию 6b.
### 6. Запуск двух процессов — `start.py` (супервизор)
В одном контейнере два процесса — это риск (см. F-01 из ревью: упавший uvicorn, неубитые zombies, игнор сигналов PID 1). Вместо хрупкого shell-скрипта использовать маленький Python-супервизор, который:
- стартует `uvicorn` и `streamlit` через `subprocess.Popen`;
- пробрасывает `SIGTERM`/`SIGINT` обоим дочерним через `signal.signal`;
- ждёт через `os.wait()`**если любой из детей падает, супервизор убивает второго и выходит с его exit code** (контейнер корректно умирает и Docker перезапускает его по restart policy, а не живёт-зомби на одном сервисе);
- после `SIGTERM` делает graceful shutdown с таймаутом (например 10 сек), затем `SIGKILL`.
Скрипт ~40 строк, без дополнительных зависимостей. Альтернатива `tini` как init (`ENTRYPOINT ["/usr/bin/tini", "--"]`) — для reaping, но решение о fail-fast всё равно за супервизором.
Ориентир (псевдокод, финализировать при реализации):
```python
# start.py
import os, signal, subprocess, sys
procs = [
subprocess.Popen(["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]),
subprocess.Popen(["streamlit", "run", "app/streamlit_app.py",
"--server.port", "8501", "--server.address", "0.0.0.0",
"--server.headless", "true",
"--browser.gatherUsageStats", "false"]),
]
def shutdown(signum, _frame):
for p in procs: p.terminate()
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
pid, status = os.wait()
for p in procs:
if p.pid != pid: p.terminate()
sys.exit(os.waitstatus_to_exitcode(status))
```
`Dockerfile` использует `CMD ["python", "start.py"]` плюс `ENTRYPOINT ["tini", "--"]` (ставится через `apt-get install -y --no-install-recommends tini`) для надёжного reaping.
### 6b. Healthcheck
`HEALTHCHECK` в Dockerfile проверяет **оба** сервиса:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request as u; \
u.urlopen('http://127.0.0.1:8000/health', timeout=3); \
u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" || exit 1
```
Если упадёт только Streamlit — healthcheck покраснеет, контейнер перезапустится (в паре с restart policy).
### 7. `.dockerignore`
Исключить `.git`, `.DS_Store`, `.claude/`, `.agents/`, `.review-sandboxes/`, `md/*.html` (сгенерированное), `__pycache__/`, `*.pyc`, `venv/`, `.venv/`.
### 8. `README.md`
Короткий блок: как собрать образ (`docker build -t md-to-html .`), как запустить (`docker run -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=... md-to-html`), примеры `curl` для API.
## Критические файлы
- **Создать:** `app/converter.py`, `app/api.py`, `app/streamlit_app.py`, `app/__init__.py`, `requirements.txt`, `Dockerfile`, `start.py`, `.dockerignore`, `README.md`.
- **Изменить:** `md_to_html.py` (переписать на использование `app.converter.convert`).
- **Без изменений:** `md/template.html`, `md/01-01-pretask.md`.
## Проверка (verification)
1. **Локально без Docker (через uv + venv):**
- `uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt`.
- `uvicorn app.api:app --reload``curl -X POST http://localhost:8000/convert -H 'Content-Type: application/json' -d '{"markdown":"# Hello"}'` — должна вернуться полная HTML-страница с `<title>Hello</title>`.
- `streamlit run app/streamlit_app.py` → загрузить `md/01-01-pretask.md`, нажать кнопку, убедиться что превью отрисовывается и скачивание работает.
- `GET /health``{"status":"ok"}`.
- CLI не сломался: `python md_to_html.py md/01-01-pretask.md` создаёт рядом `.html`, идентичный прежнему.
2. **Error paths API:**
- `curl -X POST .../convert -d '{"markdown":""}'``400`.
- `curl` с телом >1 МБ (поле `markdown` превышает `MAX_MARKDOWN_BYTES`) → `413` (validator).
- `curl --data-binary @big.json` >1.2 МБ общего размера → `413` (middleware).
- `curl -H "Transfer-Encoding: chunked" --data-binary @big.json` без `Content-Length``413` (middleware-ветка подсчёта байт из `receive()`).
- Невалидный JSON (отсутствует поле `markdown`) → `400` (через `RequestValidationError` handler).
- Имитация недоступности GitHub (временно подменить `API_URL` или поднять firewall rule) → `502`, контейнер не падает.
3. **Preview fallback в Streamlit:** загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что `link_button` скрывается и появляется `st.info` про скачивание; `download_button` при этом работает.
4. **В Docker:**
- `docker build -t md-to-html .` — собирается без ошибок.
- `docker run --rm -p 8000:8000 -p 8501:8501 md-to-html` — оба порта отвечают.
- Открыть `http://localhost:8501`, прогнать сценарий из пункта 1.
- `curl` на `http://localhost:8000/convert` работает снаружи контейнера.
5. **Supervision (F-01):**
- Внутри работающего контейнера убить uvicorn (`docker exec ... pkill -f uvicorn`) → контейнер **должен завершиться** (не остаться с одним Streamlit). `docker ps` покажет рестарт.
- `docker stop <container>` — оба процесса уходят в ≤10 сек, exit code корректный.
- `docker inspect ... | grep Health` после 30 сек — `healthy`; после kill любого сервиса — `unhealthy`.
6. **С токеном:** `docker run -e GITHUB_TOKEN=ghp_... ...` — запросы проходят, в логах нет 403/429 при нагрузке.
+65
View File
@@ -0,0 +1,65 @@
1. Функциональный паритет с GitHub API. Сейчас HTML от GitHub даёт: таблицы, task-list, strikethrough, autolinks, footnotes, подсветку кода, emoji
:name:, и главное — обёртки <div class="markdown-heading"> с якорями <a class="anchor"> (на них завязан CSS в template.html). Что нужно сохранить 1-в-1?
- a) Полный GFM (goldmark поддерживает через extension.GFM) — да/нет?
- b) Подсветку кода chroma встроить в <pre><code>? Или оставить «просто теги» без классов?
- c) Emoji-shortcodes (yuin/goldmark-emoji)?
- d) Обёртки heading’ов с якорями (делается через abhinav/goldmark-anchor или кастомный renderer). Если убрать — придётся править CSS в шаблоне.
- e) Frontmatter (---) — парсить/игнорировать/использовать для title?
2. Архитектура Go-приложения. Предлагаю один бинарник с подкомандами:
- serve — единый HTTP-сервер: / и /convert (форма на templUI, HTMX-превью), /api/convert, /preview/{id}, /health, /version, /ready, /download/{id}.
- cli <file.md> — режим CLI (заменяет md_to_html.py).
Подходит, или надо разделить два бинарника (api и ui)?
3. templUI-стек. templUI = templ + Tailwind + Alpine.js + HTMX. Подтвердите:
- a) Tailwind CSS сборку (tailwindcss CLI) встраиваем в Docker/Makefile?
- b) HTMX для live-превью (без полной перезагрузки) — желательно?
- c) Тёмная тема / языковой переключатель — нужны или оставляем русский-only как сейчас?
4. Структура репозитория. Ваш вариант — archive/ для Python, корень для Go. Подтвердите:
/archive/ # текущий Python-проект целиком
/cmd/md-to-html/ # main.go
/internal/converter/ # goldmark-рендер
/internal/server/ # HTTP handlers
/internal/ui/ # .templ файлы (templUI components)
/web/static/ # Tailwind output, favicon
/template/ # самодостаточный HTML-шаблон для итога
go.mod / Makefile / Dockerfile
Go module path — github.com/fserg/md-to-html?
5. Шаблон итогового HTML. template.html сейчас рассчитан на GitHub-разметку (классы .markdown-heading, .heading-element, .anchor). Варианты:
- a) Сохранить визуал идентично → реализовать goldmark-renderer, генерирующий ту же разметку.
- b) Упростить шаблон под «чистый» goldmark-вывод (меньше кода, чуть другой вид h2/h3).
Какой?
6. Версионирование и релизы. Сейчас v0.1.2, GitHub Actions собирает Docker в GHCR. После переписывания:
- a) Бампнуть до v0.2.0 (или v1.0.0)?
- b) CI: заменить на Go-сборку (тесты + cross-compile linux/amd64,arm64, darwin/arm64) + Docker multi-stage?
- c) Публиковать бинарники в GitHub Releases?
7. Нефункциональное.
- Лимиты MAX_MARKDOWN_BYTES, MAX_REQUEST_BYTES — переносим (envs)?
- Go 1.23+?
- Тесты: golden-файлы (MD→HTML diff против эталона) + smoke-тесты HTTP?
- air / templ generate --watch для dev-режима?
# Ответы
1. Полный GFM и хотелось бы подсветку кода. Шаблон можно править как угодно под новый рендер. Якоря в заголовках хотелось бы сохранить, так как они полезны для навигации по документу.
2. Один бинарник, включая cli режим.
3. templUI можно встраивать в бинарник? Лайв-превью было бы круто, но не критично. Тёмная тема и языковой переключатель не нужны, так как целевая аудитория русскоязычная.
4. archive/ для Python, корень для Go
5. Шаблон поменяй под новый проект
6. После перехода на Go предлагаю бампнуть до v0.2.0
7. Про лимиты не знаю, на твое усмотрение и Го - на твой выбор. Тесты с golden-файлами звучат отлично, а для dev-режима air / templ generate --watch будет удобно.
===
Сохрани подробный план как md/02-01-plan.md
+210
View File
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Проект 1С УНФ 3.0</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.45;
color: #1a1a1a;
background: #fafafa;
padding: 16px 12px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: #ffffff;
padding: 50px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.markdown-heading {
margin-top: 10px;
margin-bottom: 5px;
position: relative;
}
.markdown-heading:first-child {
margin-top: 0;
}
.heading-element {
font-weight: 600;
letter-spacing: -0.02em;
color: #0a0a0a;
}
h2.heading-element {
font-size: 28px;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 4px;
margin-bottom: 6px;
}
h3.heading-element {
font-size: 20px;
margin-bottom: 6px;
}
.anchor {
text-decoration: none;
color: inherit;
opacity: 0;
transition: opacity 0.2s;
margin-left: 8px;
}
.markdown-heading:hover .anchor {
opacity: 0.5;
}
.anchor:hover {
opacity: 1 !important;
}
p {
margin-bottom: 8px;
color: #2a2a2a;
}
ul,
ol {
margin-bottom: 8px;
padding-left: 20px;
}
li {
margin-bottom: 2px;
color: #2a2a2a;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
color: #525252;
border: 1px solid #e8e8e8;
}
strong {
font-weight: 600;
color: #0a0a0a;
}
a {
color: #2563eb;
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #1d4ed8;
}
@media (max-width: 768px) {
body {
padding: 12px 10px;
}
.container {
padding: 28px 20px;
}
h2.heading-element {
font-size: 24px;
}
h3.heading-element {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="markdown-heading">
<h2 class="heading-element">Проект 1С Управление нашей фирмой (УНФ) 3.0 с доработками в расширениях
конфигурации
</h2><a id="user-content-проект-1с-управление-нашей-фирмой-унф-30-с-доработками-в-расширениях-конфигурации"
class="anchor"
aria-label="Permalink: Проект 1С Управление нашей фирмой (УНФ) 3.0 с доработками в расширениях конфигурации"
href="#проект-1с-управление-нашей-фирмой-унф-30-с-доработками-в-расширениях-конфигурации"><span
aria-hidden="true" class="octicon octicon-link"></span></a>
</div>
<div class="markdown-heading">
<h3 class="heading-element">Основная кодовая база</h3><a id="user-content-основная-кодовая-база"
class="anchor" aria-label="Permalink: Основная кодовая база" href="#основная-кодовая-база"><span
aria-hidden="true" class="octicon octicon-link"></span></a>
</div>
<ul>
<li>Исходный код основной конфигурации 1С Управление нашей фирмой (УТ) 3.0.12.146:
<code>1c-src/Configuration</code>
</li>
</ul>
<div class="markdown-heading">
<h3 class="heading-element">Расширения конфигурации</h3><a id="user-content-расширения-конфигурации"
class="anchor" aria-label="Permalink: Расширения конфигурации" href="#расширения-конфигурации"><span
aria-hidden="true" class="octicon octicon-link"></span></a>
</div>
<ul>
<li>расширение конфигурации АПРО_Доработки <code>1c-src/ExtensionsXML/АПРО_Доработки</code> с доработками
функционала по рабочему месту кассиров (РМК) и части документов</li>
</ul>
<div class="markdown-heading">
<h2 class="heading-element">Окружение разработки</h2><a id="user-content-окружение-разработки"
class="anchor" aria-label="Permalink: Окружение разработки" href="#окружение-разработки"><span
aria-hidden="true" class="octicon octicon-link"></span></a>
</div>
<ul>
<li>Разработка ведется в операционной системе Ubuntu 24.04 с использованием платформы 1С:Предприятие
8.3.27.1688 и
конфигуратора 1С:Предприятие.</li>
<li>В системе доступен Python 3.12.</li>
<li>Агентские возможности нужно запускать учитывая особенности консоли на Bash.</li>
</ul>
<div class="markdown-heading">
<h2 class="heading-element">MCP-серверы и когда их вызывать</h2><a
id="user-content-mcp-серверы-и-когда-их-вызывать" class="anchor"
aria-label="Permalink: MCP-серверы и когда их вызывать" href="#mcp-серверы-и-когда-их-вызывать"><span
aria-hidden="true" class="octicon octicon-link"></span></a>
</div>
<div class="markdown-heading">
<h3 class="heading-element">1с-metadata (MCP)</h3><a id="user-content-1с-metadata-mcp" class="anchor"
aria-label="Permalink: 1с-metadata (MCP)" href="#1с-metadata-mcp"><span aria-hidden="true"
class="octicon octicon-link"></span></a>
</div>
<p><strong>Назначение:</strong> быстрый поиск описаний объектов конфигурации (структуры метаданных).
<strong>Жёсткий порядок работы:</strong>
</p>
<ol>
<li>
<code>search_metadata(query[, object_type])</code> → топ-K совпадений (как минимум: <code>id</code>,
<code>name</code>, иногда <code>type</code>, <code>score</code>).
</li>
<li>Выбираешь релевантный результат и вызываешь <code>metadata_details_by_id(id)</code> → подробности по
объекту.
</li>
</ol>
<p><strong>Использовать, когда:</strong></p>
<ul>
<li>нужно понять, существуют ли документ/справочник/регистр и как они называются;</li>
<li>требуется структура объекта, реквизиты, измерения, ресурсы, табличные части и т.п.;</li>
<li>нужно уточнить корректные имена метаданных перед написанием запроса/кода.</li>
</ul>
</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
import argparse
from pathlib import Path
from app.converter import convert
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert a Markdown file to HTML using the GitHub Markdown API."
)
parser.add_argument("input", help="Path to the Markdown file to convert")
return parser.parse_args()
def main() -> int:
args = parse_args()
input_path = Path(args.input).expanduser().resolve()
if not input_path.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
markdown_text = input_path.read_text(encoding="utf-8")
output_text = convert(markdown_text, fallback_title=input_path.stem)
output_path = input_path.with_suffix(".html")
output_path.write_text(output_text, encoding="utf-8")
print(f"Saved: {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
streamlit>=1.42
fastapi>=0.115
uvicorn[standard]>=0.32
pydantic>=2.9
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

+122
View File
@@ -0,0 +1,122 @@
import signal
import subprocess
import sys
from pathlib import Path
GRACEFUL_TIMEOUT_SECONDS = 10
def build_processes() -> list[subprocess.Popen[bytes]]:
root = Path(__file__).resolve().parent
return [
subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"app.api:app",
"--host",
"0.0.0.0",
"--port",
"8000",
],
cwd=root,
),
subprocess.Popen(
[
sys.executable,
"-m",
"streamlit",
"run",
"app/streamlit_app.py",
"--server.port",
"8501",
"--server.address",
"0.0.0.0",
"--server.headless",
"true",
"--browser.gatherUsageStats",
"false",
],
cwd=root,
),
]
def stop_processes(processes: list[subprocess.Popen[bytes]], skip_pid: int | None = None) -> None:
for process in processes:
if process.pid == skip_pid:
continue
if process.poll() is None:
process.terminate()
def reap_processes(
processes: list[subprocess.Popen[bytes]], skip_pid: int | None = None
) -> None:
for process in processes:
if process.pid == skip_pid:
continue
if process.poll() is not None:
continue
try:
process.wait(timeout=GRACEFUL_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def main() -> int:
processes = build_processes()
exit_code = 0
shutting_down = False
def handle_signal(signum, _frame) -> None:
nonlocal exit_code, shutting_down
if shutting_down:
return
shutting_down = True
exit_code = 128 + signum
stop_processes(processes)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
while True:
try:
pid, status = os_wait()
except ChildProcessError:
break
except InterruptedError:
continue
process = next((item for item in processes if item.pid == pid), None)
if process is not None:
process.returncode = os_waitstatus_to_exitcode(status)
if not shutting_down:
exit_code = os_waitstatus_to_exitcode(status)
shutting_down = True
stop_processes(processes, skip_pid=pid)
reap_processes(processes, skip_pid=pid)
break
reap_processes(processes)
return exit_code
def os_wait() -> tuple[int, int]:
import os
return os.wait()
def os_waitstatus_to_exitcode(status: int) -> int:
import os
return os.waitstatus_to_exitcode(status)
if __name__ == "__main__":
raise SystemExit(main())
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><!-- Сюда вставлять текст из первого по порядку <h[N]></h> тега --></title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
line-height: 1.45;
color: #1a1a1a;
background: #fafafa;
padding: 16px 12px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: #ffffff;
padding: 50px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.markdown-heading {
margin-top: 10px;
margin-bottom: 5px;
position: relative;
}
.markdown-heading:first-child {
margin-top: 0;
}
.heading-element {
font-weight: 600;
letter-spacing: -0.02em;
color: #0a0a0a;
}
h2.heading-element {
font-size: 28px;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 4px;
margin-bottom: 6px;
}
h3.heading-element {
font-size: 20px;
margin-bottom: 6px;
}
.anchor {
text-decoration: none;
color: inherit;
opacity: 0;
transition: opacity 0.2s;
margin-left: 8px;
}
.markdown-heading:hover .anchor {
opacity: 0.5;
}
.anchor:hover {
opacity: 1 !important;
}
p {
margin-bottom: 8px;
color: #2a2a2a;
}
ul,
ol {
margin-bottom: 8px;
padding-left: 20px;
}
li {
margin-bottom: 2px;
color: #2a2a2a;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
color: #525252;
border: 1px solid #e8e8e8;
}
pre {
background: #f5f5f5;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 8px;
overflow-x: auto;
}
pre code {
display: block;
background: transparent;
padding: 0;
border: none;
white-space: pre;
}
strong {
font-weight: 600;
color: #0a0a0a;
}
a {
color: #2563eb;
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #1d4ed8;
}
@media (max-width: 768px) {
body {
padding: 12px 10px;
}
.container {
padding: 28px 20px;
}
h2.heading-element {
font-size: 24px;
}
h3.heading-element {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Начало содержимого Markdown -->
</div>
</body>
</html>