19 Commits

Author SHA1 Message Date
Sergey Filkin 2894cf222b chore(progress): complete phase5 2026-04-18 12:17:06 +03:00
Sergey Filkin 6aa19fe12a phase5: cli subcommand with file/stdin input and output options 2026-04-18 12:16:58 +03:00
Sergey Filkin 3b947e278c chore(progress): start phase5 2026-04-18 12:13:16 +03:00
Sergey Filkin ea47b446d4 chore(progress): complete phase4 2026-04-18 12:06:54 +03:00
Sergey Filkin d6aef5560a phase4: templUI-based frontend with HTMX-powered conversion form 2026-04-18 12:06:43 +03:00
Sergey Filkin ac826e8b5e chore(progress): start phase4 2026-04-18 11:57:25 +03:00
Sergey Filkin c2298ac1bd chore(progress): complete phase3 2026-04-18 11:56:00 +03:00
Sergey Filkin 843d8dc710 phase3: HTTP server with converter, one-shot preview store, and middleware 2026-04-18 11:55:42 +03:00
Sergey Filkin d1682813ff chore(progress): start phase3 2026-04-18 11:49:03 +03:00
Sergey Filkin 5674177943 chore(progress): complete phase2 2026-04-18 11:47:38 +03:00
Sergey Filkin 8deba3627f phase2: markdown converter with goldmark, chroma, and ASCII-translit anchors 2026-04-18 11:47:18 +03:00
Sergey Filkin cab04768b5 chore(progress): start phase2 2026-04-18 11:38:11 +03:00
Sergey Filkin 621158ae54 chore(progress): complete phase1 2026-04-18 11:36:52 +03:00
Sergey Filkin 6b8d588c43 phase1: go module skeleton with subcommand stubs 2026-04-18 11:36:39 +03:00
Sergey Filkin f36e9f003f chore(progress): start phase1 2026-04-18 11:33:14 +03:00
Sergey Filkin 17debf2aca chore(progress): complete phase0 2026-04-18 11:30:06 +03:00
Sergey Filkin 425eae7170 phase0: archive Python implementation under archive/ 2026-04-18 11:29:36 +03:00
Sergey Filkin 771169f93f chore(progress): start phase0 2026-04-18 11:27:55 +03:00
Sergey Filkin cbb281d14c Update .gitignore to include docs/ directory 2026-04-18 11:26:18 +03:00
100 changed files with 11513 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o tmp/md-to-html ./cmd/md-to-html"
bin = "tmp/md-to-html serve"
exclude_dir = ["archive", "tmp", "bin", "web/static/dist", "node_modules", ".review-sandboxes", ".git"]
include_ext = ["go", "templ", "html"]
+16
View File
@@ -15,3 +15,19 @@ __pycache__/
# Local markdown workspace
md/
docs/
# Go
/md-to-html
/bin/
/dist/
/tmp/
*.test
*.out
# Web build artifacts
/web/static/dist/
/node_modules/
# Air live-reload
.air.log
+7
View File
@@ -0,0 +1,7 @@
{
"componentsDir": "internal/ui/components",
"utilsDir": "internal/ui/utils",
"moduleName": "github.com/fserg/md-to-html",
"jsDir": "web/static/assets/js",
"jsPublicPath": "/static/assets/js"
}
+39
View File
@@ -0,0 +1,39 @@
VERSION := $(shell cat VERSION)
LDFLAGS := -X github.com/fserg/md-to-html/internal/version.Version=$(VERSION)
GOBIN := $(shell go env GOPATH)/bin
TEMPL := $(GOBIN)/templ
.PHONY: build run test templ tailwind dev docker clean tools
build:
go build -ldflags "$(LDFLAGS)" -o bin/md-to-html ./cmd/md-to-html
run:
go run ./cmd/md-to-html serve
test:
go test ./...
templ:
$(TEMPL) generate ./...
tailwind:
mkdir -p web/static/dist
npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --minify
dev:
mkdir -p web/static/dist
sh -c 'npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --watch & \
TAILWIND_PID=$$!; \
trap "kill $$TAILWIND_PID" EXIT INT TERM; \
$(TEMPL) generate --watch --proxy=http://localhost:8080 --cmd="go run ./cmd/md-to-html serve"'
docker:
@echo "docker target will be implemented in phase 6"
clean:
rm -rf bin/ tmp/ web/static/dist/
tools:
go install github.com/a-h/templ/cmd/templ@v0.3.1001
go install github.com/templui/templui/cmd/templui@latest
View File
+1
View File
@@ -0,0 +1 @@
Архивная Python-реализация md-to-html v0.1.2. Для истории.
View File
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>
View File

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

