Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ca05692b | |||
| 13ce2a5b4f | |||
| 5a6f278d8a | |||
| 4b55661aa4 | |||
| 08d12feaa9 | |||
| 2894cf222b | |||
| 6aa19fe12a | |||
| 3b947e278c | |||
| ea47b446d4 | |||
| d6aef5560a | |||
| ac826e8b5e | |||
| c2298ac1bd | |||
| 843d8dc710 | |||
| d1682813ff | |||
| 5674177943 | |||
| 8deba3627f | |||
| cab04768b5 | |||
| 621158ae54 | |||
| 6b8d588c43 | |||
| f36e9f003f | |||
| 17debf2aca | |||
| 425eae7170 | |||
| 771169f93f | |||
| cbb281d14c |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,79 @@
|
||||
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 }}
|
||||
|
||||
- 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: |
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository }}: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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -4,6 +4,28 @@ 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.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
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
VERSION := $(shell cat VERSION)
|
||||
LDFLAGS := -X github.com/fserg/md-to-html/internal/version.Version=$(VERSION)
|
||||
GOBIN := $(shell go env GOPATH)/bin
|
||||
TEMPL := $(GOBIN)/templ
|
||||
|
||||
.PHONY: build run test templ tailwind dev docker clean tools
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/md-to-html ./cmd/md-to-html
|
||||
|
||||
run:
|
||||
go run ./cmd/md-to-html serve
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
templ:
|
||||
$(TEMPL) generate ./...
|
||||
|
||||
tailwind:
|
||||
mkdir -p web/static/dist
|
||||
npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --minify
|
||||
|
||||
dev:
|
||||
mkdir -p web/static/dist
|
||||
sh -c 'npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --watch & \
|
||||
TAILWIND_PID=$$!; \
|
||||
trap "kill $$TAILWIND_PID" EXIT INT TERM; \
|
||||
$(TEMPL) generate --watch --proxy=http://localhost:8080 --cmd="go run ./cmd/md-to-html serve"'
|
||||
|
||||
docker:
|
||||
@echo "docker target will be implemented in phase 6"
|
||||
|
||||
clean:
|
||||
rm -rf bin/ tmp/ web/static/dist/
|
||||
|
||||
tools:
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
go install github.com/templui/templui/cmd/templui@latest
|
||||
@@ -1,81 +1,90 @@
|
||||
# md-to-html
|
||||
|
||||
Сервис конвертации Markdown в самодостаточный HTML (через GitHub API).
|
||||
Сервис конвертации Markdown в самодостаточный HTML. Полностью офлайн, без обращений к внешним API.
|
||||
|
||||
Текущая версия: `0.1.2`
|
||||
Текущая версия: `0.2.0` (Go + goldmark + templUI)
|
||||
|
||||
Часто нужен адекватно (минималистично) выглядящий HTML из Markdown. HTML получем через открытый API GitHub, а стили просто захардкожены в шаблоне.
|
||||
## Возможности
|
||||
|
||||

