29 Commits

Author SHA1 Message Date
Sergey Filkin 5bb488ccd0 Release v0.2.2
release / release (push) Has been cancelled
build / test (push) Successful in 11m1s
build / cross-compile (amd64, linux) (push) Failing after 5m43s
build / cross-compile (arm64, darwin) (push) Failing after 5m23s
build / cross-compile (arm64, linux) (push) Failing after 5m23s
2026-04-18 14:42:16 +03:00
Sergey Filkin 256d5c9e6d chore(progress): complete phase7 2026-04-18 13:43:58 +03:00
Sergey Filkin a90519807c phase7: release v0.2.1
release / release (push) Has been cancelled
2026-04-18 13:32:31 +03:00
Sergey Filkin 4cd85e3515 chore(progress): resume phase7 2026-04-18 13:30:37 +03:00
Sergey Filkin 9531730283 chore(progress): block phase7 2026-04-18 12:59:29 +03:00
Sergey Filkin 66ca05692b phase7: documentation + release v0.2.0
release / release (push) Has been cancelled
2026-04-18 12:57:03 +03:00
Sergey Filkin 13ce2a5b4f chore(progress): start phase7 2026-04-18 12:53:13 +03:00
Sergey Filkin 5a6f278d8a chore(progress): complete phase6 2026-04-18 12:38:55 +03:00
Sergey Filkin 4b55661aa4 phase6: Dockerfile, CI workflows, GHCR release pipeline 2026-04-18 12:38:43 +03:00
Sergey Filkin 08d12feaa9 chore(progress): start phase6 2026-04-18 12:22:38 +03:00
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
111 changed files with 12733 additions and 66 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"]
+18 -8
View File
@@ -1,10 +1,20 @@
.git
.github
.review-sandboxes
.claude
.agents
.DS_Store
.claude/
.agents/
.review-sandboxes/
md/*.html
__pycache__/
*.pyc
venv/
.venv/
.venv
venv
__pycache__
*.py[cod]
archive
docs
tmp
bin
dist
node_modules
web/static/dist
.air.log
*.md
!README.md
+108
View File
@@ -0,0 +1,108 @@
name: build
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env:
TAILWIND_VERSION: v3.4.17
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Install templ
run: go install github.com/a-h/templ/cmd/templ@v0.3.1001
- name: Install tailwindcss standalone
run: |
curl -fsSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64"
chmod +x /usr/local/bin/tailwindcss
- name: Build assets
run: |
mkdir -p web/static/dist
templ generate ./...
tailwindcss -c tailwind.config.js -i web/static/src/app.css -o web/static/dist/app.css --minify
- name: Vet
run: go vet ./...
- name: Test
run: go test -race ./...
- name: Build
run: |
mkdir -p bin
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=$(cat VERSION)" \
-o bin/md-to-html ./cmd/md-to-html
cross-compile:
needs: test
runs-on: ubuntu-latest
env:
TAILWIND_VERSION: v3.4.17
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install templ
run: go install github.com/a-h/templ/cmd/templ@v0.3.1001
- name: Install tailwindcss standalone
run: |
curl -fsSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64"
chmod +x /usr/local/bin/tailwindcss
- name: Build assets
run: |
mkdir -p web/static/dist
templ generate ./...
tailwindcss -c tailwind.config.js -i web/static/src/app.css -o web/static/dist/app.css --minify
- name: Cross-compile
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p bin
go build -trimpath \
-ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=$(cat VERSION)" \
-o "bin/md-to-html-${GOOS}-${GOARCH}" \
./cmd/md-to-html
- uses: actions/upload-artifact@v4
with:
name: md-to-html-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/md-to-html-${{ matrix.goos }}-${{ matrix.goarch }}
retention-days: 7
+85
View File
@@ -0,0 +1,85 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
env:
TAILWIND_VERSION: v3.4.17
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install templ
run: go install github.com/a-h/templ/cmd/templ@v0.3.1001
- name: Install tailwindcss standalone
run: |
curl -fsSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64"
chmod +x /usr/local/bin/tailwindcss
- name: Build assets
run: |
mkdir -p web/static/dist
templ generate ./...
tailwindcss -c tailwind.config.js -i web/static/src/app.css -o web/static/dist/app.css --minify
- name: Cross-compile binaries
run: |
mkdir -p dist
for target in "linux amd64" "linux arm64" "darwin arm64"; do
set -- $target
GOOS=$1 GOARCH=$2 CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=$(cat VERSION)" \
-o "dist/md-to-html-${1}-${2}" \
./cmd/md-to-html
done
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image name
id: image
run: |
repo_lower=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')
echo "name=ghcr.io/${repo_lower}" >> "${GITHUB_OUTPUT}"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ steps.image.outputs.name }}:${{ github.ref_name }}
${{ steps.image.outputs.name }}:latest
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--notes-file CHANGELOG.md \
dist/*
+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"
}
+36
View File
@@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and the project uses Semantic Versioning.
## [0.2.2] - 2026-04-18
### Changed
- Web UI redesigned into a single-column classic layout with tabbed file/text input, updated result card, and API snippet.
- Added a local release build script and Make targets for current-platform and cross-platform release artifacts.
- README updated with the current build, release, and run instructions.
## [0.2.1] - 2026-04-18
### Fixed
- GitHub release workflow now lowercases the GHCR image name before publishing, which fixes releases for repositories with uppercase owner names.
## [0.2.0] - 2026-04-18
### Changed
- **BREAKING**: project fully rewritten in Go (goldmark + templUI); Python implementation moved to `archive/`.
- **BREAKING**: heading anchors now use ASCII transliteration (`## Установка``id="ustanovka"`).
- **BREAKING**: heading HTML markup simplified; `<div class="markdown-heading">` is no longer emitted.
- Removed the GitHub Markdown API dependency; conversion now works fully offline.
- Replaced the two-process runtime (uvicorn + Streamlit) with a single binary.
- Preview and download links are now one-shot, UUID-backed, and expire after one hour.
### Added
- Syntax highlighting via chroma with inline styles for self-contained HTML output.
- Footnote support in addition to baseline GFM features.
- Cross-platform release binaries for `linux/amd64`, `linux/arm64`, and `darwin/arm64`.
### Removed
- `READY_CHECK_GITHUB` environment variable.
- Streamlit UI on dedicated port `:8501`.
## [0.1.2] - 2026-04-18
### Added
+53 -11
View File
@@ -1,20 +1,62 @@
FROM python:3.12-slim
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim AS tailwind
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
WORKDIR /src
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ARG TARGETARCH
ARG TAILWIND_VERSION=v3.4.17
RUN case "$TARGETARCH" in \
amd64) tailwind_arch='x64' ;; \
arm64) tailwind_arch='arm64' ;; \
*) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${tailwind_arch}" \
&& chmod +x /usr/local/bin/tailwindcss
COPY tailwind.config.js ./
COPY web/ ./web/
COPY internal/ui/ ./internal/ui/
RUN mkdir -p web/static/dist \
&& tailwindcss \
-c tailwind.config.js \
-i web/static/src/app.css \
-o web/static/dist/app.css \
--minify
FROM golang:1.24-alpine AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates git
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1001
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=tailwind /src/web/static/dist/app.css ./web/static/dist/app.css
EXPOSE 8000 8501
RUN templ generate ./... \
&& CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=$(cat VERSION)" \
-o /out/md-to-html \
./cmd/md-to-html
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)"
FROM gcr.io/distroless/static-debian12:nonroot
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "start.py"]
COPY --from=build /out/md-to-html /md-to-html
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/md-to-html", "serve"]
+45
View File
@@ -0,0 +1,45 @@
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 release release-all
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"
release:
./scripts/release-build.sh
release-all:
./scripts/release-build.sh --all
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
+94 -46
View File
@@ -1,81 +1,129 @@
# md-to-html
Сервис конвертации Markdown в самодостаточный HTML (через GitHub API).
Сервис конвертации Markdown в самодостаточный HTML. Конвертация выполняется локально, без внешних API.
Текущая версия: `0.1.2`
![Превью интерфейса](screen.png)
Часто нужен адекватно (минималистично) выглядящий HTML из Markdown. HTML получем через открытый API GitHub, а стили просто захардкожены в шаблоне.
Текущая версия: `0.2.2` (Go + goldmark + templUI)
![Streamlit UI](screen.png)
## Возможности
GITHUB_TOKEN не нужен, если не требуется массовая (поточная) конвертация. Но если нужно, то его можно передать через переменную окружения при запуске.
- GFM + footnote + emoji + подсветка кода через chroma.
- Web UI на `http://localhost:8080/` с загрузкой файла или вставкой текста, HTMX-обновлением результата и одноразовыми ссылками на preview/download.
- CLI: `md-to-html cli file.md`.
- HTTP API: `POST /convert`, совместим с `v0.1.x`.
- Якоря в заголовках с ASCII-транслитом: `## Установка``#ustanovka`.
Есть два интерфейса:
- FastAPI на `http://localhost:8000`
- Streamlit UI на `http://localhost:8501` с двумя режимами ввода: загрузка `.md` файла или вставка Markdown-текста из буфера обмена
## Локальный запуск
## Запуск через Docker
```bash
uv venv .venv
source .venv/bin/activate
uv pip install -r requirements.txt
uvicorn app.api:app --reload
streamlit run app/streamlit_app.py
docker run --rm -p 8080:8080 ghcr.io/fserg/md-to-html:latest
```
CLI сохранился:
## Быстрый старт
```bash
python3 md_to_html.py /path/to/file.md
go install github.com/a-h/templ/cmd/templ@v0.3.1001
npm install
make build
./bin/md-to-html serve
```
## Docker
## Локальная разработка
Требования: Go 1.24+, Node.js, `templ` CLI.
```bash
docker build -t md-to-html .
docker run --rm -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=your_token md-to-html
go install github.com/a-h/templ/cmd/templ@v0.3.1001
npm install
make tailwind
make build
./bin/md-to-html serve
```
## API
Для live-reload:
```bash
make dev
```
## Релизная сборка
Локальный release-билд для текущей платформы:
```bash
make release
```
Скрипт:
- генерирует `templ`-код
- собирает Tailwind bundle
- прогоняет `go test ./...`
- собирает release-бинарь с версией из `VERSION`
- кладёт артефакты в `dist/`
Проверка готового release-билда:
```bash
./dist/md-to-html-$(go env GOOS)-$(go env GOARCH) serve
```
Сборка всех release-таргетов как в CI:
```bash
make release-all
```
## CLI
```bash
md-to-html cli file.md
md-to-html cli file.md -o out.html
md-to-html cli --stdin < file.md
md-to-html cli - --title "Заголовок"
```
## HTTP API
`POST /convert`
```bash
curl -X POST http://localhost:8000/convert \
-H 'Content-Type: application/json' \
-d '{"markdown":"# Hello"}'
curl -X POST http://localhost:8080/convert \
-H 'content-type: application/json' \
-d '{"markdown":"# Привет"}'
```
`GET /health`
Прочие эндпоинты:
```bash
curl http://localhost:8000/health
```
- `GET /` — веб-интерфейс.
- `GET /health`, `GET /version`, `GET /ready` — служебные эндпоинты.
- `GET /preview/{id}`, `GET /download/{id}` — одноразовые ссылки из веб-формы.
`GET /version`
## Env-переменные
```bash
curl http://localhost:8000/version
```
| Переменная | По умолчанию | Назначение |
|----------------------|--------------|------------|
| `ADDR` | `:8080` | Адрес прослушивания |
| `MAX_MARKDOWN_BYTES` | `1048576` | Лимит размера markdown |
| `MAX_REQUEST_BYTES` | `1200000` | Лимит размера HTTP-запроса |
| `PREVIEW_TTL` | `1h` | TTL одноразовых ссылок |
## Миграция с v0.1.x
- API-контракт `POST /convert` не изменился, существующие клиенты продолжают работать.
- Якоря заголовков теперь используют ASCII-транслит. Ссылки вида `#установка` нужно заменить на `#ustanovka`.
- HTML-разметка упрощена: больше нет `<div class="markdown-heading">`, поэтому ручные CSS-оверрайды нужно пересмотреть.
- Переменная окружения `READY_CHECK_GITHUB` удалена: сервис больше не зависит от внешнего Markdown API.
- UI работает на том же порту `8080`, отдельный UI-порт `:8501` больше не нужен.
Python-реализация сохранена в `archive/`.
## Релизы
Проект использует Semantic Versioning. Текущая версия хранится в файле `VERSION`, история изменений ведётся в `CHANGELOG.md`.
Чтобы выпустить релиз:
```bash
git add VERSION CHANGELOG.md
git commit -m "Release v0.1.2"
git tag v0.1.2
git commit -am "Release vX.Y.Z"
git tag vX.Y.Z
git push origin main --tags
gh release create v0.1.2 --notes-file CHANGELOG.md
```
После публикации релиза GitHub Actions автоматически собирает Docker-образ и публикует его в GitHub Container Registry:
```bash
docker pull ghcr.io/fserg/md-to-html:v0.1.2
```
GitHub Actions публикует Docker-образ для `linux/amd64` и `linux/arm64` в GHCR и прикладывает бинарники для `linux/amd64`, `linux/arm64` и `darwin/arm64` к GitHub Release.
+1 -1
View File
@@ -1 +1 @@
0.1.2
0.2.2
+10
View File
@@ -0,0 +1,10 @@
.git
.DS_Store
.claude/
.agents/
.review-sandboxes/
md/*.html
__pycache__/
*.pyc
venv/
.venv/
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3); u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "start.py"]
+1
View File
@@ -0,0 +1 @@
Архивная Python-реализация md-to-html v0.1.2. Для истории.
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>
Binary file not shown.

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) | ✅ done | 2026-04-18 | 2026-04-18 | 4b55661 | Tailwind standalone pinned to `v3.4.17`: `latest` did not emit `web/static/dist/app.css` for the current build pipeline. |
| 7 | [Документация + v0.2.0](phases/phase-7-docs.md) | ✅ done | 2026-04-18 | 2026-04-18 | a905198 | `v0.2.0` remained as failed tag history after the initial GHCR naming bug; phase completed via patch release `v0.2.1` after lowercasing image tags in `release.yml`. |
Легенда статусов:
-`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
}
+318
View File
@@ -0,0 +1,318 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"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) {
startedAt := time.Now()
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)
lineCount := bytes.Count(result.HTML, []byte("\n")) + 1
elapsedMs := int(time.Since(startedAt).Milliseconds())
if elapsedMs < 1 {
elapsedMs = 1
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = ui.Result(previewID, downloadID, string(result.HTML), filename, len(result.HTML), lineCount, elapsedMs).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
+371
View File
@@ -0,0 +1,371 @@
package ui
templ Home() {
@Layout("Markdown → standalone HTML") {
<div class="mx-auto max-w-3xl px-6 py-10">
<header class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight text-foreground sm:text-[2rem]">Markdown → standalone HTML</h1>
<p class="mt-1.5 max-w-prose text-sm leading-6 text-muted-foreground">
Загрузите .md файл или вставьте Markdown-текст. Результат — готовый самодостаточный HTML со встроенными стилями.
</p>
</header>
<form
id="convert-form"
class="space-y-6"
hx-post="/ui/convert"
hx-target="#result"
hx-swap="outerHTML"
hx-encoding="multipart/form-data"
onreset="window.setTimeout(window.mdToHTMLResetForm, 0)"
>
<input id="source-field" type="hidden" name="source" value="file"/>
<section class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
<div class="border-b border-border px-4 py-4">
<div class="inline-flex items-center gap-1 rounded-lg bg-muted p-1" role="tablist" aria-label="Источник markdown">
<button
id="tab-file"
type="button"
value="file"
class="tabs-trigger"
data-state="active"
onclick="window.mdToHTMLSetSource('file')"
>
@FileIcon("size-3.5")
<span>Загрузить файл</span>
</button>
<button
id="tab-text"
type="button"
value="text"
class="tabs-trigger"
onclick="window.mdToHTMLSetSource('text')"
>
@AlignLeftIcon("size-3.5")
<span>Вставить текст</span>
</button>
</div>
</div>
<div class="p-6">
<div id="panel-file" class="flex flex-col gap-4">
<label
id="markdown-dropzone"
for="markdown-file"
class="dropzone group block cursor-pointer rounded-lg border-2 border-dashed border-border p-10 text-center transition hover:border-foreground/25"
>
<input
id="markdown-file"
type="file"
name="markdown_file"
accept=".md,.markdown,.mdown,text/markdown"
class="sr-only"
onchange="window.mdToHTMLHandleFileChange(this)"
/>
<div class="mx-auto mb-3 grid size-10 place-items-center rounded-full bg-muted text-muted-foreground transition group-hover:bg-primary/5 group-hover:text-foreground">
@UploadIcon("size-5")
</div>
<div class="text-sm font-medium text-foreground">Перетащите .md файл сюда</div>
<div class="mt-1 text-xs text-muted-foreground">
или <span class="text-foreground underline underline-offset-2">выберите на диске</span>
</div>
<div class="mt-3 text-[11px] text-muted-foreground">Лимит: 200 MB · Тип: text/markdown</div>
</label>
<div id="selected-file" class="hidden items-center gap-3 rounded-lg border border-border bg-muted/40 p-3">
<div class="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-background text-muted-foreground">
@FileIcon("size-4")
</div>
<div class="min-w-0 flex-1">
<div id="selected-file-name" class="truncate text-sm font-medium text-foreground">README.md</div>
<div id="selected-file-meta" class="text-xs text-muted-foreground font-mono">3.4 KB · изменён только что</div>
</div>
<button
type="button"
class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="Удалить файл"
onclick="window.mdToHTMLClearFile()"
>
@CloseIcon("size-4")
</button>
</div>
</div>
<div id="panel-text" class="hidden flex-col gap-3">
<label for="markdown-text" class="text-[13px] font-medium text-foreground">Markdown-текст</label>
<div class="relative">
<textarea
id="markdown-text"
name="markdown_text"
rows="10"
class="focus-ring min-h-[16rem] w-full resize-y rounded-md border border-border bg-background px-3 py-2.5 font-mono text-sm leading-6 text-foreground placeholder:text-muted-foreground"
placeholder="# Мой заголовок&#10;&#10;Здесь будет текст..."
oninput="window.mdToHTMLUpdateCharCount(this)"
></textarea>
<span id="markdown-char-count" class="pointer-events-none absolute bottom-2.5 right-3 text-[10px] text-muted-foreground font-mono">
0 символов
</span>
</div>
<p class="flex items-center gap-2 text-[11px] text-muted-foreground">
@InfoIcon("size-3.5")
<span>Поддерживается CommonMark + GFM</span>
</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-4">
<p class="text-xs text-muted-foreground">
Конвертация использует публичный <code class="font-mono text-foreground">GitHub API</code>
</p>
<div class="flex items-center gap-2">
<button
type="reset"
class="focus-ring inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
>
Сбросить
</button>
<button
type="submit"
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<span>Конвертировать</span>
@ArrowRightIcon("size-4")
</button>
</div>
</div>
</section>
</form>
<div id="result" class="mt-6"></div>
<section class="mt-6 overflow-hidden rounded-xl border border-border bg-background shadow-xs">
<div class="flex items-center justify-between border-b border-border px-5 py-3.5">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground">API</span>
<span class="inline-flex items-center rounded-md border border-border bg-muted px-1.5 py-px text-[10px] font-medium text-foreground font-mono">
POST /convert
</span>
</div>
<button
type="button"
class="focus-ring inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="Скопировать curl"
data-copy-target="api-curl"
data-copy-label="curl"
onclick="window.mdToHTMLCopyButton(this)"
>
@CopyIcon("size-3.5")
</button>
</div>
<textarea id="api-curl" class="sr-only" readonly>curl -X POST http://localhost:8000/convert \
-H 'Content-Type: application/json' \
-d '&#123;"markdown":"# Hello"&#125;'</textarea>
<pre class="overflow-x-auto px-5 py-4 font-mono text-[12px] leading-relaxed text-foreground"><span class="text-muted-foreground">$</span> curl -X POST http://localhost:8000/convert \
-H 'Content-Type: application/json' \
-d '&#123;"markdown":"# Hello"&#125;'</pre>
</section>
</div>
<script>
(() => {
function byId(id) {
return document.getElementById(id);
}
function formatBytes(bytes) {
if (bytes < 1024) {
return bytes + " B";
}
return (bytes / 1024).toFixed(1) + " KB";
}
function formatRelativeTime(lastModified) {
const delta = Date.now() - lastModified;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (delta < minute) {
return "изменён только что";
}
if (delta < hour) {
const value = Math.max(1, Math.round(delta / minute));
return "изменён " + value + " мин назад";
}
if (delta < day) {
const value = Math.max(1, Math.round(delta / hour));
return "изменён " + value + " ч назад";
}
const value = Math.max(1, Math.round(delta / day));
return "изменён " + value + " дн назад";
}
function flashCopyState(button) {
if (!button) {
return;
}
const original = button.dataset.copyFlashOriginal || button.innerHTML;
button.dataset.copyFlashOriginal = original;
button.innerHTML = "Скопировано";
window.setTimeout(() => {
button.innerHTML = original;
}, 1400);
}
async function copyText(value) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return;
}
const helper = document.createElement("textarea");
helper.value = value;
helper.setAttribute("readonly", "readonly");
helper.style.position = "absolute";
helper.style.left = "-9999px";
document.body.appendChild(helper);
helper.select();
document.execCommand("copy");
document.body.removeChild(helper);
}
function bindDropzone() {
const dropzone = byId("markdown-dropzone");
const input = byId("markdown-file");
if (!dropzone || !input || dropzone.dataset.bound === "true") {
return;
}
const activeClasses = ["border-foreground/35", "bg-muted/60"];
dropzone.dataset.bound = "true";
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
activeClasses.forEach((className) => dropzone.classList.add(className));
});
dropzone.addEventListener("dragleave", () => {
activeClasses.forEach((className) => dropzone.classList.remove(className));
});
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
activeClasses.forEach((className) => dropzone.classList.remove(className));
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
input.files = event.dataTransfer.files;
window.mdToHTMLHandleFileChange(input);
});
}
window.mdToHTMLSetSource = function(source) {
const sourceField = byId("source-field");
const filePanel = byId("panel-file");
const textPanel = byId("panel-text");
const fileTab = byId("tab-file");
const textTab = byId("tab-text");
if (!sourceField || !filePanel || !textPanel || !fileTab || !textTab) {
return;
}
const showFile = source === "file";
sourceField.value = source;
filePanel.classList.toggle("hidden", !showFile);
filePanel.classList.toggle("flex", showFile);
textPanel.classList.toggle("hidden", showFile);
textPanel.classList.toggle("flex", !showFile);
fileTab.setAttribute("data-state", showFile ? "active" : "inactive");
textTab.setAttribute("data-state", showFile ? "inactive" : "active");
};
window.mdToHTMLHandleFileChange = function(input) {
const file = input && input.files && input.files[0];
const summary = byId("selected-file");
const fileName = byId("selected-file-name");
const fileMeta = byId("selected-file-meta");
if (!summary || !fileName || !fileMeta) {
return;
}
if (!file) {
summary.classList.add("hidden");
summary.classList.remove("flex");
return;
}
fileName.textContent = file.name;
fileMeta.textContent = formatBytes(file.size) + " · " + formatRelativeTime(file.lastModified);
summary.classList.remove("hidden");
summary.classList.add("flex");
};
window.mdToHTMLClearFile = function() {
const input = byId("markdown-file");
const summary = byId("selected-file");
if (input) {
input.value = "";
}
if (summary) {
summary.classList.add("hidden");
summary.classList.remove("flex");
}
};
window.mdToHTMLUpdateCharCount = function(textarea) {
const counter = byId("markdown-char-count");
if (!counter || !textarea) {
return;
}
const count = textarea.value.length;
counter.textContent = count + " символов";
};
window.mdToHTMLResetForm = function() {
window.mdToHTMLSetSource("file");
window.mdToHTMLClearFile();
const textarea = byId("markdown-text");
if (textarea) {
window.mdToHTMLUpdateCharCount(textarea);
}
const result = byId("result");
if (result) {
result.innerHTML = "";
}
};
window.mdToHTMLCopyButton = async function(button) {
if (!button) {
return;
}
const targetID = button.dataset.copyTarget;
const target = targetID ? byId(targetID) : null;
const value = target ? target.value || target.textContent || "" : "";
if (!value) {
return;
}
try {
await copyText(value);
flashCopyState(button);
} catch (_) {
// noop
}
};
function init() {
bindDropzone();
window.mdToHTMLSetSource("file");
const textarea = byId("markdown-text");
if (textarea) {
window.mdToHTMLUpdateCharCount(textarea);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();
</script>
}
}
File diff suppressed because one or more lines are too long
+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)
}
}
+75
View File
@@ -0,0 +1,75 @@
package ui
templ UploadIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
}
templ FileIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<path d="M14 2v6h6"></path>
</svg>
}
templ AlignLeftIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M4 12h10"></path>
<path d="M4 17h16"></path>
</svg>
}
templ ArrowRightIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
}
templ InfoIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
}
templ CloseIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
}
templ CheckIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
}
templ DownloadIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
}
templ CopyIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
}
templ ExternalLinkIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
}
+481
View File
@@ -0,0 +1,481 @@
// 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"
func UploadIcon(class 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)
var templ_7745c5c3_Var2 = []any{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, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, 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/icons.templ`, Line: 1, Col: 0}
}
_, 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, 2, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"17 8 12 3 7 8\"></polyline> <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func FileIcon(class 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_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var5 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg 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_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.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, 4, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path> <path d=\"M14 2v6h6\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AlignLeftIcon(class 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_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var8 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg 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_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.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, 6, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 7h16\"></path> <path d=\"M4 12h10\"></path> <path d=\"M4 17h16\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ArrowRightIcon(class 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_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var11 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg 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_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.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, 8, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 12h14\"></path> <path d=\"m12 5 7 7-7 7\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func InfoIcon(class 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_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var14 = []any{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, 9, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, 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/icons.templ`, Line: 1, Col: 0}
}
_, 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, 10, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M12 16v-4\"></path> <path d=\"M12 8h.01\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CloseIcon(class 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_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var17 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CheckIcon(class 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_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var20 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var20).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 6 9 17l-5-5\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func DownloadIcon(class 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_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var23 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg 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_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.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, 16, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"7 10 12 15 17 10\"></polyline> <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CopyIcon(class 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_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var26 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var26).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"></rect> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ExternalLinkIcon(class 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_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var29 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var29).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path> <polyline points=\"15 3 21 3 21 9\"></polyline> <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+23
View File
@@ -0,0 +1,23 @@
package ui
templ Layout(title string) {
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title } · md-to-html</title>
<link rel="stylesheet" href="/static/dist/app.css"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
</head>
<body class="min-h-screen bg-[#fafafa] font-sans text-foreground antialiased">
{ children... }
</body>
</html>
}
+61
View File
@@ -0,0 +1,61 @@
// 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"
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\"><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: 9, 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, " · md-to-html</title><link rel=\"stylesheet\" href=\"/static/dist/app.css\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap\" rel=\"stylesheet\"><script src=\"https://unpkg.com/htmx.org@2.0.3\"></script></head><body class=\"min-h-screen bg-[#fafafa] font-sans text-foreground antialiased\">")
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, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+71
View File
@@ -0,0 +1,71 @@
package ui
import "fmt"
templ Result(previewID, downloadID, fullHTML, filename string, sizeBytes int, lineCount int, elapsedMs int) {
<div id="result" class="mt-6">
<section class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
<div class="flex items-center gap-3 border-b border-border px-5 py-4">
<div class="grid size-8 shrink-0 place-items-center rounded-md bg-emerald-50 text-emerald-600">
@CheckIcon("size-4")
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">Готово — { filename }</div>
<div class="text-xs text-muted-foreground font-mono">
{ formatResultMeta(sizeBytes, lineCount, elapsedMs) }
</div>
</div>
<span class="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground font-mono">
standalone
</span>
</div>
<textarea id={ "result-html-" + previewID } class="sr-only" readonly>{ fullHTML }</textarea>
<a href={ "/preview/" + previewID } target="_blank" rel="noreferrer" class="sr-only">Открыть превью</a>
<iframe class="hidden" sandbox="" referrerpolicy="no-referrer" srcdoc={ fullHTML }></iframe>
<div class="flex flex-wrap items-center gap-2 px-5 py-5">
<a
href={ "/download/" + downloadID }
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
@DownloadIcon("size-4")
<span>Скачать HTML</span>
</a>
<button
type="button"
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
data-copy-target={ "result-html-" + previewID }
onclick="window.mdToHTMLCopyButton(this)"
>
@CopyIcon("size-4")
<span>Скопировать</span>
</button>
<a
href={ "/preview/" + previewID }
target="_blank"
rel="noreferrer"
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
>
@ExternalLinkIcon("size-4")
<span>Открыть в новой вкладке</span>
</a>
</div>
</section>
</div>
}
templ Error(msg string) {
<div id="result" class="mt-6">
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 shadow-xs">
{ msg }
</div>
</div>
}
func formatResultMeta(sizeBytes int, lineCount int, elapsedMs int) string {
kilobytes := float64(sizeBytes) / 1024
seconds := float64(elapsedMs) / 1000
if seconds < 0.1 {
seconds = 0.1
}
return fmt.Sprintf("%.1f KB · %d строки · сгенерирован %.1f сек назад", kilobytes, lineCount, seconds)
}
+242
View File
@@ -0,0 +1,242 @@
// 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 "fmt"
func Result(previewID, downloadID, fullHTML, filename string, sizeBytes int, lineCount int, elapsedMs int) 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, "<div id=\"result\" class=\"mt-6\"><section class=\"overflow-hidden rounded-xl border border-border bg-background shadow-xs\"><div class=\"flex items-center gap-3 border-b border-border px-5 py-4\"><div class=\"grid size-8 shrink-0 place-items-center rounded-md bg-emerald-50 text-emerald-600\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = CheckIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"min-w-0 flex-1\"><div class=\"truncate text-sm font-medium text-foreground\">Готово — ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 13, Col: 90}
}
_, 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, 3, "</div><div class=\"text-xs text-muted-foreground font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(formatResultMeta(sizeBytes, lineCount, elapsedMs))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 15, Col: 57}
}
_, 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, "</div></div><span class=\"inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground font-mono\">standalone</span></div><textarea id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("result-html-" + previewID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 22, Col: 44}
}
_, 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, "\" class=\"sr-only\" readonly>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 22, Col: 82}
}
_, 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, 6, "</textarea> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs("/preview/" + previewID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 23, Col: 36}
}
_, 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, 7, "\" target=\"_blank\" rel=\"noreferrer\" class=\"sr-only\">Открыть превью</a> <iframe class=\"hidden\" 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: 24, Col: 83}
}
_, 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, 8, "\"></iframe><div class=\"flex flex-wrap items-center gap-2 px-5 py-5\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + downloadID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 27, Col: 37}
}
_, 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, 9, "\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = DownloadIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span>Скачать HTML</span></a> <button type=\"button\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted\" data-copy-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("result-html-" + previewID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 36, Col: 50}
}
_, 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, 11, "\" onclick=\"window.mdToHTMLCopyButton(this)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = CopyIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span>Скопировать</span></button> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs("/preview/" + previewID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 43, Col: 35}
}
_, 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, 13, "\" target=\"_blank\" rel=\"noreferrer\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ExternalLinkIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Открыть в новой вкладке</span></a></div></section></div>")
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_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div id=\"result\" class=\"mt-6\"><div class=\"rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 shadow-xs\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 59, Col: 8}
}
_, 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, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func formatResultMeta(sizeBytes int, lineCount int, elapsedMs int) string {
kilobytes := float64(sizeBytes) / 1024
seconds := float64(elapsedMs) / 1000
if seconds < 0.1 {
seconds = 0.1
}
return fmt.Sprintf("%.1f KB · %d строки · сгенерирован %.1f сек назад", kilobytes, lineCount, seconds)
}
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"
}
}

Some files were not shown because too many files have changed in this diff Show More