View File
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"syscall"
internalcli "github.com/fserg/md-to-html/internal/cli"
"github.com/fserg/md-to-html/internal/converter"
"github.com/fserg/md-to-html/internal/server"
"github.com/fserg/md-to-html/internal/version"
webtemplate "github.com/fserg/md-to-html/web/template"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stdout)
return 0
}
switch args[0] {
case "-h", "--help", "help":
printUsage(stdout)
return 0
case "serve":
return runServe(args[1:], stdout, stderr)
case "cli":
return runCLI(args[1:], stdout, stderr)
case "version":
return runVersion(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unknown subcommand %q\n\n", args[0])
printUsage(stderr)
return 2
}
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 0 {
fmt.Fprintln(stderr, "usage: md-to-html serve")
return 2
}
cfg, err := server.LoadConfig()
if err != nil {
fmt.Fprintf(stderr, "load config: %v\n", err)
return 1
}
conv, err := converter.New(webtemplate.FS)
if err != nil {
fmt.Fprintf(stderr, "load converter: %v\n", err)
return 1
}
srv, err := server.New(cfg, conv)
if err != nil {
fmt.Fprintf(stderr, "create server: %v\n", err)
return 1
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := srv.Run(ctx); err != nil {
fmt.Fprintf(stderr, "run server: %v\n", err)
return 1
}
_ = stdout
return 0
}
func runCLI(args []string, stdout, stderr io.Writer) int {
err := internalcli.Run(context.Background(), args, os.Stdin, stdout, stderr)
if err == nil {
return 0
}
if errors.Is(err, internalcli.ErrUsage) {
return 2
}
fmt.Fprintln(stderr, err)
return 1
}
func runVersion(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("version", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 0 {
fmt.Fprintln(stderr, "usage: md-to-html version")
return 2
}
fmt.Fprintln(stdout, version.Version)
return 0
}
func printUsage(w io.Writer) {
fmt.Fprint(w, `Usage:
md-to-html serve
md-to-html cli [--stdin|-|<file.md>] [--output path] [--title str]
md-to-html version
Commands:
serve Start the HTTP server
cli Convert Markdown from a file or stdin
version Print the build version
`)
}
+46
View File
@@ -0,0 +1,46 @@
# Прогресс миграции Python → Go
Источник истины по статусу фаз. Обновляется после каждого завершённого шага.
- Общий план: [plan-go-migration.md](plan-go-migration.md)
- Универсальный промпт для запуска фазы: [execute-phase-prompt.md](execute-phase-prompt.md)
## Статус
| # | Фаза | Статус | Начата | Завершена | Commit/PR | Заметки |
|----|------------------------------------------------------|--------------|------------|------------|-----------|---------|
| 0 | [Архивирование Python](phases/phase-0-archive.md) | ✅ done | 2026-04-18 | 2026-04-18 | 425eae7 | |
| 1 | [Go-скелет](phases/phase-1-skeleton.md) | ✅ done | 2026-04-18 | 2026-04-18 | 6b8d588 | |
| 2 | [Converter (goldmark)](phases/phase-2-converter.md) | ✅ done | 2026-04-18 | 2026-04-18 | 8deba36 | Golden fixtures use relative/email links to keep generated HTML free of external resource URLs. |
| 3 | [HTTP-сервер](phases/phase-3-server.md) | ✅ done | 2026-04-18 | 2026-04-18 | 843d8dc | |
| 4 | [UI на templUI](phases/phase-4-ui.md) | ✅ done | 2026-04-18 | 2026-04-18 | d6aef55 | |
| 5 | [CLI-подкоманда](phases/phase-5-cli.md) | ✅ done | 2026-04-18 | 2026-04-18 | 6aa19fe | |
| 6 | [Docker + CI](phases/phase-6-docker-ci.md) | ⏳ pending | — | — | — | |
| 7 | [Документация + v0.2.0](phases/phase-7-docs.md) | ⏳ pending | — | — | — | |
Легенда статусов:
-`pending` — не начата
- 🔄 `in_progress` — в работе
-`done` — завершена, acceptance criteria выполнены
- ⚠️ `blocked` — заблокирована, см. заметки
## Инварианты между фазами
- `git status` чист перед началом каждой фазы.
- Каждая фаза завершается отдельным commit в `main` (или PR с мёрджем). Сообщение в формате `phaseN: <краткое описание>`.
- Acceptance criteria фазы проверяются до смены статуса на `done`.
- Любое отклонение от плана документируется в колонке «Заметки» с ссылкой на commit.
## Лог ключевых решений (ADR lite)
| Дата | Решение | Обоснование |
|------------|---------|-------------|
| 2026-04-18 | Goldmark + chroma inline + extension.Footnote + кастомный anchor-extender | См. `plan-go-migration.md` §11 |
| 2026-04-18 | ASCII-транслит id заголовков через `mozillazg/go-unidecode` | Решение пользователя (round-1) |
| 2026-04-18 | One-shot preview/download с UUIDv4 + TTL 1 ч | Решение пользователя (round-1) |
| 2026-04-18 | GitHub-style prefix-anchor (`<a>` как первый child `<h>`), не wrap-anchor | Закрытие F-01 round-3 — избегаем nested `<a>` |
| 2026-04-18 | `extractHeadingText` walker вместо deprecated `BaseNode.Text(src)` | Закрытие F-02 round-3 |
| 2026-04-18 | `<iframe sandbox srcdoc>` без `allow-same-origin` вместо `bluemonday` для inline preview | Меньше зависимостей, полная изоляция |
| 2026-04-18 | `POST /convert` сохраняется (не `/api/convert`), UI-форма на `POST /ui/convert` | Паритет API-контракта |
| 2026-04-18 | `html.WithUnsafe()` выключен; `parser.WithAttribute()` выключен | Безопасность + паритет |
| 2026-04-18 | Tailwind standalone binary в Docker (без Node) | Упрощение multi-stage build |
+18
View File
@@ -0,0 +1,18 @@
module github.com/fserg/md-to-html
go 1.24
require (
github.com/Oudwins/tailwind-merge-go v0.2.1
github.com/a-h/templ v0.3.1001
github.com/alecthomas/chroma/v2 v2.23.1
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/mozillazg/go-unidecode v0.2.0
github.com/templui/templui v1.10.0
github.com/yuin/goldmark v1.7.17
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
)
require github.com/dlclark/regexp2 v1.11.5 // indirect
+50
View File
@@ -0,0 +1,50 @@
github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE=
github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/templui/templui v1.10.0 h1:6R5KaF6fA7DJDVbOraF9M0yBsYet79qKuymF54Fqo9c=
github.com/templui/templui v1.10.0/go.mod h1:WWX9O4UebQiSipKaoUQ7Cb0UWtqopzZHtgBu1gtItzU=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+171
View File
@@ -0,0 +1,171 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/fserg/md-to-html/internal/converter"
webtemplate "github.com/fserg/md-to-html/web/template"
)
var ErrUsage = errors.New("cli usage error")
func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if ctx == nil {
ctx = context.Background()
}
if wantsHelp(args) {
printUsage(stdout)
return nil
}
normalized, err := normalizeArgs(args)
if err != nil {
printUsage(stderr)
return fmt.Errorf("%w: %v", ErrUsage, err)
}
fs := flag.NewFlagSet("cli", flag.ContinueOnError)
fs.SetOutput(stderr)
var (
output string
title string
useStdin bool
)
fs.StringVar(&output, "output", "", "output file path")
fs.StringVar(&output, "o", "", "output file path")
fs.StringVar(&title, "title", "", "fallback title if markdown has no headings")
fs.BoolVar(&useStdin, "stdin", false, "read markdown from stdin")
if err := fs.Parse(normalized); err != nil {
printUsage(stderr)
return fmt.Errorf("%w: %v", ErrUsage, err)
}
if err := ctx.Err(); err != nil {
return err
}
positional := fs.Args()
if len(positional) > 1 {
printUsage(stderr)
return fmt.Errorf("%w: expected a single input file or '-'", ErrUsage)
}
conv, err := converter.New(webtemplate.FS)
if err != nil {
return fmt.Errorf("init converter: %w", err)
}
var (
markdown []byte
fallbackTitle = title
outputPath = output
writeToStdout bool
)
switch {
case useStdin || (len(positional) == 1 && positional[0] == "-"):
markdown, err = io.ReadAll(stdin)
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
if fallbackTitle == "" {
fallbackTitle = "Document"
}
writeToStdout = outputPath == ""
case len(positional) == 1:
inputPath := positional[0]
markdown, err = os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("read %s: %w", inputPath, err)
}
if fallbackTitle == "" {
fallbackTitle = strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
}
if outputPath == "" {
outputPath = strings.TrimSuffix(inputPath, filepath.Ext(inputPath)) + ".html"
}
default:
printUsage(stderr)
return fmt.Errorf("%w: no input specified", ErrUsage)
}
result, err := conv.Convert(markdown, fallbackTitle)
if err != nil {
return fmt.Errorf("convert markdown: %w", err)
}
if writeToStdout {
_, err = stdout.Write(result.HTML)
if err != nil {
return fmt.Errorf("write stdout: %w", err)
}
return nil
}
if err := os.WriteFile(outputPath, result.HTML, 0o644); err != nil {
return fmt.Errorf("write %s: %w", outputPath, err)
}
return nil
}
func normalizeArgs(args []string) ([]string, error) {
flags := make([]string, 0, len(args))
positionals := make([]string, 0, 1)
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
positionals = append(positionals, args[i+1:]...)
return append(flags, positionals...), nil
case arg == "-":
positionals = append(positionals, arg)
case !strings.HasPrefix(arg, "-"):
positionals = append(positionals, arg)
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--title="), strings.HasPrefix(arg, "-o="):
flags = append(flags, arg)
case arg == "--output" || arg == "-o" || arg == "--title":
if i+1 >= len(args) {
return nil, fmt.Errorf("flag needs an argument: %s", arg)
}
flags = append(flags, arg, args[i+1])
i++
default:
flags = append(flags, arg)
}
}
return append(flags, positionals...), nil
}
func wantsHelp(args []string) bool {
for _, arg := range args {
switch arg {
case "-h", "--help", "-help":
return true
}
}
return false
}
func printUsage(w io.Writer) {
fmt.Fprint(w, `Usage: md-to-html cli [--stdin|-|<file.md>] [--output path] [--title str]
Options:
--stdin Read markdown from stdin
-o, --output Output file path (default: stdout for stdin, <input>.html for file)
--title Fallback title if markdown has no headings
-h, --help Show this help
`)
}
+124
View File
@@ -0,0 +1,124 @@
package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCLIFileToFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
inputPath := filepath.Join(dir, "example.md")
if err := os.WriteFile(inputPath, []byte("# Hello\n\nBody"), 0o644); err != nil {
t.Fatalf("write input: %v", err)
}
var stdout, stderr bytes.Buffer
if err := Run(context.Background(), []string{inputPath}, strings.NewReader(""), &stdout, &stderr); err != nil {
t.Fatalf("run: %v", err)
}
outputPath := filepath.Join(dir, "example.html")
got, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !bytes.Contains(got, []byte("<!DOCTYPE html>")) {
t.Fatalf("output missing doctype: %s", got)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestCLIStdin(t *testing.T) {
t.Parallel()
stdin := strings.NewReader("# Привет\n\nТекст")
var stdout, stderr bytes.Buffer
if err := Run(context.Background(), []string{"--stdin"}, stdin, &stdout, &stderr); err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(stdout.String(), "<!DOCTYPE html>") {
t.Fatalf("stdout missing doctype: %s", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestCLIOutputFlag(t *testing.T) {
t.Parallel()
dir := t.TempDir()
inputPath := filepath.Join(dir, "example.md")
outputPath := filepath.Join(dir, "custom.html")
if err := os.WriteFile(inputPath, []byte("Plain text"), 0o644); err != nil {
t.Fatalf("write input: %v", err)
}
var stdout, stderr bytes.Buffer
if err := Run(context.Background(), []string{inputPath, "-o", outputPath}, strings.NewReader(""), &stdout, &stderr); err != nil {
t.Fatalf("run: %v", err)
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("stat output: %v", err)
}
}
func TestCLITitle(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
if err := Run(context.Background(), []string{"--stdin", "--title", "Custom"}, strings.NewReader(""), &stdout, &stderr); err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(stdout.String(), "<title>Custom</title>") {
t.Fatalf("stdout missing title: %s", stdout.String())
}
}
func TestCLINoInput(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
err := Run(context.Background(), nil, strings.NewReader(""), &stdout, &stderr)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUsage) {
t.Fatalf("error = %v, want ErrUsage", err)
}
if !strings.Contains(stderr.String(), "Usage: md-to-html cli") {
t.Fatalf("stderr missing usage: %s", stderr.String())
}
}
func TestCLIMissingFile(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
err := Run(context.Background(), []string{"missing.md"}, strings.NewReader(""), &stdout, &stderr)
if err == nil {
t.Fatal("expected error, got nil")
}
if errors.Is(err, ErrUsage) {
t.Fatalf("error = %v, did not want ErrUsage", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
+123
View File
@@ -0,0 +1,123 @@
package converter
import (
"strings"
"github.com/yuin/goldmark"
emojiast "github.com/yuin/goldmark-emoji/ast"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type anchorExtension struct{}
func (e *anchorExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(&anchorTransformer{}, 900),
))
}
type anchorTransformer struct{}
func (t *anchorTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
src := reader.Source()
used := map[string]int{}
_ = pc
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
h, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
slug := translitSlug(extractHeadingText(h, src), used)
h.SetAttributeString("id", []byte(slug))
link := ast.NewLink()
link.Destination = []byte("#" + slug)
link.SetAttributeString("class", []byte("heading-anchor"))
link.SetAttributeString("aria-hidden", []byte("true"))
link.AppendChild(link, ast.NewString([]byte("#")))
if first := h.FirstChild(); first != nil {
h.InsertBefore(h, first, link)
} else {
h.AppendChild(h, link)
}
return ast.WalkSkipChildren, nil
})
}
func extractHeadingText(h *ast.Heading, src []byte) string {
var b strings.Builder
_ = ast.Walk(h, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch v := n.(type) {
case *ast.Link:
if isHeadingAnchor(v) {
return ast.WalkSkipChildren, nil
}
case *ast.Text:
b.Write(v.Segment.Value(src))
if v.HardLineBreak() || v.SoftLineBreak() {
b.WriteByte(' ')
}
case *ast.String:
b.Write(v.Value)
case *ast.CodeSpan:
for child := v.FirstChild(); child != nil; child = child.NextSibling() {
switch c := child.(type) {
case *ast.Text:
b.Write(c.Segment.Value(src))
case *ast.String:
b.Write(c.Value)
}
}
return ast.WalkSkipChildren, nil
case *ast.AutoLink:
b.Write(v.Label(src))
return ast.WalkSkipChildren, nil
case *emojiast.Emoji:
if v.Value != nil && len(v.Value.Unicode) > 0 {
b.WriteString(string(v.Value.Unicode))
} else if len(v.ShortName) > 0 {
b.WriteByte(':')
b.Write(v.ShortName)
b.WriteByte(':')
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
return strings.TrimSpace(b.String())
}
func isHeadingAnchor(link *ast.Link) bool {
attr, ok := link.AttributeString("class")
if !ok {
return false
}
switch value := attr.(type) {
case []byte:
return string(value) == "heading-anchor"
case string:
return value == "heading-anchor"
default:
return false
}
}
+170
View File
@@ -0,0 +1,170 @@
package converter
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"strings"
"sync"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
const documentLang = "ru"
type Result struct {
HTML []byte
Title string
}
type Converter struct {
md goldmark.Markdown
tmpl *template.Template
bufferPool sync.Pool
}
type templateData struct {
Lang string
Title string
Body template.HTML
ShowTitle bool
}
func New(templateFS fs.FS) (*Converter, error) {
tmpl, err := template.ParseFS(templateFS, "document.html")
if err != nil {
return nil, err
}
return &Converter{
md: goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Footnote,
emoji.Emoji,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
highlighting.WithFormatOptions(chromahtml.WithClasses(false)),
),
&anchorExtension{},
),
goldmark.WithRendererOptions(
renderer.WithNodeRenderers(
util.Prioritized(&escapedRawHTMLRenderer{}, 999),
),
),
),
tmpl: tmpl,
bufferPool: sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
},
}, nil
}
func (c *Converter) Convert(md []byte, fallbackTitle string) (Result, error) {
body, title, hasH1, err := c.render(md)
if err != nil {
return Result{}, err
}
if title == "" {
title = fallbackTitle
}
buf := c.getBuffer()
defer c.putBuffer(buf)
data := templateData{
Lang: documentLang,
Title: title,
Body: template.HTML(body),
ShowTitle: !hasH1 && title != "",
}
if err := c.tmpl.Execute(buf, data); err != nil {
return Result{}, err
}
return Result{
HTML: append([]byte(nil), buf.Bytes()...),
Title: title,
}, nil
}
func (c *Converter) RenderBody(md []byte) ([]byte, string, error) {
body, title, _, err := c.render(md)
if err != nil {
return nil, "", err
}
return body, title, nil
}
func (c *Converter) render(md []byte) ([]byte, string, bool, error) {
root := c.md.Parser().Parse(text.NewReader(md))
doc, ok := root.(*ast.Document)
if !ok {
return nil, "", false, fmt.Errorf("expected *ast.Document, got %T", root)
}
title, hasH1 := extractDocumentTitle(doc, md)
buf := c.getBuffer()
defer c.putBuffer(buf)
if err := c.md.Renderer().Render(buf, md, doc); err != nil {
return nil, "", false, err
}
return append([]byte(nil), buf.Bytes()...), title, hasH1, nil
}
func extractDocumentTitle(doc *ast.Document, src []byte) (string, bool) {
var (
title string
hasH1 bool
)
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
h, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
if h.Level == 1 {
hasH1 = true
}
if title == "" {
title = strings.TrimSpace(extractHeadingText(h, src))
}
return ast.WalkContinue, nil
})
return title, hasH1
}
func (c *Converter) getBuffer() *bytes.Buffer {
buf := c.bufferPool.Get().(*bytes.Buffer)
buf.Reset()
return buf
}
func (c *Converter) putBuffer(buf *bytes.Buffer) {
buf.Reset()
c.bufferPool.Put(buf)
}
+162
View File
@@ -0,0 +1,162 @@
package converter
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fserg/md-to-html/web/template"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
func TestGolden(t *testing.T) {
c := newTestConverter(t)
update := os.Getenv("UPDATE_GOLDEN") == "1"
entries, err := os.ReadDir("testdata")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".md") {
continue
}
t.Run(name, func(t *testing.T) {
md, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
wantPath := filepath.Join("testdata", strings.TrimSuffix(name, ".md")+".html")
got, err := c.Convert(md, "Document")
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"http://", "https://", "cdn.", "googleapis.com"} {
if bytes.Contains(got.HTML, []byte(forbidden)) {
t.Fatalf("generated HTML contains forbidden external resource marker %q", forbidden)
}
}
if update {
if err := os.WriteFile(wantPath, got.HTML, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("missing golden %s; run UPDATE_GOLDEN=1", wantPath)
}
if !bytes.Equal(got.HTML, want) {
t.Errorf("mismatch: run UPDATE_GOLDEN=1 go test ./internal/converter/... to refresh")
}
})
}
}
func TestTranslitSlug(t *testing.T) {
tests := []struct {
name string
in string
want string
used map[string]int
}{
{name: "cyrillic", in: "Установка", want: "ustanovka", used: map[string]int{}},
{name: "collision first", in: "Install", want: "install", used: map[string]int{}},
{name: "collision second", in: "Install", want: "install-1", used: map[string]int{"install": 1}},
{name: "cyrillic translit", in: "Сетап", want: "setap", used: map[string]int{}},
{name: "empty fallback", in: "!!!", want: "section", used: map[string]int{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := translitSlug(tt.in, tt.used)
if got != tt.want {
t.Fatalf("translitSlug(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestExtractHeadingText(t *testing.T) {
c := newTestConverter(t)
src := []byte("## [API](https://example.com) `go fmt` https://example.com :rocket:\n")
doc := c.md.Parser().Parse(text.NewReader(src))
var heading *ast.Heading
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if h, ok := n.(*ast.Heading); ok {
heading = h
return ast.WalkStop, nil
}
return ast.WalkContinue, nil
})
if heading == nil {
t.Fatal("heading not found")
}
got := extractHeadingText(heading, src)
want := "API go fmt https://example.com 🚀"
if got != want {
t.Fatalf("extractHeadingText() = %q, want %q", got, want)
}
}
func TestConvertTitleFromFirstHeading(t *testing.T) {
c := newTestConverter(t)
result, err := c.Convert([]byte("# Hello\n\nParagraph"), "fallback")
if err != nil {
t.Fatal(err)
}
if result.Title != "Hello" {
t.Fatalf("result.Title = %q, want %q", result.Title, "Hello")
}
if !bytes.Contains(result.HTML, []byte("<title>Hello</title>")) {
t.Fatalf("expected HTML title to contain Hello")
}
}
func TestConvertTitleFallback(t *testing.T) {
c := newTestConverter(t)
result, err := c.Convert([]byte("Paragraph only"), "fallback")
if err != nil {
t.Fatal(err)
}
if result.Title != "fallback" {
t.Fatalf("result.Title = %q, want %q", result.Title, "fallback")
}
if !bytes.Contains(result.HTML, []byte("<h1>fallback</h1>")) {
t.Fatalf("expected fallback h1 to be injected")
}
}
func newTestConverter(t *testing.T) *Converter {
t.Helper()
c, err := New(webtemplate.FS)
if err != nil {
t.Fatal(err)
}
return c
}
+45
View File
@@ -0,0 +1,45 @@
package converter
import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
type escapedRawHTMLRenderer struct{}
func (r *escapedRawHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
}
func (r *escapedRawHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.HTMLBlock)
if entering {
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
}
return ast.WalkContinue, nil
}
if n.HasClosure() {
_, _ = w.Write(util.EscapeHTML(n.ClosureLine.Value(source)))
}
return ast.WalkContinue, nil
}
func (r *escapedRawHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkSkipChildren, nil
}
n := node.(*ast.RawHTML)
for i := 0; i < n.Segments.Len(); i++ {
segment := n.Segments.At(i)
_, _ = w.Write(util.EscapeHTML(segment.Value(source)))
}
return ast.WalkSkipChildren, nil
}
+26
View File
@@ -0,0 +1,26 @@
package converter
import (
"fmt"
"regexp"
"strings"
"github.com/mozillazg/go-unidecode"
)
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func translitSlug(s string, used map[string]int) string {
t := strings.ToLower(unidecode.Unidecode(s))
t = slugRe.ReplaceAllString(t, "-")
t = strings.Trim(t, "-")
if t == "" {
t = "section"
}
if n, ok := used[t]; ok && n > 0 {
used[t] = n + 1
return fmt.Sprintf("%s-%d", t, n)
}
used[t] = 1
return t
}
+1
View File
@@ -0,0 +1 @@
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<p>Contact <a href="mailto:dev@example.test">dev@example.test</a> for details.</p>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Contact <dev@example.test> for details.
+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Basic Example</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1 id="basic-example"><a href="#basic-example" class="heading-anchor">#</a>Basic Example</h1>
<p>Simple paragraph with <strong>bold</strong>, <em>italic</em>, and <a href="/docs">docs</a>.</p>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
# Basic Example
Simple paragraph with **bold**, *italic*, and [docs](/docs).
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<p>Ready to launch &#x1f680; today.</p>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Ready to launch :rocket: today.
+303
View File
@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<pre style="background-color:#f7f7f7;-webkit-text-size-adjust:none;"><code><span style="display:flex;"><span><span style="color:#cf222e">package</span><span style="color:#fff"> </span><span style="color:#1f2328">main</span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#cf222e">import</span><span style="color:#fff"> </span><span style="color:#0a3069">&#34;fmt&#34;</span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#cf222e">func</span><span style="color:#fff"> </span><span style="color:#6639ba">main</span><span style="color:#1f2328">()</span><span style="color:#fff"> </span><span style="color:#1f2328">{</span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#fff"> </span><span style="color:#1f2328">fmt</span><span style="color:#1f2328">.</span><span style="color:#6639ba">Println</span><span style="color:#1f2328">(</span><span style="color:#0a3069">&#34;hello&#34;</span><span style="color:#1f2328">)</span><span style="color:#fff">
</span></span></span><span style="display:flex;"><span><span style="color:#1f2328">}</span><span style="color:#fff">
</span></span></span></code></pre>
</main>
</body>
</html>
@@ -0,0 +1,9 @@
```go
package main
import "fmt"
func main() {
fmt.Println("hello")
}
```
+305
View File
@@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<p>Footnote text.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Extra details.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
Footnote text.[^1]
[^1]: Extra details.
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dev@example.test</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>dev@example.test</h1>
<h2 id="dev-example-test"><a href="#dev-example-test" class="heading-anchor">#</a><a href="mailto:dev@example.test">dev@example.test</a></h2>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
## <dev@example.test>
+300
View File
@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Install</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Install</h1>
<h2 id="install"><a href="#install" class="heading-anchor">#</a>Install</h2>
<h2 id="install-1"><a href="#install-1" class="heading-anchor">#</a>Install</h2>
<h2 id="setup"><a href="#setup" class="heading-anchor">#</a>Setup</h2>
<h2 id="setap"><a href="#setap" class="heading-anchor">#</a>Сетап</h2>
</main>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
## Install
## Install
## Setup
## Сетап
+299
View File
@@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Привет</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1 id="privet"><a href="#privet" class="heading-anchor">#</a>Привет</h1>
<h2 id="ustanovka"><a href="#ustanovka" class="heading-anchor">#</a>Установка</h2>
<h3 id="bystryi-start"><a href="#bystryi-start" class="heading-anchor">#</a>Быстрый старт</h3>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
# Привет
## Установка
### Быстрый старт
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🚀 Launch</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>🚀 Launch</h1>
<h2 id="launch"><a href="#launch" class="heading-anchor">#</a>&#x1f680; Launch</h2>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
## :rocket: Launch
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>alt Title</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>alt Title</h1>
<h2 id="alt-title"><a href="#alt-title" class="heading-anchor">#</a><img src="image.png" alt="alt"> Title</h2>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
## ![alt](image.png) Title
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>API</h1>
<h2 id="api"><a href="#api" class="heading-anchor">#</a><a href="/api">API</a></h2>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
## [API](/api)
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Using go fmt</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Using go fmt</h1>
<h2 id="using-go-fmt"><a href="#using-go-fmt" class="heading-anchor">#</a>Using <code>go fmt</code></h2>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
## Using `go fmt`
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
&lt;script&gt;alert(1)&lt;/script&gt;
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<script>alert(1)</script>
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<p>Use <del>old</del> new output.</p>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Use ~~old~~ new output.
+314
View File
@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alpha</td>
<td>1</td>
</tr>
<tr>
<td>Beta</td>
<td>2</td>
</tr>
</tbody>
</table>
</main>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
| Name | Value |
| --- | --- |
| Alpha | 1 |
| Beta | 2 |
+300
View File
@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
<h1>Document</h1>
<ul>
<li><input checked="" disabled="" type="checkbox"> Ship phase 2</li>
<li><input disabled="" type="checkbox"> Review output</li>
</ul>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
- [x] Ship phase 2
- [ ] Review output
+1
View File
@@ -0,0 +1 @@
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
const (
defaultAddr = ":8080"
defaultMaxMarkdownBytes = int64(1_048_576)
defaultMaxRequestBytes = int64(1_200_000)
defaultPreviewTTL = time.Hour
defaultShutdownTimeout = 10 * time.Second
)
type Config struct {
Addr string
MaxMarkdownBytes int64
MaxRequestBytes int64
PreviewTTL time.Duration
ShutdownTimeout time.Duration
}
func LoadConfig() (Config, error) {
maxMarkdownBytes, err := loadPositiveInt64("MAX_MARKDOWN_BYTES", defaultMaxMarkdownBytes)
if err != nil {
return Config{}, err
}
maxRequestBytes, err := loadPositiveInt64("MAX_REQUEST_BYTES", defaultMaxRequestBytes)
if err != nil {
return Config{}, err
}
previewTTL, err := loadDuration("PREVIEW_TTL", defaultPreviewTTL)
if err != nil {
return Config{}, err
}
shutdownTimeout, err := loadDuration("SHUTDOWN_TIMEOUT", defaultShutdownTimeout)
if err != nil {
return Config{}, err
}
addr := strings.TrimSpace(os.Getenv("ADDR"))
if addr == "" {
addr = defaultAddr
}
return Config{
Addr: addr,
MaxMarkdownBytes: maxMarkdownBytes,
MaxRequestBytes: maxRequestBytes,
PreviewTTL: previewTTL,
ShutdownTimeout: shutdownTimeout,
}, nil
}
func loadPositiveInt64(name string, fallback int64) (int64, error) {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
}
if value <= 0 {
return 0, fmt.Errorf("%s must be positive", name)
}
return value, nil
}
func loadDuration(name string, fallback time.Duration) (time.Duration, error) {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback, nil
}
value, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration: %w", name, err)
}
if value <= 0 {
return 0, fmt.Errorf("%s must be positive", name)
}
return value, nil
}
+311
View File
@@ -0,0 +1,311 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/fserg/md-to-html/internal/converter"
"github.com/fserg/md-to-html/internal/ui"
"github.com/fserg/md-to-html/internal/version"
"github.com/go-chi/chi/v5"
)
const defaultDocumentTitle = "Document"
type Server struct {
cfg Config
conv *converter.Converter
store *PreviewStore
log *slog.Logger
}
type convertRequest struct {
Markdown string `json:"markdown"`
Title string `json:"title,omitempty"`
}
func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) {
if !hasJSONContentType(r.Header.Get("Content-Type")) {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
"detail": "content-type must be application/json",
})
return
}
var payload convertRequest
if err := decodeJSON(r, &payload); err != nil {
s.writeDecodeError(w, err)
return
}
result, err := s.convertMarkdown(payload.Markdown, payload.Title)
if err != nil {
s.writeConvertError(w, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(result.HTML)
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"version": version.Version})
}
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"template_loaded": s.conv != nil,
})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = ui.Home().Render(r.Context(), w)
}
func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxRequestBytes)
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, "Слишком большой файл или ошибка формы")
return
}
md, filename, err := s.readUIMarkdownPayload(r)
if err != nil {
s.renderUIReadError(w, r, err)
return
}
result, err := s.conv.Convert(md, defaultDocumentTitle)
if err != nil {
s.log.Error("ui_convert_failed", "error", err)
s.renderUIError(w, r, http.StatusBadGateway, "Ошибка конвертации: "+err.Error())
return
}
previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = ui.Result(previewID, downloadID, string(result.HTML), filename).Render(r.Context(), w)
}
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
item, ok := s.store.Take(id)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(item.html)
}
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
item, ok := s.store.Take(id)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
"filename": item.filename,
}))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(item.html)
}
func (s *Server) convertMarkdown(markdown, title string) (converter.Result, error) {
if strings.TrimSpace(markdown) == "" {
return converter.Result{}, errEmptyMarkdown
}
if int64(len([]byte(markdown))) > s.cfg.MaxMarkdownBytes {
return converter.Result{}, errMarkdownTooLarge{limit: s.cfg.MaxMarkdownBytes}
}
fallbackTitle := strings.TrimSpace(title)
if fallbackTitle == "" {
fallbackTitle = defaultDocumentTitle
}
result, err := s.conv.Convert([]byte(markdown), fallbackTitle)
if err != nil {
return converter.Result{}, fmt.Errorf("convert markdown: %w", err)
}
return result, nil
}
func (s *Server) writeDecodeError(w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
"detail": fmt.Sprintf("request exceeds %d bytes", s.cfg.MaxRequestBytes),
})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": "invalid request payload"})
}
func (s *Server) writeConvertError(w http.ResponseWriter, err error) {
var markdownTooLarge errMarkdownTooLarge
switch {
case errors.Is(err, errEmptyMarkdown):
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": err.Error()})
case errors.As(err, &markdownTooLarge):
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
"detail": markdownTooLarge.Error(),
})
default:
s.log.Error("convert_failed", "error", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"detail": err.Error()})
}
}
func hasJSONContentType(value string) bool {
mediaType, _, err := mime.ParseMediaType(value)
return err == nil && mediaType == "application/json"
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
var extra json.RawMessage
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
return err
}
if len(extra) > 0 {
return errors.New("unexpected trailing JSON data")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(payload)
}
func htmlFilename(title string) string {
name := strings.TrimSpace(title)
if name == "" {
name = "document"
}
replacer := strings.NewReplacer("/", "-", "\\", "-", "\"", "", "\n", " ", "\r", " ")
name = strings.TrimSpace(replacer.Replace(name))
if name == "" {
name = "document"
}
return name + ".html"
}
func contentTypeOrDefault(value string) string {
if strings.TrimSpace(value) == "" {
return "text/html; charset=utf-8"
}
return value
}
func (s *Server) renderUIError(w http.ResponseWriter, r *http.Request, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = ui.Error(msg).Render(r.Context(), w)
}
func (s *Server) renderUIReadError(w http.ResponseWriter, r *http.Request, err error) {
var markdownTooLarge errMarkdownTooLarge
switch {
case errors.Is(err, errEmptyMarkdown):
s.renderUIError(w, r, http.StatusBadRequest, "Пустой markdown")
case errors.As(err, &markdownTooLarge):
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, fmt.Sprintf("Markdown больше %d байт", s.cfg.MaxMarkdownBytes))
default:
s.renderUIError(w, r, http.StatusBadRequest, err.Error())
}
}
func (s *Server) readUIMarkdownPayload(r *http.Request) ([]byte, string, error) {
switch r.FormValue("source") {
case "", "file":
file, header, err := r.FormFile("markdown_file")
if err != nil {
return nil, "", errors.New("Файл не загружен")
}
defer file.Close()
markdown, err := io.ReadAll(io.LimitReader(file, s.cfg.MaxMarkdownBytes+1))
if err != nil {
return nil, "", fmt.Errorf("не удалось прочитать файл: %w", err)
}
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
return nil, "", err
}
name := strings.TrimSpace(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
return markdown, htmlFilename(name), nil
case "text":
markdown := []byte(r.FormValue("markdown_text"))
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
return nil, "", err
}
return markdown, "document.html", nil
default:
return nil, "", errors.New("Неизвестный источник markdown")
}
}
func validateMarkdown(markdown []byte, limit int64) error {
if int64(len(markdown)) > limit {
return errMarkdownTooLarge{limit: limit}
}
if len(bytes.TrimSpace(markdown)) == 0 {
return errEmptyMarkdown
}
return nil
}
var errEmptyMarkdown = errors.New("markdown must not be empty")
type errMarkdownTooLarge struct {
limit int64
}
func (e errMarkdownTooLarge) Error() string {
return fmt.Sprintf("markdown exceeds %d bytes", e.limit)
}
+59
View File
@@ -0,0 +1,59 @@
package server
import (
"log/slog"
"net/http"
"time"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)
func MaxBytesMiddleware(limit int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}
func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
headers := w.Header()
headers.Set("Access-Control-Allow-Origin", "*")
headers.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
headers.Set("Access-Control-Allow-Headers", "content-type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequestLogger(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
log.Info(
"http_request",
"request_id", chimiddleware.GetReqID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration", time.Since(start),
)
})
}
}
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"context"
"sync"
"time"
"github.com/google/uuid"
)
const janitorInterval = 5 * time.Minute
type PreviewStore struct {
mu sync.Mutex
items map[string]previewItem
ttl time.Duration
now func() time.Time
}
type previewItem struct {
html []byte
mime string
filename string
expires time.Time
}
func NewPreviewStore(ttl time.Duration) *PreviewStore {
return &PreviewStore{
items: make(map[string]previewItem),
ttl: ttl,
now: time.Now,
}
}
func (s *PreviewStore) Put(html []byte, mime, filename string) string {
s.mu.Lock()
defer s.mu.Unlock()
id := uuid.NewString()
s.items[id] = previewItem{
html: append([]byte(nil), html...),
mime: mime,
filename: filename,
expires: s.now().Add(s.ttl),
}
return id
}
func (s *PreviewStore) Take(id string) (previewItem, bool) {
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.items[id]
if !ok {
return previewItem{}, false
}
delete(s.items, id)
if s.now().After(item.expires) {
return previewItem{}, false
}
item.html = append([]byte(nil), item.html...)
return item, true
}
func (s *PreviewStore) janitor(ctx context.Context) {
ticker := time.NewTicker(janitorInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
s.cleanupExpired(now)
}
}
}
func (s *PreviewStore) cleanupExpired(now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
for id, item := range s.items {
if now.After(item.expires) {
delete(s.items, id)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"context"
"sync"
"testing"
"time"
)
func TestPreviewStore_OneShot(t *testing.T) {
t.Parallel()
store := NewPreviewStore(time.Hour)
id := store.Put([]byte("<h1>Hello</h1>"), "text/html; charset=utf-8", "hello.html")
item, ok := store.Take(id)
if !ok {
t.Fatalf("expected first take to succeed")
}
if got := string(item.html); got != "<h1>Hello</h1>" {
t.Fatalf("unexpected html: %q", got)
}
if _, ok := store.Take(id); ok {
t.Fatalf("expected second take to miss")
}
}
func TestPreviewStore_TTL(t *testing.T) {
t.Parallel()
store := NewPreviewStore(10 * time.Millisecond)
id := store.Put([]byte("expired"), "text/html; charset=utf-8", "expired.html")
time.Sleep(30 * time.Millisecond)
store.cleanupExpired(time.Now())
if _, ok := store.Take(id); ok {
t.Fatalf("expected expired item to be removed")
}
}
func TestPreviewStore_Concurrent(t *testing.T) {
t.Parallel()
store := NewPreviewStore(time.Hour)
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
id := store.Put([]byte("payload"), "text/html; charset=utf-8", "payload.html")
store.Take(id)
}()
}
wg.Wait()
}
func TestPreviewStore_JanitorStopsWithContext(t *testing.T) {
t.Parallel()
store := NewPreviewStore(time.Hour)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
store.janitor(ctx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("janitor did not stop after context cancellation")
}
}
+95
View File
@@ -0,0 +1,95 @@
package server
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"time"
"github.com/fserg/md-to-html/internal/converter"
"github.com/fserg/md-to-html/web"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)
func New(cfg Config, conv *converter.Converter) (*Server, error) {
if conv == nil {
return nil, errors.New("converter is required")
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
return &Server{
cfg: cfg,
conv: conv,
store: NewPreviewStore(cfg.PreviewTTL),
log: logger,
}, nil
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(chimiddleware.RequestID)
r.Use(CORSMiddleware())
r.Use(MaxBytesMiddleware(s.cfg.MaxRequestBytes))
r.Use(RequestLogger(s.log))
r.Use(chimiddleware.Recoverer)
r.Use(chimiddleware.Timeout(30 * time.Second))
r.Get("/", s.handleHome)
r.Post("/convert", s.handleConvert)
r.Get("/health", s.handleHealth)
r.Get("/version", s.handleVersion)
r.Get("/ready", s.handleReady)
r.Post("/ui/convert", s.handleUIConvert)
r.Get("/preview/{id}", s.handlePreview)
r.Get("/download/{id}", s.handleDownload)
if staticFS, err := fs.Sub(web.StaticFS, "static"); err == nil {
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
}
return r
}
func (s *Server) Run(ctx context.Context) error {
httpServer := &http.Server{
Addr: s.cfg.Addr,
Handler: s.Router(),
}
errCh := make(chan error, 1)
go s.store.janitor(ctx)
go func() {
s.log.Info("server starting", "addr", s.cfg.Addr)
errCh <- httpServer.ListenAndServe()
}()
select {
case <-ctx.Done():
s.log.Info("shutting down", "timeout", s.cfg.ShutdownTimeout)
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.ShutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown server: %w", err)
}
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("server exited after shutdown: %w", err)
}
return nil
case err := <-errCh:
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve: %w", err)
}
}
+564
View File
@@ -0,0 +1,564 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"time"
"github.com/fserg/md-to-html/internal/converter"
"github.com/fserg/md-to-html/internal/version"
webtemplate "github.com/fserg/md-to-html/web/template"
)
func TestConvertEndpoint(t *testing.T) {
srv := newTestServer(t, Config{
Addr: ":0",
MaxMarkdownBytes: 128,
MaxRequestBytes: 256,
PreviewTTL: time.Hour,
ShutdownTimeout: time.Second,
})
ts := httptest.NewServer(srv.Router())
defer ts.Close()
tests := []struct {
name string
body string
contentType string
wantStatus int
wantType string
wantBody string
}{
{
name: "valid markdown",
body: `{"markdown":"# Hello"}`,
contentType: "application/json",
wantStatus: http.StatusOK,
wantType: "text/html; charset=utf-8",
wantBody: "<!DOCTYPE html>",
},
{
name: "empty markdown",
body: `{"markdown":" "}`,
contentType: "application/json",
wantStatus: http.StatusBadRequest,
wantType: "application/json; charset=utf-8",
wantBody: `{"detail":"markdown must not be empty"}`,
},
{
name: "markdown too large",
body: `{"markdown":"` + strings.Repeat("a", 129) + `"}`,
contentType: "application/json",
wantStatus: http.StatusRequestEntityTooLarge,
wantType: "application/json; charset=utf-8",
wantBody: `{"detail":"markdown exceeds 128 bytes"}`,
},
{
name: "missing content type",
body: `{"markdown":"# Hello"}`,
contentType: "",
wantStatus: http.StatusUnsupportedMediaType,
wantType: "application/json; charset=utf-8",
wantBody: `{"detail":"content-type must be application/json"}`,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, ts.URL+"/convert", strings.NewReader(tc.body))
if err != nil {
t.Fatalf("new request: %v", err)
}
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != tc.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tc.wantStatus, body)
}
if got := resp.Header.Get("Content-Type"); got != tc.wantType {
t.Fatalf("content-type = %q, want %q", got, tc.wantType)
}
if !bytes.Contains(body, []byte(tc.wantBody)) {
t.Fatalf("body %q does not contain %q", body, tc.wantBody)
}
})
}
}
func TestConvertEndpoint_RequestLimit(t *testing.T) {
t.Parallel()
srv := newTestServer(t, Config{
Addr: ":0",
MaxMarkdownBytes: 1_048_576,
MaxRequestBytes: 64,
PreviewTTL: time.Hour,
ShutdownTimeout: time.Second,
})
ts := httptest.NewServer(srv.Router())
defer ts.Close()
req, err := http.NewRequest(http.MethodPost, ts.URL+"/convert", strings.NewReader(`{"markdown":"`+strings.Repeat("a", 100)+`"}`))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusRequestEntityTooLarge, body)
}
if !bytes.Contains(body, []byte(`{"detail":"request exceeds 64 bytes"}`)) {
t.Fatalf("unexpected body: %s", body)
}
}
func TestStatusEndpoints(t *testing.T) {
originalVersion := version.Version
version.Version = "dev"
t.Cleanup(func() {
version.Version = originalVersion
})
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
tests := []struct {
path string
want map[string]any
}{
{path: "/health", want: map[string]any{"status": "ok"}},
{path: "/version", want: map[string]any{"version": "dev"}},
{path: "/ready", want: map[string]any{"status": "ok", "template_loaded": true}},
}
for _, tc := range tests {
tc := tc
t.Run(tc.path, func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + tc.path)
if err != nil {
t.Fatalf("get %s: %v", tc.path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
var got map[string]any
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
for key, wantValue := range tc.want {
if got[key] != wantValue {
t.Fatalf("%s[%q] = %v, want %v", tc.path, key, got[key], wantValue)
}
}
})
}
}
func TestHomePage(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/")
if err != nil {
t.Fatalf("get home: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read home body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("content-type = %q, want %q", got, "text/html; charset=utf-8")
}
for _, needle := range []string{
`hx-post="/ui/convert"`,
`id="result"`,
`value="file"`,
`value="text"`,
} {
if !bytes.Contains(body, []byte(needle)) {
t.Fatalf("home body missing %q", needle)
}
}
}
func TestUIConvertWithText(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
body, contentType := newMultipartRequest(t, map[string]string{
"source": "text",
"markdown_text": "# Привет мир\n\nТекст",
}, nil)
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, respBody)
}
for _, needle := range []string{
"Открыть превью",
"Скачать HTML",
`/preview/`,
`/download/`,
`srcdoc=`,
`document.html`,
} {
if !bytes.Contains(respBody, []byte(needle)) {
t.Fatalf("response missing %q", needle)
}
}
}
func TestUIConvertWithFile(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
body, contentType := newMultipartRequest(t, map[string]string{
"source": "file",
}, map[string]filePart{
"markdown_file": {
filename: "guide.md",
content: "# Guide\n\nBody",
},
})
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, respBody)
}
if !bytes.Contains(respBody, []byte("guide.html")) {
t.Fatalf("response missing filename; body=%s", respBody)
}
}
func TestUIConvertErrors(t *testing.T) {
t.Parallel()
srv := newTestServer(t, Config{
Addr: ":0",
MaxMarkdownBytes: 8,
MaxRequestBytes: 1024,
PreviewTTL: time.Hour,
ShutdownTimeout: time.Second,
})
ts := httptest.NewServer(srv.Router())
defer ts.Close()
tests := []struct {
name string
fields map[string]string
files map[string]filePart
wantStatus int
wantBody string
}{
{
name: "empty text",
fields: map[string]string{"source": "text", "markdown_text": " "},
wantStatus: http.StatusBadRequest,
wantBody: "Пустой markdown",
},
{
name: "missing file",
fields: map[string]string{"source": "file"},
wantStatus: http.StatusBadRequest,
wantBody: "Файл не загружен",
},
{
name: "markdown too large",
fields: map[string]string{"source": "text", "markdown_text": strings.Repeat("x", 9)},
wantStatus: http.StatusRequestEntityTooLarge,
wantBody: "Markdown больше 8 байт",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
body, contentType := newMultipartRequest(t, tc.fields, tc.files)
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
if resp.StatusCode != tc.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tc.wantStatus, respBody)
}
if !bytes.Contains(respBody, []byte(tc.wantBody)) {
t.Fatalf("response %q missing %q", respBody, tc.wantBody)
}
})
}
}
func TestPreviewAndDownloadOneShot(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
previewID := srv.store.Put([]byte("<h1>Preview</h1>"), "text/html; charset=utf-8", "preview.html")
downloadID := srv.store.Put([]byte("<h1>Download</h1>"), "text/html; charset=utf-8", "download.html")
ts := httptest.NewServer(srv.Router())
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/preview/" + previewID)
if err != nil {
t.Fatalf("get preview: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read preview body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
t.Fatalf("preview cache-control = %q, want %q", got, "no-store")
}
if string(body) != "<h1>Preview</h1>" {
t.Fatalf("preview body = %q", body)
}
resp, err = ts.Client().Get(ts.URL + "/preview/" + previewID)
if err != nil {
t.Fatalf("get preview second time: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("second preview status = %d, want %d", resp.StatusCode, http.StatusNotFound)
}
resp, err = ts.Client().Get(ts.URL + "/download/" + downloadID)
if err != nil {
t.Fatalf("get download: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("download status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Disposition"); !strings.Contains(got, `attachment; filename=preview.html`) && !strings.Contains(got, `attachment; filename=download.html`) {
t.Fatalf("unexpected content-disposition: %q", got)
}
resp, err = ts.Client().Get(ts.URL + "/download/" + downloadID)
if err != nil {
t.Fatalf("get download second time: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("second download status = %d, want %d", resp.StatusCode, http.StatusNotFound)
}
}
func TestPreviewMissing(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/preview/nonexistent")
if err != nil {
t.Fatalf("get preview: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
}
}
func TestCORSPreflight(t *testing.T) {
t.Parallel()
srv := newTestServer(t, defaultTestConfig())
ts := httptest.NewServer(srv.Router())
defer ts.Close()
req, err := http.NewRequest(http.MethodOptions, ts.URL+"/convert", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Origin", "https://evil.com")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("allow-origin = %q, want %q", got, "*")
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "POST, GET, OPTIONS" {
t.Fatalf("allow-methods = %q", got)
}
}
func newTestServer(t *testing.T, cfg Config) *Server {
t.Helper()
conv, err := converter.New(webtemplate.FS)
if err != nil {
t.Fatalf("new converter: %v", err)
}
srv, err := New(cfg, conv)
if err != nil {
t.Fatalf("new server: %v", err)
}
return srv
}
func defaultTestConfig() Config {
return Config{
Addr: ":0",
MaxMarkdownBytes: 1_048_576,
MaxRequestBytes: 1_200_000,
PreviewTTL: time.Hour,
ShutdownTimeout: time.Second,
}
}
type filePart struct {
filename string
content string
}
func newMultipartRequest(t *testing.T, fields map[string]string, files map[string]filePart) ([]byte, string) {
t.Helper()
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for name, value := range fields {
if err := writer.WriteField(name, value); err != nil {
t.Fatalf("write field %s: %v", name, err)
}
}
for name, file := range files {
header := textproto.MIMEHeader{}
header.Set("Content-Disposition", `form-data; name="`+name+`"; filename="`+file.filename+`"`)
header.Set("Content-Type", "text/markdown")
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create part %s: %v", name, err)
}
if _, err := io.WriteString(part, file.content); err != nil {
t.Fatalf("write part %s: %v", name, err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return buf.Bytes(), writer.FormDataContentType()
}
+1
View File
@@ -0,0 +1 @@
+152
View File
@@ -0,0 +1,152 @@
// templui component button - version: v1.10.0 installed by templui v1.10.0
// 📚 Documentation: https://templui.io/docs/components/button
package button
import (
"github.com/fserg/md-to-html/internal/ui/utils"
"strings"
)
type Variant string
type Size string
type Type string
const (
VariantDefault Variant = "default"
VariantDestructive Variant = "destructive"
VariantOutline Variant = "outline"
VariantSecondary Variant = "secondary"
VariantGhost Variant = "ghost"
VariantLink Variant = "link"
)
const (
TypeButton Type = "button"
TypeReset Type = "reset"
TypeSubmit Type = "submit"
)
const (
SizeDefault Size = "default"
SizeSm Size = "sm"
SizeLg Size = "lg"
SizeIcon Size = "icon"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Variant Variant
Size Size
FullWidth bool
Href string
Target string
Disabled bool
Type Type
Form string
}
templ Button(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = TypeButton }}
}
if p.Href != "" && !p.Disabled {
<a
if p.ID != "" {
id={ p.ID }
}
href={ templ.SafeURL(p.Href) }
if p.Target != "" {
target={ p.Target }
}
class={
utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
if p.Type != "" {
type={ string(p.Type) }
}
if p.Form != "" {
form={ p.Form }
}
disabled?={ p.Disabled }
{ p.Attributes... }
>
{ children... }
</button>
}
}
func (b Props) variantClasses() string {
switch b.Variant {
case VariantDestructive:
return "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
case VariantOutline:
return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50"
case VariantSecondary:
return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80"
case VariantGhost:
return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
case VariantLink:
return "text-primary underline-offset-4 hover:underline"
default:
return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
}
}
func (b Props) sizeClasses() string {
switch b.Size {
case SizeSm:
return "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5"
case SizeLg:
return "h-10 rounded-md px-6 has-[>svg]:px-4"
case SizeIcon:
return "size-9"
default: // SizeDefault
return "h-9 px-4 py-2 has-[>svg]:px-3"
}
}
func (b Props) modifierClasses() string {
classes := []string{}
if b.FullWidth {
classes = append(classes, "w-full")
}
return strings.Join(classes, " ")
}
@@ -0,0 +1,357 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// templui component button - version: v1.10.0 installed by templui v1.10.0
// 📚 Documentation: https://templui.io/docs/components/button
package button
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/fserg/md-to-html/internal/ui/utils"
"strings"
)
type Variant string
type Size string
type Type string
const (
VariantDefault Variant = "default"
VariantDestructive Variant = "destructive"
VariantOutline Variant = "outline"
VariantSecondary Variant = "secondary"
VariantGhost Variant = "ghost"
VariantLink Variant = "link"
)
const (
TypeButton Type = "button"
TypeReset Type = "reset"
TypeSubmit Type = "submit"
)
const (
SizeDefault Size = "default"
SizeSm Size = "sm"
SizeLg Size = "lg"
SizeIcon Size = "icon"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Variant Variant
Size Size
FullWidth bool
Href string
Target string
Disabled bool
Type Type
Form string
}
func Button(props ...Props) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p Props
if len(props) > 0 {
p = props[0]
}
if p.Type == "" {
p.Type = TypeButton
}
if p.Href != "" && !p.Disabled {
var templ_7745c5c3_Var2 = []any{utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 61, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Href))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 63, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.Target != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 65, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var7 = []any{utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 87, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.Type != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " type=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(string(p.Type))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 103, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if p.Form != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " form=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(p.Form)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/button/button.templ`, Line: 106, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if p.Disabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func (b Props) variantClasses() string {
switch b.Variant {
case VariantDestructive:
return "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
case VariantOutline:
return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50"
case VariantSecondary:
return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80"
case VariantGhost:
return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
case VariantLink:
return "text-primary underline-offset-4 hover:underline"
default:
return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
}
}
func (b Props) sizeClasses() string {
switch b.Size {
case SizeSm:
return "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5"
case SizeLg:
return "h-10 rounded-md px-6 has-[>svg]:px-4"
case SizeIcon:
return "size-9"
default: // SizeDefault
return "h-9 px-4 py-2 has-[>svg]:px-3"
}
}
func (b Props) modifierClasses() string {
classes := []string{}
if b.FullWidth {
classes = append(classes, "w-full")
}
return strings.Join(classes, " ")
}
var _ = templruntime.GeneratedTemplate
+167
View File
@@ -0,0 +1,167 @@
// templui component card - version: v1.10.0 installed by templui v1.10.0
// 📚 Documentation: https://templui.io/docs/components/card
package card
import "github.com/fserg/md-to-html/internal/ui/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Card(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"w-full rounded-lg border bg-card text-card-foreground shadow-xs",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex flex-col space-y-1.5 p-6 pb-0",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Title(props ...TitleProps) {
{{ var p TitleProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<h3
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"text-lg font-semibold leading-none tracking-tight",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</h3>
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<p
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"text-sm text-muted-foreground",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</p>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-6",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center p-6 pt-0",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
+617
View File
@@ -0,0 +1,617 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
// templui component card - version: v1.10.0 installed by templui v1.10.0
// 📚 Documentation: https://templui.io/docs/components/card
package card
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/fserg/md-to-html/internal/ui/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
func Card(props ...Props) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p Props
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var2 = []any{utils.TwMerge(
"w-full rounded-lg border bg-card text-card-foreground shadow-xs",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 50, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Header(props ...HeaderProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p HeaderProps
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var6 = []any{utils.TwMerge(
"flex flex-col space-y-1.5 p-6 pb-0",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 71, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var5.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Title(props ...TitleProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p TitleProps
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var10 = []any{utils.TwMerge(
"text-lg font-semibold leading-none tracking-tight",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h3")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 92, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var9.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Description(props ...DescriptionProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p DescriptionProps
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var14 = []any{utils.TwMerge(
"text-sm text-muted-foreground",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<p")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 113, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var13.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Content(props ...ContentProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var17 := templ.GetChildren(ctx)
if templ_7745c5c3_Var17 == nil {
templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p ContentProps
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var18 = []any{utils.TwMerge(
"p-6",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 134, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var18).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var17.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Footer(props ...FooterProps) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var p FooterProps
if len(props) > 0 {
p = props[0]
}
var templ_7745c5c3_Var22 = []any{utils.TwMerge(
"flex items-center p-6 pt-0",
p.Class,
),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(p.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 155, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var22).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/card/card.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, p.Attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var21.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+158
View File
@@ -0,0 +1,158 @@
package ui
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
templ Home() {
@Layout("Markdown → HTML") {
<div class="panel-grid">
<section class="space-y-6">
<div class="space-y-4">
<div class="eyebrow">
<span>Go migration</span>
<span>goldmark + templUI</span>
</div>
<div class="space-y-3">
<h1 class="max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-foreground sm:text-5xl">
Markdown → HTML без внешних зависимостей в результирующем документе.
</h1>
<p class="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
Загрузите `.md`-файл или вставьте текст вручную. Сервис отдаст автономный HTML, одноразовое превью и отдельную ссылку на скачивание.
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Самодостаточный HTML</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Результат открывается локально без CDN и без сетевых вызовов.</p>
</div>
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Одноразовые ссылки</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Preview и download живут до первого открытия или максимум один час.</p>
</div>
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Русский интерфейс</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Форма ориентирована на быстрый ручной прогон документации и заметок.</p>
</div>
</div>
</section>
<section>
@card.Card(card.Props{Class: "section-card overflow-hidden"}) {
@card.Header(card.HeaderProps{Class: "space-y-2 border-b border-border/70 pb-6"}) {
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">Конвертация</div>
@card.Title(card.TitleProps{Class: "text-2xl font-semibold tracking-tight text-foreground"}) {
Выберите источник Markdown
}
@card.Description(card.DescriptionProps{Class: "max-w-xl text-sm leading-6 text-muted-foreground"}) {
Форма отправляется через HTMX на `POST /ui/convert`, а результат подменяется прямо в блоке ниже.
}
}
@card.Content(card.ContentProps{Class: "space-y-5"}) {
<form
id="convert-form"
hx-post="/ui/convert"
hx-target="#result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
class="space-y-5"
>
<div class="space-y-2">
<div class="field-label">Источник</div>
<div class="grid grid-cols-2 gap-2 rounded-[1.35rem] border border-border/80 bg-muted/55 p-2">
<label
class="source-tab source-tab-active"
data-source-tab="file"
data-active-classes="source-tab source-tab-active"
data-inactive-classes="source-tab"
>
<input
type="radio"
name="source"
value="file"
class="sr-only"
checked
onchange="window.mdToHTMLSwitchSource(this.value)"
/>
Файл
</label>
<label
class="source-tab"
data-source-tab="text"
data-active-classes="source-tab source-tab-active"
data-inactive-classes="source-tab"
>
<input
type="radio"
name="source"
value="text"
class="sr-only"
onchange="window.mdToHTMLSwitchSource(this.value)"
/>
Текст
</label>
</div>
</div>
<div id="source-file" class="source-panel space-y-3">
<label class="field-label" for="markdown-file">Markdown-файл</label>
<input
id="markdown-file"
class="surface-input file:mr-4 file:rounded-xl file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
type="file"
name="markdown_file"
accept=".md,.markdown,.mdown,text/markdown"
/>
<p class="field-hint">Используйте для загрузки существующего документа. Имя файла станет базой для имени HTML.</p>
</div>
<div id="source-text" class="source-panel hidden space-y-3">
<label class="field-label" for="markdown-text">Markdown-текст</label>
<textarea
id="markdown-text"
class="surface-textarea"
name="markdown_text"
rows="14"
placeholder="# Привет, мир&#10;&#10;- списки&#10;- таблицы&#10;- код"
></textarea>
<p class="field-hint">Подходит для быстрых заметок и вставок без промежуточного файла.</p>
</div>
<div class="flex flex-wrap items-center gap-3">
@button.Button(button.Props{
Type: button.TypeSubmit,
Class: "rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
Size: button.SizeDefault,
}) {
<span>Конвертировать</span>
}
<span class="field-hint">Лимиты тела запроса и markdown берутся из server config.</span>
</div>
</form>
<div id="result" class="min-h-[4rem]"></div>
}
}
</section>
</div>
<script>
window.mdToHTMLSwitchSource = function(value) {
const filePanel = document.getElementById("source-file");
const textPanel = document.getElementById("source-text");
if (!filePanel || !textPanel) {
return;
}
const showFile = value === "file";
filePanel.classList.toggle("hidden", !showFile);
textPanel.classList.toggle("hidden", showFile);
document.querySelectorAll("[data-source-tab]").forEach((tab) => {
const tabValue = tab.getAttribute("data-source-tab");
const active = tabValue === value;
tab.className = active
? tab.getAttribute("data-active-classes")
: tab.getAttribute("data-inactive-classes");
});
};
</script>
}
}
+212
View File
@@ -0,0 +1,212 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
func Home() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"panel-grid\"><section class=\"space-y-6\"><div class=\"space-y-4\"><div class=\"eyebrow\"><span>Go migration</span> <span>goldmark + templUI</span></div><div class=\"space-y-3\"><h1 class=\"max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-foreground sm:text-5xl\">Markdown → HTML без внешних зависимостей в результирующем документе.</h1><p class=\"max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg\">Загрузите `.md`-файл или вставьте текст вручную. Сервис отдаст автономный HTML, одноразовое превью и отдельную ссылку на скачивание.</p></div></div><div class=\"grid gap-4 sm:grid-cols-3\"><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Самодостаточный HTML</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Результат открывается локально без CDN и без сетевых вызовов.</p></div><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Одноразовые ссылки</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Preview и download живут до первого открытия или максимум один час.</p></div><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Русский интерфейс</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Форма ориентирована на быстрый ручной прогон документации и заметок.</p></div></div></section><section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground\">Конвертация</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Выберите источник Markdown")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Title(card.TitleProps{Class: "text-2xl font-semibold tracking-tight text-foreground"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Форма отправляется через HTMX на `POST /ui/convert`, а результат подменяется прямо в блоке ниже.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Description(card.DescriptionProps{Class: "max-w-xl text-sm leading-6 text-muted-foreground"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Header(card.HeaderProps{Class: "space-y-2 border-b border-border/70 pb-6"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form id=\"convert-form\" hx-post=\"/ui/convert\" hx-target=\"#result\" hx-swap=\"innerHTML\" hx-encoding=\"multipart/form-data\" class=\"space-y-5\"><div class=\"space-y-2\"><div class=\"field-label\">Источник</div><div class=\"grid grid-cols-2 gap-2 rounded-[1.35rem] border border-border/80 bg-muted/55 p-2\"><label class=\"source-tab source-tab-active\" data-source-tab=\"file\" data-active-classes=\"source-tab source-tab-active\" data-inactive-classes=\"source-tab\"><input type=\"radio\" name=\"source\" value=\"file\" class=\"sr-only\" checked onchange=\"window.mdToHTMLSwitchSource(this.value)\"> Файл</label> <label class=\"source-tab\" data-source-tab=\"text\" data-active-classes=\"source-tab source-tab-active\" data-inactive-classes=\"source-tab\"><input type=\"radio\" name=\"source\" value=\"text\" class=\"sr-only\" onchange=\"window.mdToHTMLSwitchSource(this.value)\"> Текст</label></div></div><div id=\"source-file\" class=\"source-panel space-y-3\"><label class=\"field-label\" for=\"markdown-file\">Markdown-файл</label> <input id=\"markdown-file\" class=\"surface-input file:mr-4 file:rounded-xl file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90\" type=\"file\" name=\"markdown_file\" accept=\".md,.markdown,.mdown,text/markdown\"><p class=\"field-hint\">Используйте для загрузки существующего документа. Имя файла станет базой для имени HTML.</p></div><div id=\"source-text\" class=\"source-panel hidden space-y-3\"><label class=\"field-label\" for=\"markdown-text\">Markdown-текст</label> <textarea id=\"markdown-text\" class=\"surface-textarea\" name=\"markdown_text\" rows=\"14\" placeholder=\"# Привет, мир&#10;&#10;- списки&#10;- таблицы&#10;- код\"></textarea><p class=\"field-hint\">Подходит для быстрых заметок и вставок без промежуточного файла.</p></div><div class=\"flex flex-wrap items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Конвертировать</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = button.Button(button.Props{
Type: button.TypeSubmit,
Class: "rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
Size: button.SizeDefault,
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"field-hint\">Лимиты тела запроса и markdown берутся из server config.</span></div></form><div id=\"result\" class=\"min-h-[4rem]\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Content(card.ContentProps{Class: "space-y-5"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Card(card.Props{Class: "section-card overflow-hidden"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</section></div><script>\n\t\t\twindow.mdToHTMLSwitchSource = function(value) {\n\t\t\t\tconst filePanel = document.getElementById(\"source-file\");\n\t\t\t\tconst textPanel = document.getElementById(\"source-text\");\n\t\t\t\tif (!filePanel || !textPanel) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst showFile = value === \"file\";\n\t\t\t\tfilePanel.classList.toggle(\"hidden\", !showFile);\n\t\t\t\ttextPanel.classList.toggle(\"hidden\", showFile);\n\n\t\t\t\tdocument.querySelectorAll(\"[data-source-tab]\").forEach((tab) => {\n\t\t\t\t\tconst tabValue = tab.getAttribute(\"data-source-tab\");\n\t\t\t\t\tconst active = tabValue === value;\n\t\t\t\t\ttab.className = active\n\t\t\t\t\t\t? tab.getAttribute(\"data-active-classes\")\n\t\t\t\t\t\t: tab.getAttribute(\"data-inactive-classes\");\n\t\t\t\t});\n\t\t\t};\n\t\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Markdown → HTML").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+20
View File
@@ -0,0 +1,20 @@
package ui
import (
"bytes"
"context"
"testing"
)
func TestHomeRenderSmoke(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
if err := Home().Render(context.Background(), &buf); err != nil {
t.Fatalf("render home: %v", err)
}
if got := buf.Len(); got <= 500 {
t.Fatalf("rendered output too small: %d", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
package ui
import "github.com/fserg/md-to-html/internal/version"
templ Layout(title string) {
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/dist/app.css"/>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="app-shell">
<div class="hero-panel">
<div class="relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10">
{ children... }
</div>
</div>
<footer class="mt-6 text-center text-sm text-muted-foreground">
Markdown → HTML · v{ version.Version }
</footer>
</div>
</body>
</html>
}
+76
View File
@@ -0,0 +1,76 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/fserg/md-to-html/internal/version"
func Layout(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"ru\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 11, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/dist/app.css\"><script src=\"/static/htmx.min.js\"></script></head><body><div class=\"app-shell\"><div class=\"hero-panel\"><div class=\"relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><footer class=\"mt-6 text-center text-sm text-muted-foreground\">Markdown → HTML · v")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 23, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</footer></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
templ Result(previewID, downloadID string, fullHTML string, filename string) {
@card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}) {
@card.Content(card.ContentProps{Class: "space-y-4"}) {
<div class="flex flex-wrap items-center gap-3">
@button.Button(button.Props{
Href: "/preview/" + previewID,
Target: "_blank",
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
}) {
Открыть превью
}
@button.Button(button.Props{
Href: "/download/" + downloadID,
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
Variant: button.VariantOutline,
}) {
Скачать HTML
}
<span class="text-sm text-muted-foreground">Файл: <span class="font-medium text-foreground">{ filename }</span></span>
</div>
<p class="text-sm leading-6 text-muted-foreground">
Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.
</p>
<details class="group overflow-hidden rounded-[1.25rem] border border-border bg-card/80">
<summary class="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground">
<span class="inline-flex items-center gap-2">
<span class="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">i</span>
Inline-превью в изолированном iframe
</span>
</summary>
<div class="border-t border-border/70 px-4 pb-4 pt-3">
<iframe
class="result-frame"
sandbox=""
referrerpolicy="no-referrer"
srcdoc={ fullHTML }
></iframe>
</div>
</details>
}
}
}
templ Error(msg string) {
<div class="rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{ msg }
</div>
}
+206
View File
@@ -0,0 +1,206 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
func Result(previewID, downloadID string, fullHTML string, filename string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex flex-wrap items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Открыть превью")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = button.Button(button.Props{
Href: "/preview/" + previewID,
Target: "_blank",
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Скачать HTML")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = button.Button(button.Props{
Href: "/download/" + downloadID,
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
Variant: button.VariantOutline,
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-sm text-muted-foreground\">Файл: <span class=\"font-medium text-foreground\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 27, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></span></div><p class=\"text-sm leading-6 text-muted-foreground\">Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.</p><details class=\"group overflow-hidden rounded-[1.25rem] border border-border bg-card/80\"><summary class=\"cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground\"><span class=\"inline-flex items-center gap-2\"><span class=\"inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground\">i</span> Inline-превью в изолированном iframe</span></summary><div class=\"border-t border-border/70 px-4 pb-4 pt-3\"><iframe class=\"result-frame\" sandbox=\"\" referrerpolicy=\"no-referrer\" srcdoc=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 44, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></iframe></div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Content(card.ContentProps{Class: "space-y-4"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Error(msg string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 54, Col: 7}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+162
View File
@@ -0,0 +1,162 @@
// templui util templui.go - version: v1.10.0 installed by templui v1.10.0
package utils
import (
"context"
"crypto/rand"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
"github.com/a-h/templ"
"github.com/templui/templui/components"
twmerge "github.com/Oudwins/tailwind-merge-go"
)
// TwMerge combines Tailwind classes and resolves conflicts.
// Example: "bg-red-500 hover:bg-blue-500", "bg-green-500" → "hover:bg-blue-500 bg-green-500"
func TwMerge(classes ...string) string {
return twmerge.Merge(classes...)
}
// If returns value if condition is true, otherwise the zero value of T.
// Example: true, "bg-red-500" → "bg-red-500"
func If[T any](condition bool, value T) T {
var empty T
if condition {
return value
}
return empty
}
// IfElse returns trueValue if condition is true, otherwise falseValue.
// Example: true, "bg-red-500", "bg-gray-300" → "bg-red-500"
func IfElse[T any](condition bool, trueValue T, falseValue T) T {
if condition {
return trueValue
}
return falseValue
}
// MergeAttributes combines multiple Attributes into one.
// Example: MergeAttributes(attr1, attr2) → combined attributes
func MergeAttributes(attrs ...templ.Attributes) templ.Attributes {
merged := templ.Attributes{}
for _, attr := range attrs {
for k, v := range attr {
merged[k] = v
}
}
return merged
}
// RandomID generates a random ID string.
// Example: RandomID() → "id-1a2b3c"
func RandomID() string {
return fmt.Sprintf("id-%s", rand.Text())
}
// ScriptVersion is a timestamp generated at app start for cache busting.
// Used in component script tags to append ?v=<timestamp> to script URLs.
var ScriptVersion = fmt.Sprintf("%d", time.Now().Unix())
// ScriptURL generates cache-busted script URLs.
// Override this to use custom cache busting (CDN, content hashing, etc.)
//
// Example override in your app:
//
// func init() {
// utils.ScriptURL = func(path string) string {
// return myAssetManifest.GetURL(path)
// }
// }
var ScriptURL = func(path string) string {
return path + "?v=" + ScriptVersion
}
// componentScriptBasePath is the base public path for component JavaScript files.
// In the import workflow this stays "/templui/js". The CLI rewrites it to the user's local jsPublicPath.
var componentScriptBasePath = "/static/assets/js"
// UseUnminifiedScripts switches component script loading to the unminified files.
// Leave this false in normal use and set it to true during app startup for debugging.
var UseUnminifiedScripts = false
// ComponentScript renders a deferred script tag for a component JavaScript file.
// Example: ComponentScript("datepicker") → <script defer src="/templui/js/datepicker.min.js?..."></script>
func ComponentScript(component string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
nonce := templ.GetNonce(ctx)
fileName := component + ".min.js"
if UseUnminifiedScripts {
fileName = component + ".js"
}
src := ScriptURL(componentScriptBasePath + "/" + fileName)
if _, err := io.WriteString(w, `<script type="module"`); err != nil {
return err
}
if nonce != "" {
if _, err := io.WriteString(w, ` nonce="`); err != nil {
return err
}
if _, err := io.WriteString(w, templ.EscapeString(nonce)); err != nil {
return err
}
if _, err := io.WriteString(w, `"`); err != nil {
return err
}
}
if _, err := io.WriteString(w, ` src="`); err != nil {
return err
}
if _, err := io.WriteString(w, templ.EscapeString(src)); err != nil {
return err
}
if _, err := io.WriteString(w, `"></script>`); err != nil {
return err
}
return nil
})
}
// SetupScriptRoutes serves embedded component JavaScript files for the import workflow.
// Example: SetupScriptRoutes(mux, true) mounts /templui/js/*.js with no-store caching in development.
func SetupScriptRoutes(mux *http.ServeMux, isDevelopment bool) {
if mux == nil || componentScriptBasePath != "/templui/js" {
return
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlPath := strings.TrimPrefix(r.URL.Path, "/templui/js/")
if urlPath == r.URL.Path || urlPath == "" || strings.Contains(urlPath, "..") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/javascript")
if isDevelopment {
w.Header().Set("Cache-Control", "no-store")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000")
}
fileName := path.Base(urlPath)
component := strings.TrimSuffix(fileName, ".min.js")
component = strings.TrimSuffix(component, ".js")
file, err := fs.ReadFile(components.TemplFiles, path.Join(component, fileName))
if err != nil {
http.NotFound(w, r)
return
}
_, _ = w.Write(file)
})
mux.Handle("GET /templui/js/", handler)
}
+4
View File
@@ -0,0 +1,4 @@
package version
// Version устанавливается через -ldflags при сборке.
var Version = "dev"
+1035
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"name": "md-to-html",
"private": true,
"devDependencies": {
"tailwindcss": "3.4.17"
}
}
+37
View File
@@ -0,0 +1,37 @@
module.exports = {
content: [
"./internal/ui/**/*.templ",
"./internal/ui/**/*.go",
"./web/template/**/*.html",
],
theme: {
extend: {
colors: {
background: "#f5efe2",
foreground: "#221f1a",
card: "#fffdf8",
"card-foreground": "#221f1a",
primary: "#b85c38",
"primary-foreground": "#fffaf4",
secondary: "#ead7b0",
"secondary-foreground": "#3f3528",
muted: "#efe4d2",
"muted-foreground": "#6c6254",
accent: "#d0b38a",
"accent-foreground": "#2e2417",
border: "#d8c6ab",
ring: "#b85c38",
input: "#fffaf4",
destructive: "#b42318",
},
boxShadow: {
xs: "0 1px 2px rgba(34, 31, 26, 0.08)",
},
fontFamily: {
sans: ["IBM Plex Sans", "Avenir Next", "Segoe UI", "sans-serif"],
mono: ["IBM Plex Mono", "SFMono-Regular", "monospace"],
},
},
},
plugins: [],
};
+14
View File
@@ -0,0 +1,14 @@
//go:build tools
package tools
import (
_ "github.com/a-h/templ"
_ "github.com/alecthomas/chroma/v2"
_ "github.com/go-chi/chi/v5"
_ "github.com/google/uuid"
_ "github.com/mozillazg/go-unidecode"
_ "github.com/yuin/goldmark"
_ "github.com/yuin/goldmark-emoji"
_ "github.com/yuin/goldmark-highlighting/v2"
)
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed all:static
var StaticFS embed.FS
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
+87
View File
@@ -0,0 +1,87 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html {
background:
radial-gradient(circle at top left, rgba(234, 215, 176, 0.55), transparent 34rem),
radial-gradient(circle at top right, rgba(184, 92, 56, 0.14), transparent 24rem),
linear-gradient(180deg, #fbf7ef 0%, #f3eadb 100%);
}
body {
@apply min-h-screen bg-transparent font-sans text-foreground antialiased;
}
a {
@apply transition-colors;
}
::selection {
background: rgba(184, 92, 56, 0.18);
}
}
@layer components {
.app-shell {
@apply mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8;
}
.hero-panel {
@apply relative overflow-hidden rounded-[2rem] border border-border/70 bg-card/95 shadow-xl shadow-stone-900/5 backdrop-blur;
}
.hero-panel::before {
content: "";
@apply absolute inset-x-0 top-0 h-40 bg-gradient-to-r from-secondary/80 via-card to-transparent;
}
.panel-grid {
@apply grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(21rem,0.9fr)];
}
.eyebrow {
@apply inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground;
}
.section-card {
@apply rounded-[1.5rem] border border-border/80 bg-card/90 shadow-lg shadow-stone-900/5;
}
.field-label {
@apply text-sm font-semibold text-foreground;
}
.field-hint {
@apply text-sm text-muted-foreground;
}
.surface-input {
@apply block w-full rounded-2xl border border-border bg-background/95 px-4 py-3 text-sm text-foreground shadow-xs outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20;
}
.surface-textarea {
@apply surface-input min-h-[18rem] resize-y font-mono leading-6;
}
.source-tab {
@apply inline-flex flex-1 cursor-pointer items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold text-muted-foreground transition;
}
.source-tab-active {
@apply bg-primary text-primary-foreground shadow-sm;
}
.source-panel {
@apply rounded-[1.5rem] border border-dashed border-border/80 bg-background/70 p-4;
}
.result-frame {
@apply mt-3 h-[36rem] w-full rounded-[1.25rem] border border-border bg-white shadow-inner;
}
}
+1
View File
@@ -0,0 +1 @@
+296
View File
@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>
:root {
color-scheme: light;
--page-bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--muted: #475569;
--code-bg: #f1f5f9;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--quote-bg: #eff6ff;
--quote-border: #93c5fd;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 24px 16px 40px;
}
.document {
max-width: 960px;
margin: 0 auto;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 48px 56px;
}
.document > :first-child {
margin-top: 0;
}
.document h1,
.document h2,
.document h3,
.document h4,
.document h5,
.document h6 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 1.75rem 0 0.85rem;
position: relative;
scroll-margin-top: 2rem;
}
.document h1 {
border-bottom: 1px solid var(--border);
font-size: 2.4rem;
padding-bottom: 0.5rem;
}
.document h2 {
border-bottom: 1px solid var(--border);
font-size: 1.85rem;
padding-bottom: 0.45rem;
}
.document h3 {
font-size: 1.45rem;
}
.document h4 {
font-size: 1.2rem;
}
.document h5,
.document h6 {
font-size: 1rem;
}
.document .heading-anchor {
color: var(--muted);
float: left;
margin-left: -1.25em;
opacity: 0;
padding-right: 0.3em;
text-decoration: none;
transition: opacity 0.18s ease;
user-select: none;
}
.document h1:hover .heading-anchor,
.document h2:hover .heading-anchor,
.document h3:hover .heading-anchor,
.document h4:hover .heading-anchor,
.document h5:hover .heading-anchor,
.document h6:hover .heading-anchor,
.document .heading-anchor:focus {
opacity: 0.65;
}
.document p,
.document ul,
.document ol,
.document blockquote,
.document table,
.document pre,
.document hr {
margin: 0 0 1rem;
}
.document ul,
.document ol {
padding-left: 1.7rem;
}
.document li + li {
margin-top: 0.3rem;
}
.document li > p {
margin-bottom: 0.5rem;
}
.document a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.15em;
}
.document a:hover {
color: var(--accent-hover);
}
.document strong {
font-weight: 700;
}
.document em {
font-style: italic;
}
.document del {
color: var(--muted);
text-decoration-thickness: 0.08em;
}
.document blockquote {
background: var(--quote-bg);
border-left: 4px solid var(--quote-border);
border-radius: 0 12px 12px 0;
color: var(--muted);
padding: 1rem 1.2rem;
}
.document hr {
border: 0;
border-top: 1px solid var(--border);
}
.document code {
background: var(--code-bg);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
font-family: "SFMono-Regular", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
}
.document pre {
background: #0f172a;
border-radius: 16px;
color: #e2e8f0;
overflow-x: auto;
padding: 1rem 1.1rem;
}
.document pre code {
background: transparent;
border: 0;
color: inherit;
display: block;
font-size: 0.92rem;
line-height: 1.6;
padding: 0;
white-space: pre;
}
.document img {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
display: block;
height: auto;
max-width: 100%;
}
.document table {
border-collapse: collapse;
display: block;
overflow-x: auto;
width: 100%;
}
.document th,
.document td {
border: 1px solid var(--border);
padding: 0.65rem 0.8rem;
text-align: left;
vertical-align: top;
}
.document th {
background: #f8fafc;
font-weight: 700;
}
.document tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.7);
}
.document .task-list-item {
list-style: none;
margin-left: -1.55rem;
padding-left: 1.55rem;
}
.document .task-list-item input[type="checkbox"] {
accent-color: var(--accent);
margin-right: 0.5rem;
pointer-events: none;
transform: translateY(1px);
}
.document .footnotes {
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.95rem;
margin-top: 2rem;
padding-top: 1rem;
}
.document .footnotes ol {
margin-bottom: 0;
}
@media (max-width: 768px) {
body {
padding: 16px 10px 28px;
}
.document {
border-radius: 16px;
padding: 28px 20px;
}
.document h1 {
font-size: 2rem;
}
.document h2 {
font-size: 1.55rem;
}
.document h3 {
font-size: 1.3rem;
}
.document .heading-anchor {
margin-left: -1.05em;
}
.document th,
.document td {
min-width: 140px;
}
}
</style>
</head>
<body>
<main class="document">
{{if .ShowTitle}}<h1>{{.Title}}</h1>{{end}}
{{.Body}}
</main>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
package webtemplate
import "embed"
//go:embed document.html
var FS embed.FS