|
||||
- GFM + footnote + emoji + подсветка кода через chroma.
|
||||
- Якоря в заголовках с ASCII-транслитом: `## Установка` → `#ustanovka`.
|
||||
- CLI: `md-to-html cli file.md`.
|
||||
- HTTP API: `POST /convert` совместим с `v0.1.x`.
|
||||
- Web UI на `http://localhost:8080/` с inline-preview в sandbox iframe и одноразовыми ссылками на preview/download.
|
||||
|
||||
GITHUB_TOKEN не нужен, если не требуется массовая (поточная) конвертация. Но если нужно, то его можно передать через переменную окружения при запуске.
|
||||
|
||||
Есть два интерфейса:
|
||||
|
||||
- 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 сохранился:
|
||||
## Локальная разработка
|
||||
|
||||
Требования: Go 1.23+, `templ` CLI, Node.js для dev-режима Tailwind или standalone `tailwindcss`.
|
||||
|
||||
```bash
|
||||
python3 md_to_html.py /path/to/file.md
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
make tailwind
|
||||
make build
|
||||
./bin/md-to-html serve
|
||||
```
|
||||
|
||||
## Docker
|
||||
Для live-reload:
|
||||
|
||||
```bash
|
||||
docker build -t md-to-html .
|
||||
docker run --rm -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=your_token md-to-html
|
||||
make dev
|
||||
```
|
||||
|
||||
## API
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.claude/
|
||||
.agents/
|
||||
.review-sandboxes/
|
||||
md/*.html
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
Архивная Python-реализация md-to-html v0.1.2. Для истории.
|
||||
Vendored
BIN
Binary file not shown.
@@ -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 для автоматической сборки и публикации докер образа при каждом релизе.
|
||||
|
||||
|
||||
@@ -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 при нагрузке.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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) | 🔄 in_progress | 2026-04-18 | — | — | |
|
||||
|
||||
Легенда статусов:
|
||||
- ⏳ `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 |
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
+297
@@ -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
@@ -0,0 +1 @@
|
||||
Contact <dev@example.test> for details.
|
||||
+298
@@ -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>
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Basic Example
|
||||
|
||||
Simple paragraph with **bold**, *italic*, and [docs](/docs).
|
||||
+297
@@ -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 🚀 today.</p>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Ready to launch :rocket: today.
|
||||
@@ -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">"fmt"</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">"hello"</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
@@ -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. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Footnote text.[^1]
|
||||
|
||||
[^1]: Extra details.
|
||||
+297
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
## <dev@example.test>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
## Install
|
||||
|
||||
## Install
|
||||
|
||||
## Setup
|
||||
|
||||
## Сетап
|
||||
+299
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
# Привет
|
||||
|
||||
## Установка
|
||||
|
||||
### Быстрый старт
|
||||
+297
@@ -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>🚀 Launch</h2>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
## :rocket: Launch
|
||||
+297
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
##  Title
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
## [API](/api)
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
## Using `go fmt`
|
||||
+297
@@ -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>
|
||||
<script>alert(1)</script>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<script>alert(1)</script>
|
||||
+297
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
Use ~~old~~ new output.
|
||||
+314
@@ -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
@@ -0,0 +1,4 @@
|
||||
| Name | Value |
|
||||
| --- | --- |
|
||||
| Alpha | 1 |
|
||||
| Beta | 2 |
|
||||
+300
@@ -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
@@ -0,0 +1,2 @@
|
||||
- [x] Ship phase 2
|
||||
- [ ] Review output
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fserg/md-to-html/internal/converter"
|
||||
"github.com/fserg/md-to-html/internal/ui"
|
||||
"github.com/fserg/md-to-html/internal/version"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const defaultDocumentTitle = "Document"
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
conv *converter.Converter
|
||||
store *PreviewStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
type convertRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) {
|
||||
if !hasJSONContentType(r.Header.Get("Content-Type")) {
|
||||
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
|
||||
"detail": "content-type must be application/json",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var payload convertRequest
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
s.writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.convertMarkdown(payload.Markdown, payload.Title)
|
||||
if err != nil {
|
||||
s.writeConvertError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(result.HTML)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"version": version.Version})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"template_loaded": s.conv != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = ui.Home().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxRequestBytes)
|
||||
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, "Слишком большой файл или ошибка формы")
|
||||
return
|
||||
}
|
||||
|
||||
md, filename, err := s.readUIMarkdownPayload(r)
|
||||
if err != nil {
|
||||
s.renderUIReadError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.conv.Convert(md, defaultDocumentTitle)
|
||||
if err != nil {
|
||||
s.log.Error("ui_convert_failed", "error", err)
|
||||
s.renderUIError(w, r, http.StatusBadGateway, "Ошибка конвертации: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = ui.Result(previewID, downloadID, string(result.HTML), filename).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, ok := s.store.Take(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(item.html)
|
||||
}
|
||||
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, ok := s.store.Take(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
|
||||
"filename": item.filename,
|
||||
}))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(item.html)
|
||||
}
|
||||
|
||||
func (s *Server) convertMarkdown(markdown, title string) (converter.Result, error) {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return converter.Result{}, errEmptyMarkdown
|
||||
}
|
||||
|
||||
if int64(len([]byte(markdown))) > s.cfg.MaxMarkdownBytes {
|
||||
return converter.Result{}, errMarkdownTooLarge{limit: s.cfg.MaxMarkdownBytes}
|
||||
}
|
||||
|
||||
fallbackTitle := strings.TrimSpace(title)
|
||||
if fallbackTitle == "" {
|
||||
fallbackTitle = defaultDocumentTitle
|
||||
}
|
||||
|
||||
result, err := s.conv.Convert([]byte(markdown), fallbackTitle)
|
||||
if err != nil {
|
||||
return converter.Result{}, fmt.Errorf("convert markdown: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) writeDecodeError(w http.ResponseWriter, err error) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"detail": fmt.Sprintf("request exceeds %d bytes", s.cfg.MaxRequestBytes),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": "invalid request payload"})
|
||||
}
|
||||
|
||||
func (s *Server) writeConvertError(w http.ResponseWriter, err error) {
|
||||
var markdownTooLarge errMarkdownTooLarge
|
||||
switch {
|
||||
case errors.Is(err, errEmptyMarkdown):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": err.Error()})
|
||||
case errors.As(err, &markdownTooLarge):
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"detail": markdownTooLarge.Error(),
|
||||
})
|
||||
default:
|
||||
s.log.Error("convert_failed", "error", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"detail": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func hasJSONContentType(value string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(value)
|
||||
return err == nil && mediaType == "application/json"
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var extra json.RawMessage
|
||||
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(extra) > 0 {
|
||||
return errors.New("unexpected trailing JSON data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(payload)
|
||||
}
|
||||
|
||||
func htmlFilename(title string) string {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer("/", "-", "\\", "-", "\"", "", "\n", " ", "\r", " ")
|
||||
name = strings.TrimSpace(replacer.Replace(name))
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
|
||||
return name + ".html"
|
||||
}
|
||||
|
||||
func contentTypeOrDefault(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) renderUIError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = ui.Error(msg).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) renderUIReadError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var markdownTooLarge errMarkdownTooLarge
|
||||
|
||||
switch {
|
||||
case errors.Is(err, errEmptyMarkdown):
|
||||
s.renderUIError(w, r, http.StatusBadRequest, "Пустой markdown")
|
||||
case errors.As(err, &markdownTooLarge):
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, fmt.Sprintf("Markdown больше %d байт", s.cfg.MaxMarkdownBytes))
|
||||
default:
|
||||
s.renderUIError(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) readUIMarkdownPayload(r *http.Request) ([]byte, string, error) {
|
||||
switch r.FormValue("source") {
|
||||
case "", "file":
|
||||
file, header, err := r.FormFile("markdown_file")
|
||||
if err != nil {
|
||||
return nil, "", errors.New("Файл не загружен")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
markdown, err := io.ReadAll(io.LimitReader(file, s.cfg.MaxMarkdownBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("не удалось прочитать файл: %w", err)
|
||||
}
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
return markdown, htmlFilename(name), nil
|
||||
case "text":
|
||||
markdown := []byte(r.FormValue("markdown_text"))
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return markdown, "document.html", nil
|
||||
default:
|
||||
return nil, "", errors.New("Неизвестный источник markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdown(markdown []byte, limit int64) error {
|
||||
if int64(len(markdown)) > limit {
|
||||
return errMarkdownTooLarge{limit: limit}
|
||||
}
|
||||
if len(bytes.TrimSpace(markdown)) == 0 {
|
||||
return errEmptyMarkdown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errEmptyMarkdown = errors.New("markdown must not be empty")
|
||||
|
||||
type errMarkdownTooLarge struct {
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (e errMarkdownTooLarge) Error() string {
|
||||
return fmt.Sprintf("markdown exceeds %d bytes", e.limit)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,158 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/fserg/md-to-html/internal/ui/components/button"
|
||||
"github.com/fserg/md-to-html/internal/ui/components/card"
|
||||
)
|
||||
|
||||
templ Home() {
|
||||
@Layout("Markdown → HTML") {
|
||||
<div class="panel-grid">
|
||||
<section class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="eyebrow">
|
||||
<span>Go migration</span>
|
||||
<span>goldmark + templUI</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h1 class="max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-foreground sm:text-5xl">
|
||||
Markdown → HTML без внешних зависимостей в результирующем документе.
|
||||
</h1>
|
||||
<p class="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
|
||||
Загрузите `.md`-файл или вставьте текст вручную. Сервис отдаст автономный HTML, одноразовое превью и отдельную ссылку на скачивание.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="section-card p-4">
|
||||
<div class="text-sm font-semibold text-foreground">Самодостаточный HTML</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">Результат открывается локально без CDN и без сетевых вызовов.</p>
|
||||
</div>
|
||||
<div class="section-card p-4">
|
||||
<div class="text-sm font-semibold text-foreground">Одноразовые ссылки</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">Preview и download живут до первого открытия или максимум один час.</p>
|
||||
</div>
|
||||
<div class="section-card p-4">
|
||||
<div class="text-sm font-semibold text-foreground">Русский интерфейс</div>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">Форма ориентирована на быстрый ручной прогон документации и заметок.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
@card.Card(card.Props{Class: "section-card overflow-hidden"}) {
|
||||
@card.Header(card.HeaderProps{Class: "space-y-2 border-b border-border/70 pb-6"}) {
|
||||
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">Конвертация</div>
|
||||
@card.Title(card.TitleProps{Class: "text-2xl font-semibold tracking-tight text-foreground"}) {
|
||||
Выберите источник Markdown
|
||||
}
|
||||
@card.Description(card.DescriptionProps{Class: "max-w-xl text-sm leading-6 text-muted-foreground"}) {
|
||||
Форма отправляется через HTMX на `POST /ui/convert`, а результат подменяется прямо в блоке ниже.
|
||||
}
|
||||
}
|
||||
@card.Content(card.ContentProps{Class: "space-y-5"}) {
|
||||
<form
|
||||
id="convert-form"
|
||||
hx-post="/ui/convert"
|
||||
hx-target="#result"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="field-label">Источник</div>
|
||||
<div class="grid grid-cols-2 gap-2 rounded-[1.35rem] border border-border/80 bg-muted/55 p-2">
|
||||
<label
|
||||
class="source-tab source-tab-active"
|
||||
data-source-tab="file"
|
||||
data-active-classes="source-tab source-tab-active"
|
||||
data-inactive-classes="source-tab"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="source"
|
||||
value="file"
|
||||
class="sr-only"
|
||||
checked
|
||||
onchange="window.mdToHTMLSwitchSource(this.value)"
|
||||
/>
|
||||
Файл
|
||||
</label>
|
||||
<label
|
||||
class="source-tab"
|
||||
data-source-tab="text"
|
||||
data-active-classes="source-tab source-tab-active"
|
||||
data-inactive-classes="source-tab"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="source"
|
||||
value="text"
|
||||
class="sr-only"
|
||||
onchange="window.mdToHTMLSwitchSource(this.value)"
|
||||
/>
|
||||
Текст
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="source-file" class="source-panel space-y-3">
|
||||
<label class="field-label" for="markdown-file">Markdown-файл</label>
|
||||
<input
|
||||
id="markdown-file"
|
||||
class="surface-input file:mr-4 file:rounded-xl file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
|
||||
type="file"
|
||||
name="markdown_file"
|
||||
accept=".md,.markdown,.mdown,text/markdown"
|
||||
/>
|
||||
<p class="field-hint">Используйте для загрузки существующего документа. Имя файла станет базой для имени HTML.</p>
|
||||
</div>
|
||||
<div id="source-text" class="source-panel hidden space-y-3">
|
||||
<label class="field-label" for="markdown-text">Markdown-текст</label>
|
||||
<textarea
|
||||
id="markdown-text"
|
||||
class="surface-textarea"
|
||||
name="markdown_text"
|
||||
rows="14"
|
||||
placeholder="# Привет, мир - списки - таблицы - код"
|
||||
></textarea>
|
||||
<p class="field-hint">Подходит для быстрых заметок и вставок без промежуточного файла.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
Class: "rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
|
||||
Variant: button.VariantDefault,
|
||||
Size: button.SizeDefault,
|
||||
}) {
|
||||
<span>Конвертировать</span>
|
||||
}
|
||||
<span class="field-hint">Лимиты тела запроса и markdown берутся из server config.</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result" class="min-h-[4rem]"></div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
window.mdToHTMLSwitchSource = function(value) {
|
||||
const filePanel = document.getElementById("source-file");
|
||||
const textPanel = document.getElementById("source-text");
|
||||
if (!filePanel || !textPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showFile = value === "file";
|
||||
filePanel.classList.toggle("hidden", !showFile);
|
||||
textPanel.classList.toggle("hidden", showFile);
|
||||
|
||||
document.querySelectorAll("[data-source-tab]").forEach((tab) => {
|
||||
const tabValue = tab.getAttribute("data-source-tab");
|
||||
const active = tabValue === value;
|
||||
tab.className = active
|
||||
? tab.getAttribute("data-active-classes")
|
||||
: tab.getAttribute("data-inactive-classes");
|
||||
});
|
||||
};
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/fserg/md-to-html/internal/ui/components/button"
|
||||
"github.com/fserg/md-to-html/internal/ui/components/card"
|
||||
)
|
||||
|
||||
func Home() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"panel-grid\"><section class=\"space-y-6\"><div class=\"space-y-4\"><div class=\"eyebrow\"><span>Go migration</span> <span>goldmark + templUI</span></div><div class=\"space-y-3\"><h1 class=\"max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-foreground sm:text-5xl\">Markdown → HTML без внешних зависимостей в результирующем документе.</h1><p class=\"max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg\">Загрузите `.md`-файл или вставьте текст вручную. Сервис отдаст автономный HTML, одноразовое превью и отдельную ссылку на скачивание.</p></div></div><div class=\"grid gap-4 sm:grid-cols-3\"><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Самодостаточный HTML</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Результат открывается локально без CDN и без сетевых вызовов.</p></div><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Одноразовые ссылки</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Preview и download живут до первого открытия или максимум один час.</p></div><div class=\"section-card p-4\"><div class=\"text-sm font-semibold text-foreground\">Русский интерфейс</div><p class=\"mt-2 text-sm leading-6 text-muted-foreground\">Форма ориентирована на быстрый ручной прогон документации и заметок.</p></div></div></section><section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground\">Конвертация</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Выберите источник Markdown")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Title(card.TitleProps{Class: "text-2xl font-semibold tracking-tight text-foreground"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Форма отправляется через HTMX на `POST /ui/convert`, а результат подменяется прямо в блоке ниже.")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Description(card.DescriptionProps{Class: "max-w-xl text-sm leading-6 text-muted-foreground"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Header(card.HeaderProps{Class: "space-y-2 border-b border-border/70 pb-6"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form id=\"convert-form\" hx-post=\"/ui/convert\" hx-target=\"#result\" hx-swap=\"innerHTML\" hx-encoding=\"multipart/form-data\" class=\"space-y-5\"><div class=\"space-y-2\"><div class=\"field-label\">Источник</div><div class=\"grid grid-cols-2 gap-2 rounded-[1.35rem] border border-border/80 bg-muted/55 p-2\"><label class=\"source-tab source-tab-active\" data-source-tab=\"file\" data-active-classes=\"source-tab source-tab-active\" data-inactive-classes=\"source-tab\"><input type=\"radio\" name=\"source\" value=\"file\" class=\"sr-only\" checked onchange=\"window.mdToHTMLSwitchSource(this.value)\"> Файл</label> <label class=\"source-tab\" data-source-tab=\"text\" data-active-classes=\"source-tab source-tab-active\" data-inactive-classes=\"source-tab\"><input type=\"radio\" name=\"source\" value=\"text\" class=\"sr-only\" onchange=\"window.mdToHTMLSwitchSource(this.value)\"> Текст</label></div></div><div id=\"source-file\" class=\"source-panel space-y-3\"><label class=\"field-label\" for=\"markdown-file\">Markdown-файл</label> <input id=\"markdown-file\" class=\"surface-input file:mr-4 file:rounded-xl file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90\" type=\"file\" name=\"markdown_file\" accept=\".md,.markdown,.mdown,text/markdown\"><p class=\"field-hint\">Используйте для загрузки существующего документа. Имя файла станет базой для имени HTML.</p></div><div id=\"source-text\" class=\"source-panel hidden space-y-3\"><label class=\"field-label\" for=\"markdown-text\">Markdown-текст</label> <textarea id=\"markdown-text\" class=\"surface-textarea\" name=\"markdown_text\" rows=\"14\" placeholder=\"# Привет, мир - списки - таблицы - код\"></textarea><p class=\"field-hint\">Подходит для быстрых заметок и вставок без промежуточного файла.</p></div><div class=\"flex flex-wrap items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Конвертировать</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
Class: "rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
|
||||
Variant: button.VariantDefault,
|
||||
Size: button.SizeDefault,
|
||||
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"field-hint\">Лимиты тела запроса и markdown берутся из server config.</span></div></form><div id=\"result\" class=\"min-h-[4rem]\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Content(card.ContentProps{Class: "space-y-5"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Card(card.Props{Class: "section-card overflow-hidden"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</section></div><script>\n\t\t\twindow.mdToHTMLSwitchSource = function(value) {\n\t\t\t\tconst filePanel = document.getElementById(\"source-file\");\n\t\t\t\tconst textPanel = document.getElementById(\"source-text\");\n\t\t\t\tif (!filePanel || !textPanel) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst showFile = value === \"file\";\n\t\t\t\tfilePanel.classList.toggle(\"hidden\", !showFile);\n\t\t\t\ttextPanel.classList.toggle(\"hidden\", showFile);\n\n\t\t\t\tdocument.querySelectorAll(\"[data-source-tab]\").forEach((tab) => {\n\t\t\t\t\tconst tabValue = tab.getAttribute(\"data-source-tab\");\n\t\t\t\t\tconst active = tabValue === value;\n\t\t\t\t\ttab.className = active\n\t\t\t\t\t\t? tab.getAttribute(\"data-active-classes\")\n\t\t\t\t\t\t: tab.getAttribute(\"data-inactive-classes\");\n\t\t\t\t});\n\t\t\t};\n\t\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = Layout("Markdown → HTML").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package ui
|
||||
|
||||
import "github.com/fserg/md-to-html/internal/version"
|
||||
|
||||
templ Layout(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<div class="hero-panel">
|
||||
<div class="relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10">
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
<footer class="mt-6 text-center text-sm text-muted-foreground">
|
||||
Markdown → HTML · v{ version.Version }
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "github.com/fserg/md-to-html/internal/version"
|
||||
|
||||
func Layout(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"ru\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 11, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/dist/app.css\"><script src=\"/static/htmx.min.js\"></script></head><body><div class=\"app-shell\"><div class=\"hero-panel\"><div class=\"relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><footer class=\"mt-6 text-center text-sm text-muted-foreground\">Markdown → HTML · v")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 23, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</footer></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -0,0 +1,56 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/fserg/md-to-html/internal/ui/components/button"
|
||||
"github.com/fserg/md-to-html/internal/ui/components/card"
|
||||
)
|
||||
|
||||
templ Result(previewID, downloadID string, fullHTML string, filename string) {
|
||||
@card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}) {
|
||||
@card.Content(card.ContentProps{Class: "space-y-4"}) {
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@button.Button(button.Props{
|
||||
Href: "/preview/" + previewID,
|
||||
Target: "_blank",
|
||||
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
|
||||
Variant: button.VariantDefault,
|
||||
}) {
|
||||
Открыть превью
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Href: "/download/" + downloadID,
|
||||
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
|
||||
Variant: button.VariantOutline,
|
||||
}) {
|
||||
Скачать HTML
|
||||
}
|
||||
<span class="text-sm text-muted-foreground">Файл: <span class="font-medium text-foreground">{ filename }</span></span>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-muted-foreground">
|
||||
Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.
|
||||
</p>
|
||||
<details class="group overflow-hidden rounded-[1.25rem] border border-border bg-card/80">
|
||||
<summary class="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">i</span>
|
||||
Inline-превью в изолированном iframe
|
||||
</span>
|
||||
</summary>
|
||||
<div class="border-t border-border/70 px-4 pb-4 pt-3">
|
||||
<iframe
|
||||
class="result-frame"
|
||||
sandbox=""
|
||||
referrerpolicy="no-referrer"
|
||||
srcdoc={ fullHTML }
|
||||
></iframe>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ Error(msg string) {
|
||||
<div class="rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{ msg }
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/fserg/md-to-html/internal/ui/components/button"
|
||||
"github.com/fserg/md-to-html/internal/ui/components/card"
|
||||
)
|
||||
|
||||
func Result(previewID, downloadID string, fullHTML string, filename string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex flex-wrap items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Открыть превью")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = button.Button(button.Props{
|
||||
Href: "/preview/" + previewID,
|
||||
Target: "_blank",
|
||||
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
|
||||
Variant: button.VariantDefault,
|
||||
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Скачать HTML")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = button.Button(button.Props{
|
||||
Href: "/download/" + downloadID,
|
||||
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
|
||||
Variant: button.VariantOutline,
|
||||
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-sm text-muted-foreground\">Файл: <span class=\"font-medium text-foreground\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 27, Col: 110}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></span></div><p class=\"text-sm leading-6 text-muted-foreground\">Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.</p><details class=\"group overflow-hidden rounded-[1.25rem] border border-border bg-card/80\"><summary class=\"cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground\"><span class=\"inline-flex items-center gap-2\"><span class=\"inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground\">i</span> Inline-превью в изолированном iframe</span></summary><div class=\"border-t border-border/70 px-4 pb-4 pt-3\"><iframe class=\"result-frame\" sandbox=\"\" referrerpolicy=\"no-referrer\" srcdoc=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 44, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></iframe></div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Content(card.ContentProps{Class: "space-y-4"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Error(msg string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 54, Col: 7}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package version
|
||||
|
||||
// Version устанавливается через -ldflags при сборке.
|
||||
var Version = "dev"
|
||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "md-to-html",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"tailwindcss": "3.4.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./internal/ui/**/*.templ",
|
||||
"./internal/ui/**/*.go",
|
||||
"./web/template/**/*.html",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "#f5efe2",
|
||||
foreground: "#221f1a",
|
||||
card: "#fffdf8",
|
||||
"card-foreground": "#221f1a",
|
||||
primary: "#b85c38",
|
||||
"primary-foreground": "#fffaf4",
|
||||
secondary: "#ead7b0",
|
||||
"secondary-foreground": "#3f3528",
|
||||
muted: "#efe4d2",
|
||||
"muted-foreground": "#6c6254",
|
||||
accent: "#d0b38a",
|
||||
"accent-foreground": "#2e2417",
|
||||
border: "#d8c6ab",
|
||||
ring: "#b85c38",
|
||||
input: "#fffaf4",
|
||||
destructive: "#b42318",
|
||||
},
|
||||
boxShadow: {
|
||||
xs: "0 1px 2px rgba(34, 31, 26, 0.08)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["IBM Plex Sans", "Avenir Next", "Segoe UI", "sans-serif"],
|
||||
mono: ["IBM Plex Mono", "SFMono-Regular", "monospace"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build tools
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
_ "github.com/a-h/templ"
|
||||
_ "github.com/alecthomas/chroma/v2"
|
||||
_ "github.com/go-chi/chi/v5"
|
||||
_ "github.com/google/uuid"
|
||||
_ "github.com/mozillazg/go-unidecode"
|
||||
_ "github.com/yuin/goldmark"
|
||||
_ "github.com/yuin/goldmark-emoji"
|
||||
_ "github.com/yuin/goldmark-highlighting/v2"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user