Compare commits
29 Commits
v0.1.2
...
5bb488ccd0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bb488ccd0 | |||
| 256d5c9e6d | |||
| a90519807c | |||
| 4cd85e3515 | |||
| 9531730283 | |||
| 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,85 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAILWIND_VERSION: v3.4.17
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install templ
|
||||
run: go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
|
||||
- name: Install tailwindcss standalone
|
||||
run: |
|
||||
curl -fsSL -o /usr/local/bin/tailwindcss \
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64"
|
||||
chmod +x /usr/local/bin/tailwindcss
|
||||
|
||||
- name: Build assets
|
||||
run: |
|
||||
mkdir -p web/static/dist
|
||||
templ generate ./...
|
||||
tailwindcss -c tailwind.config.js -i web/static/src/app.css -o web/static/dist/app.css --minify
|
||||
|
||||
- name: Cross-compile binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
for target in "linux amd64" "linux arm64" "darwin arm64"; do
|
||||
set -- $target
|
||||
GOOS=$1 GOARCH=$2 CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=$(cat VERSION)" \
|
||||
-o "dist/md-to-html-${1}-${2}" \
|
||||
./cmd/md-to-html
|
||||
done
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image name
|
||||
id: image
|
||||
run: |
|
||||
repo_lower=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "name=ghcr.io/${repo_lower}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ steps.image.outputs.name }}:${{ github.ref_name }}
|
||||
${{ steps.image.outputs.name }}:latest
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--title "${{ github.ref_name }}" \
|
||||
--notes-file CHANGELOG.md \
|
||||
dist/*
|
||||
+16
@@ -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,42 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog, and the project uses Semantic Versioning.
|
||||
|
||||
## [0.2.2] - 2026-04-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Web UI redesigned into a single-column classic layout with tabbed file/text input, updated result card, and API snippet.
|
||||
- Added a local release build script and Make targets for current-platform and cross-platform release artifacts.
|
||||
- README updated with the current build, release, and run instructions.
|
||||
|
||||
## [0.2.1] - 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- GitHub release workflow now lowercases the GHCR image name before publishing, which fixes releases for repositories with uppercase owner names.
|
||||
|
||||
## [0.2.0] - 2026-04-18
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING**: project fully rewritten in Go (goldmark + templUI); Python implementation moved to `archive/`.
|
||||
- **BREAKING**: heading anchors now use ASCII transliteration (`## Установка` → `id="ustanovka"`).
|
||||
- **BREAKING**: heading HTML markup simplified; `<div class="markdown-heading">` is no longer emitted.
|
||||
- Removed the GitHub Markdown API dependency; conversion now works fully offline.
|
||||
- Replaced the two-process runtime (uvicorn + Streamlit) with a single binary.
|
||||
- Preview and download links are now one-shot, UUID-backed, and expire after one hour.
|
||||
|
||||
### Added
|
||||
|
||||
- Syntax highlighting via chroma with inline styles for self-contained HTML output.
|
||||
- Footnote support in addition to baseline GFM features.
|
||||
- Cross-platform release binaries for `linux/amd64`, `linux/arm64`, and `darwin/arm64`.
|
||||
|
||||
### Removed
|
||||
|
||||
- `READY_CHECK_GITHUB` environment variable.
|
||||
- Streamlit UI on dedicated port `:8501`.
|
||||
|
||||
## [0.1.2] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
+53
-11
@@ -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,45 @@
|
||||
VERSION := $(shell cat VERSION)
|
||||
LDFLAGS := -X github.com/fserg/md-to-html/internal/version.Version=$(VERSION)
|
||||
GOBIN := $(shell go env GOPATH)/bin
|
||||
TEMPL := $(GOBIN)/templ
|
||||
|
||||
.PHONY: build run test templ tailwind dev docker clean tools release release-all
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/md-to-html ./cmd/md-to-html
|
||||
|
||||
run:
|
||||
go run ./cmd/md-to-html serve
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
templ:
|
||||
$(TEMPL) generate ./...
|
||||
|
||||
tailwind:
|
||||
mkdir -p web/static/dist
|
||||
npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --minify
|
||||
|
||||
dev:
|
||||
mkdir -p web/static/dist
|
||||
sh -c 'npx tailwindcss -i web/static/src/app.css -o web/static/dist/app.css --watch & \
|
||||
TAILWIND_PID=$$!; \
|
||||
trap "kill $$TAILWIND_PID" EXIT INT TERM; \
|
||||
$(TEMPL) generate --watch --proxy=http://localhost:8080 --cmd="go run ./cmd/md-to-html serve"'
|
||||
|
||||
docker:
|
||||
@echo "docker target will be implemented in phase 6"
|
||||
|
||||
release:
|
||||
./scripts/release-build.sh
|
||||
|
||||
release-all:
|
||||
./scripts/release-build.sh --all
|
||||
|
||||
clean:
|
||||
rm -rf bin/ tmp/ web/static/dist/
|
||||
|
||||
tools:
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
go install github.com/templui/templui/cmd/templui@latest
|
||||
@@ -1,81 +1,129 @@
|
||||
# md-to-html
|
||||
|
||||
Сервис конвертации Markdown в самодостаточный HTML (через GitHub API).
|
||||
Сервис конвертации Markdown в самодостаточный HTML. Конвертация выполняется локально, без внешних API.
|
||||
|
||||
Текущая версия: `0.1.2`
|
||||

|
||||
|
||||
Часто нужен адекватно (минималистично) выглядящий HTML из Markdown. HTML получем через открытый API GitHub, а стили просто захардкожены в шаблоне.
|
||||
Текущая версия: `0.2.2` (Go + goldmark + templUI)
|
||||
|
||||

|
||||
## Возможности
|
||||
|
||||
GITHUB_TOKEN не нужен, если не требуется массовая (поточная) конвертация. Но если нужно, то его можно передать через переменную окружения при запуске.
|
||||
- GFM + footnote + emoji + подсветка кода через chroma.
|
||||
- Web UI на `http://localhost:8080/` с загрузкой файла или вставкой текста, HTMX-обновлением результата и одноразовыми ссылками на preview/download.
|
||||
- CLI: `md-to-html cli file.md`.
|
||||
- HTTP API: `POST /convert`, совместим с `v0.1.x`.
|
||||
- Якоря в заголовках с ASCII-транслитом: `## Установка` → `#ustanovka`.
|
||||
|
||||
Есть два интерфейса:
|
||||
|
||||
- FastAPI на `http://localhost:8000`
|
||||
- Streamlit UI на `http://localhost:8501` с двумя режимами ввода: загрузка `.md` файла или вставка Markdown-текста из буфера обмена
|
||||
|
||||
## Локальный запуск
|
||||
## Запуск через Docker
|
||||
|
||||
```bash
|
||||
uv venv .venv
|
||||
source .venv/bin/activate
|
||||
uv pip install -r requirements.txt
|
||||
uvicorn app.api:app --reload
|
||||
streamlit run app/streamlit_app.py
|
||||
docker run --rm -p 8080:8080 ghcr.io/fserg/md-to-html:latest
|
||||
```
|
||||
|
||||
CLI сохранился:
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
python3 md_to_html.py /path/to/file.md
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
npm install
|
||||
make build
|
||||
./bin/md-to-html serve
|
||||
```
|
||||
|
||||
## Docker
|
||||
## Локальная разработка
|
||||
|
||||
Требования: Go 1.24+, Node.js, `templ` CLI.
|
||||
|
||||
```bash
|
||||
docker build -t md-to-html .
|
||||
docker run --rm -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=your_token md-to-html
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||
npm install
|
||||
make tailwind
|
||||
make build
|
||||
./bin/md-to-html serve
|
||||
```
|
||||
|
||||
## API
|
||||
Для live-reload:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
## Релизная сборка
|
||||
|
||||
Локальный release-билд для текущей платформы:
|
||||
|
||||
```bash
|
||||
make release
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
- генерирует `templ`-код
|
||||
- собирает Tailwind bundle
|
||||
- прогоняет `go test ./...`
|
||||
- собирает release-бинарь с версией из `VERSION`
|
||||
- кладёт артефакты в `dist/`
|
||||
|
||||
Проверка готового release-билда:
|
||||
|
||||
```bash
|
||||
./dist/md-to-html-$(go env GOOS)-$(go env GOARCH) serve
|
||||
```
|
||||
|
||||
Сборка всех release-таргетов как в CI:
|
||||
|
||||
```bash
|
||||
make release-all
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
md-to-html cli file.md
|
||||
md-to-html cli file.md -o out.html
|
||||
md-to-html cli --stdin < file.md
|
||||
md-to-html cli - --title "Заголовок"
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
|
||||
`POST /convert`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/convert \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"markdown":"# Hello"}'
|
||||
curl -X POST http://localhost:8080/convert \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"markdown":"# Привет"}'
|
||||
```
|
||||
|
||||
`GET /health`
|
||||
Прочие эндпоинты:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
- `GET /` — веб-интерфейс.
|
||||
- `GET /health`, `GET /version`, `GET /ready` — служебные эндпоинты.
|
||||
- `GET /preview/{id}`, `GET /download/{id}` — одноразовые ссылки из веб-формы.
|
||||
|
||||
`GET /version`
|
||||
## Env-переменные
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/version
|
||||
```
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|----------------------|--------------|------------|
|
||||
| `ADDR` | `:8080` | Адрес прослушивания |
|
||||
| `MAX_MARKDOWN_BYTES` | `1048576` | Лимит размера markdown |
|
||||
| `MAX_REQUEST_BYTES` | `1200000` | Лимит размера HTTP-запроса |
|
||||
| `PREVIEW_TTL` | `1h` | TTL одноразовых ссылок |
|
||||
|
||||
## Миграция с v0.1.x
|
||||
|
||||
- API-контракт `POST /convert` не изменился, существующие клиенты продолжают работать.
|
||||
- Якоря заголовков теперь используют ASCII-транслит. Ссылки вида `#установка` нужно заменить на `#ustanovka`.
|
||||
- HTML-разметка упрощена: больше нет `<div class="markdown-heading">`, поэтому ручные CSS-оверрайды нужно пересмотреть.
|
||||
- Переменная окружения `READY_CHECK_GITHUB` удалена: сервис больше не зависит от внешнего Markdown API.
|
||||
- UI работает на том же порту `8080`, отдельный UI-порт `:8501` больше не нужен.
|
||||
|
||||
Python-реализация сохранена в `archive/`.
|
||||
|
||||
## Релизы
|
||||
|
||||
Проект использует Semantic Versioning. Текущая версия хранится в файле `VERSION`, история изменений ведётся в `CHANGELOG.md`.
|
||||
|
||||
Чтобы выпустить релиз:
|
||||
|
||||
```bash
|
||||
git add VERSION CHANGELOG.md
|
||||
git commit -m "Release v0.1.2"
|
||||
git tag v0.1.2
|
||||
git commit -am "Release vX.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
git push origin main --tags
|
||||
gh release create v0.1.2 --notes-file CHANGELOG.md
|
||||
```
|
||||
|
||||
После публикации релиза GitHub Actions автоматически собирает Docker-образ и публикует его в GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/fserg/md-to-html:v0.1.2
|
||||
```
|
||||
GitHub Actions публикует Docker-образ для `linux/amd64` и `linux/arm64` в GHCR и прикладывает бинарники для `linux/amd64`, `linux/arm64` и `darwin/arm64` к GitHub Release.
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
|
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) | ✅ done | 2026-04-18 | 2026-04-18 | a905198 | `v0.2.0` remained as failed tag history after the initial GHCR naming bug; phase completed via patch release `v0.2.1` after lowercasing image tags in `release.yml`. |
|
||||
|
||||
Легенда статусов:
|
||||
- ⏳ `pending` — не начата
|
||||
- 🔄 `in_progress` — в работе
|
||||
- ✅ `done` — завершена, acceptance criteria выполнены
|
||||
- ⚠️ `blocked` — заблокирована, см. заметки
|
||||
|
||||
## Инварианты между фазами
|
||||
|
||||
- `git status` чист перед началом каждой фазы.
|
||||
- Каждая фаза завершается отдельным commit в `main` (или PR с мёрджем). Сообщение в формате `phaseN: <краткое описание>`.
|
||||
- Acceptance criteria фазы проверяются до смены статуса на `done`.
|
||||
- Любое отклонение от плана документируется в колонке «Заметки» с ссылкой на commit.
|
||||
|
||||
## Лог ключевых решений (ADR lite)
|
||||
|
||||
| Дата | Решение | Обоснование |
|
||||
|------------|---------|-------------|
|
||||
| 2026-04-18 | Goldmark + chroma inline + extension.Footnote + кастомный anchor-extender | См. `plan-go-migration.md` §11 |
|
||||
| 2026-04-18 | ASCII-транслит id заголовков через `mozillazg/go-unidecode` | Решение пользователя (round-1) |
|
||||
| 2026-04-18 | One-shot preview/download с UUIDv4 + TTL 1 ч | Решение пользователя (round-1) |
|
||||
| 2026-04-18 | GitHub-style prefix-anchor (`<a>` как первый child `<h>`), не wrap-anchor | Закрытие F-01 round-3 — избегаем nested `<a>` |
|
||||
| 2026-04-18 | `extractHeadingText` walker вместо deprecated `BaseNode.Text(src)` | Закрытие F-02 round-3 |
|
||||
| 2026-04-18 | `<iframe sandbox srcdoc>` без `allow-same-origin` вместо `bluemonday` для inline preview | Меньше зависимостей, полная изоляция |
|
||||
| 2026-04-18 | `POST /convert` сохраняется (не `/api/convert`), UI-форма на `POST /ui/convert` | Паритет API-контракта |
|
||||
| 2026-04-18 | `html.WithUnsafe()` выключен; `parser.WithAttribute()` выключен | Безопасность + паритет |
|
||||
| 2026-04-18 | Tailwind standalone binary в Docker (без Node) | Упрощение multi-stage build |
|
||||
@@ -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,318 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fserg/md-to-html/internal/converter"
|
||||
"github.com/fserg/md-to-html/internal/ui"
|
||||
"github.com/fserg/md-to-html/internal/version"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const defaultDocumentTitle = "Document"
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
conv *converter.Converter
|
||||
store *PreviewStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
type convertRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) {
|
||||
if !hasJSONContentType(r.Header.Get("Content-Type")) {
|
||||
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
|
||||
"detail": "content-type must be application/json",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var payload convertRequest
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
s.writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.convertMarkdown(payload.Markdown, payload.Title)
|
||||
if err != nil {
|
||||
s.writeConvertError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(result.HTML)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"version": version.Version})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"template_loaded": s.conv != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = ui.Home().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxRequestBytes)
|
||||
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, "Слишком большой файл или ошибка формы")
|
||||
return
|
||||
}
|
||||
|
||||
md, filename, err := s.readUIMarkdownPayload(r)
|
||||
if err != nil {
|
||||
s.renderUIReadError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.conv.Convert(md, defaultDocumentTitle)
|
||||
if err != nil {
|
||||
s.log.Error("ui_convert_failed", "error", err)
|
||||
s.renderUIError(w, r, http.StatusBadGateway, "Ошибка конвертации: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
lineCount := bytes.Count(result.HTML, []byte("\n")) + 1
|
||||
elapsedMs := int(time.Since(startedAt).Milliseconds())
|
||||
if elapsedMs < 1 {
|
||||
elapsedMs = 1
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = ui.Result(previewID, downloadID, string(result.HTML), filename, len(result.HTML), lineCount, elapsedMs).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, ok := s.store.Take(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(item.html)
|
||||
}
|
||||
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, ok := s.store.Take(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", contentTypeOrDefault(item.mime))
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
|
||||
"filename": item.filename,
|
||||
}))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(item.html)
|
||||
}
|
||||
|
||||
func (s *Server) convertMarkdown(markdown, title string) (converter.Result, error) {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return converter.Result{}, errEmptyMarkdown
|
||||
}
|
||||
|
||||
if int64(len([]byte(markdown))) > s.cfg.MaxMarkdownBytes {
|
||||
return converter.Result{}, errMarkdownTooLarge{limit: s.cfg.MaxMarkdownBytes}
|
||||
}
|
||||
|
||||
fallbackTitle := strings.TrimSpace(title)
|
||||
if fallbackTitle == "" {
|
||||
fallbackTitle = defaultDocumentTitle
|
||||
}
|
||||
|
||||
result, err := s.conv.Convert([]byte(markdown), fallbackTitle)
|
||||
if err != nil {
|
||||
return converter.Result{}, fmt.Errorf("convert markdown: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) writeDecodeError(w http.ResponseWriter, err error) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"detail": fmt.Sprintf("request exceeds %d bytes", s.cfg.MaxRequestBytes),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": "invalid request payload"})
|
||||
}
|
||||
|
||||
func (s *Server) writeConvertError(w http.ResponseWriter, err error) {
|
||||
var markdownTooLarge errMarkdownTooLarge
|
||||
switch {
|
||||
case errors.Is(err, errEmptyMarkdown):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"detail": err.Error()})
|
||||
case errors.As(err, &markdownTooLarge):
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"detail": markdownTooLarge.Error(),
|
||||
})
|
||||
default:
|
||||
s.log.Error("convert_failed", "error", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"detail": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func hasJSONContentType(value string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(value)
|
||||
return err == nil && mediaType == "application/json"
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var extra json.RawMessage
|
||||
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(extra) > 0 {
|
||||
return errors.New("unexpected trailing JSON data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(payload)
|
||||
}
|
||||
|
||||
func htmlFilename(title string) string {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer("/", "-", "\\", "-", "\"", "", "\n", " ", "\r", " ")
|
||||
name = strings.TrimSpace(replacer.Replace(name))
|
||||
if name == "" {
|
||||
name = "document"
|
||||
}
|
||||
|
||||
return name + ".html"
|
||||
}
|
||||
|
||||
func contentTypeOrDefault(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) renderUIError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = ui.Error(msg).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) renderUIReadError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var markdownTooLarge errMarkdownTooLarge
|
||||
|
||||
switch {
|
||||
case errors.Is(err, errEmptyMarkdown):
|
||||
s.renderUIError(w, r, http.StatusBadRequest, "Пустой markdown")
|
||||
case errors.As(err, &markdownTooLarge):
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, fmt.Sprintf("Markdown больше %d байт", s.cfg.MaxMarkdownBytes))
|
||||
default:
|
||||
s.renderUIError(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) readUIMarkdownPayload(r *http.Request) ([]byte, string, error) {
|
||||
switch r.FormValue("source") {
|
||||
case "", "file":
|
||||
file, header, err := r.FormFile("markdown_file")
|
||||
if err != nil {
|
||||
return nil, "", errors.New("Файл не загружен")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
markdown, err := io.ReadAll(io.LimitReader(file, s.cfg.MaxMarkdownBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("не удалось прочитать файл: %w", err)
|
||||
}
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
return markdown, htmlFilename(name), nil
|
||||
case "text":
|
||||
markdown := []byte(r.FormValue("markdown_text"))
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return markdown, "document.html", nil
|
||||
default:
|
||||
return nil, "", errors.New("Неизвестный источник markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdown(markdown []byte, limit int64) error {
|
||||
if int64(len(markdown)) > limit {
|
||||
return errMarkdownTooLarge{limit: limit}
|
||||
}
|
||||
if len(bytes.TrimSpace(markdown)) == 0 {
|
||||
return errEmptyMarkdown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errEmptyMarkdown = errors.New("markdown must not be empty")
|
||||
|
||||
type errMarkdownTooLarge struct {
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (e errMarkdownTooLarge) Error() string {
|
||||
return fmt.Sprintf("markdown exceeds %d bytes", e.limit)
|
||||
}
|
||||
@@ -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,371 @@
|
||||
package ui
|
||||
|
||||
templ Home() {
|
||||
@Layout("Markdown → standalone HTML") {
|
||||
<div class="mx-auto max-w-3xl px-6 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground sm:text-[2rem]">Markdown → standalone HTML</h1>
|
||||
<p class="mt-1.5 max-w-prose text-sm leading-6 text-muted-foreground">
|
||||
Загрузите .md файл или вставьте Markdown-текст. Результат — готовый самодостаточный HTML со встроенными стилями.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
id="convert-form"
|
||||
class="space-y-6"
|
||||
hx-post="/ui/convert"
|
||||
hx-target="#result"
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
onreset="window.setTimeout(window.mdToHTMLResetForm, 0)"
|
||||
>
|
||||
<input id="source-field" type="hidden" name="source" value="file"/>
|
||||
|
||||
<section class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
|
||||
<div class="border-b border-border px-4 py-4">
|
||||
<div class="inline-flex items-center gap-1 rounded-lg bg-muted p-1" role="tablist" aria-label="Источник markdown">
|
||||
<button
|
||||
id="tab-file"
|
||||
type="button"
|
||||
value="file"
|
||||
class="tabs-trigger"
|
||||
data-state="active"
|
||||
onclick="window.mdToHTMLSetSource('file')"
|
||||
>
|
||||
@FileIcon("size-3.5")
|
||||
<span>Загрузить файл</span>
|
||||
</button>
|
||||
<button
|
||||
id="tab-text"
|
||||
type="button"
|
||||
value="text"
|
||||
class="tabs-trigger"
|
||||
onclick="window.mdToHTMLSetSource('text')"
|
||||
>
|
||||
@AlignLeftIcon("size-3.5")
|
||||
<span>Вставить текст</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div id="panel-file" class="flex flex-col gap-4">
|
||||
<label
|
||||
id="markdown-dropzone"
|
||||
for="markdown-file"
|
||||
class="dropzone group block cursor-pointer rounded-lg border-2 border-dashed border-border p-10 text-center transition hover:border-foreground/25"
|
||||
>
|
||||
<input
|
||||
id="markdown-file"
|
||||
type="file"
|
||||
name="markdown_file"
|
||||
accept=".md,.markdown,.mdown,text/markdown"
|
||||
class="sr-only"
|
||||
onchange="window.mdToHTMLHandleFileChange(this)"
|
||||
/>
|
||||
<div class="mx-auto mb-3 grid size-10 place-items-center rounded-full bg-muted text-muted-foreground transition group-hover:bg-primary/5 group-hover:text-foreground">
|
||||
@UploadIcon("size-5")
|
||||
</div>
|
||||
<div class="text-sm font-medium text-foreground">Перетащите .md файл сюда</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
или <span class="text-foreground underline underline-offset-2">выберите на диске</span>
|
||||
</div>
|
||||
<div class="mt-3 text-[11px] text-muted-foreground">Лимит: 200 MB · Тип: text/markdown</div>
|
||||
</label>
|
||||
|
||||
<div id="selected-file" class="hidden items-center gap-3 rounded-lg border border-border bg-muted/40 p-3">
|
||||
<div class="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-background text-muted-foreground">
|
||||
@FileIcon("size-4")
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div id="selected-file-name" class="truncate text-sm font-medium text-foreground">README.md</div>
|
||||
<div id="selected-file-meta" class="text-xs text-muted-foreground font-mono">3.4 KB · изменён только что</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
aria-label="Удалить файл"
|
||||
onclick="window.mdToHTMLClearFile()"
|
||||
>
|
||||
@CloseIcon("size-4")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-text" class="hidden flex-col gap-3">
|
||||
<label for="markdown-text" class="text-[13px] font-medium text-foreground">Markdown-текст</label>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
id="markdown-text"
|
||||
name="markdown_text"
|
||||
rows="10"
|
||||
class="focus-ring min-h-[16rem] w-full resize-y rounded-md border border-border bg-background px-3 py-2.5 font-mono text-sm leading-6 text-foreground placeholder:text-muted-foreground"
|
||||
placeholder="# Мой заголовок Здесь будет текст..."
|
||||
oninput="window.mdToHTMLUpdateCharCount(this)"
|
||||
></textarea>
|
||||
<span id="markdown-char-count" class="pointer-events-none absolute bottom-2.5 right-3 text-[10px] text-muted-foreground font-mono">
|
||||
0 символов
|
||||
</span>
|
||||
</div>
|
||||
<p class="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
@InfoIcon("size-3.5")
|
||||
<span>Поддерживается CommonMark + GFM</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-4">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Конвертация использует публичный <code class="font-mono text-foreground">GitHub API</code>
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="reset"
|
||||
class="focus-ring inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
<span>Конвертировать</span>
|
||||
@ArrowRightIcon("size-4")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<div id="result" class="mt-6"></div>
|
||||
|
||||
<section class="mt-6 overflow-hidden rounded-xl border border-border bg-background shadow-xs">
|
||||
<div class="flex items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground">API</span>
|
||||
<span class="inline-flex items-center rounded-md border border-border bg-muted px-1.5 py-px text-[10px] font-medium text-foreground font-mono">
|
||||
POST /convert
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="focus-ring inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
aria-label="Скопировать curl"
|
||||
data-copy-target="api-curl"
|
||||
data-copy-label="curl"
|
||||
onclick="window.mdToHTMLCopyButton(this)"
|
||||
>
|
||||
@CopyIcon("size-3.5")
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="api-curl" class="sr-only" readonly>curl -X POST http://localhost:8000/convert \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"markdown":"# Hello"}'</textarea>
|
||||
<pre class="overflow-x-auto px-5 py-4 font-mono text-[12px] leading-relaxed text-foreground"><span class="text-muted-foreground">$</span> curl -X POST http://localhost:8000/convert \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"markdown":"# Hello"}'</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
return (bytes / 1024).toFixed(1) + " KB";
|
||||
}
|
||||
|
||||
function formatRelativeTime(lastModified) {
|
||||
const delta = Date.now() - lastModified;
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
if (delta < minute) {
|
||||
return "изменён только что";
|
||||
}
|
||||
if (delta < hour) {
|
||||
const value = Math.max(1, Math.round(delta / minute));
|
||||
return "изменён " + value + " мин назад";
|
||||
}
|
||||
if (delta < day) {
|
||||
const value = Math.max(1, Math.round(delta / hour));
|
||||
return "изменён " + value + " ч назад";
|
||||
}
|
||||
const value = Math.max(1, Math.round(delta / day));
|
||||
return "изменён " + value + " дн назад";
|
||||
}
|
||||
|
||||
function flashCopyState(button) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const original = button.dataset.copyFlashOriginal || button.innerHTML;
|
||||
button.dataset.copyFlashOriginal = original;
|
||||
button.innerHTML = "Скопировано";
|
||||
window.setTimeout(() => {
|
||||
button.innerHTML = original;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const helper = document.createElement("textarea");
|
||||
helper.value = value;
|
||||
helper.setAttribute("readonly", "readonly");
|
||||
helper.style.position = "absolute";
|
||||
helper.style.left = "-9999px";
|
||||
document.body.appendChild(helper);
|
||||
helper.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(helper);
|
||||
}
|
||||
|
||||
function bindDropzone() {
|
||||
const dropzone = byId("markdown-dropzone");
|
||||
const input = byId("markdown-file");
|
||||
if (!dropzone || !input || dropzone.dataset.bound === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeClasses = ["border-foreground/35", "bg-muted/60"];
|
||||
dropzone.dataset.bound = "true";
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
activeClasses.forEach((className) => dropzone.classList.add(className));
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
activeClasses.forEach((className) => dropzone.classList.remove(className));
|
||||
});
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
activeClasses.forEach((className) => dropzone.classList.remove(className));
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
input.files = event.dataTransfer.files;
|
||||
window.mdToHTMLHandleFileChange(input);
|
||||
});
|
||||
}
|
||||
|
||||
window.mdToHTMLSetSource = function(source) {
|
||||
const sourceField = byId("source-field");
|
||||
const filePanel = byId("panel-file");
|
||||
const textPanel = byId("panel-text");
|
||||
const fileTab = byId("tab-file");
|
||||
const textTab = byId("tab-text");
|
||||
if (!sourceField || !filePanel || !textPanel || !fileTab || !textTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showFile = source === "file";
|
||||
sourceField.value = source;
|
||||
filePanel.classList.toggle("hidden", !showFile);
|
||||
filePanel.classList.toggle("flex", showFile);
|
||||
textPanel.classList.toggle("hidden", showFile);
|
||||
textPanel.classList.toggle("flex", !showFile);
|
||||
fileTab.setAttribute("data-state", showFile ? "active" : "inactive");
|
||||
textTab.setAttribute("data-state", showFile ? "inactive" : "active");
|
||||
};
|
||||
|
||||
window.mdToHTMLHandleFileChange = function(input) {
|
||||
const file = input && input.files && input.files[0];
|
||||
const summary = byId("selected-file");
|
||||
const fileName = byId("selected-file-name");
|
||||
const fileMeta = byId("selected-file-meta");
|
||||
if (!summary || !fileName || !fileMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
summary.classList.add("hidden");
|
||||
summary.classList.remove("flex");
|
||||
return;
|
||||
}
|
||||
|
||||
fileName.textContent = file.name;
|
||||
fileMeta.textContent = formatBytes(file.size) + " · " + formatRelativeTime(file.lastModified);
|
||||
summary.classList.remove("hidden");
|
||||
summary.classList.add("flex");
|
||||
};
|
||||
|
||||
window.mdToHTMLClearFile = function() {
|
||||
const input = byId("markdown-file");
|
||||
const summary = byId("selected-file");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
if (summary) {
|
||||
summary.classList.add("hidden");
|
||||
summary.classList.remove("flex");
|
||||
}
|
||||
};
|
||||
|
||||
window.mdToHTMLUpdateCharCount = function(textarea) {
|
||||
const counter = byId("markdown-char-count");
|
||||
if (!counter || !textarea) {
|
||||
return;
|
||||
}
|
||||
const count = textarea.value.length;
|
||||
counter.textContent = count + " символов";
|
||||
};
|
||||
|
||||
window.mdToHTMLResetForm = function() {
|
||||
window.mdToHTMLSetSource("file");
|
||||
window.mdToHTMLClearFile();
|
||||
const textarea = byId("markdown-text");
|
||||
if (textarea) {
|
||||
window.mdToHTMLUpdateCharCount(textarea);
|
||||
}
|
||||
const result = byId("result");
|
||||
if (result) {
|
||||
result.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
window.mdToHTMLCopyButton = async function(button) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const targetID = button.dataset.copyTarget;
|
||||
const target = targetID ? byId(targetID) : null;
|
||||
const value = target ? target.value || target.textContent || "" : "";
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyText(value);
|
||||
flashCopyState(button);
|
||||
} catch (_) {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
function init() {
|
||||
bindDropzone();
|
||||
window.mdToHTMLSetSource("file");
|
||||
const textarea = byId("markdown-text");
|
||||
if (textarea) {
|
||||
window.mdToHTMLUpdateCharCount(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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,75 @@
|
||||
package ui
|
||||
|
||||
templ UploadIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ FileIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<path d="M14 2v6h6"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ AlignLeftIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16"></path>
|
||||
<path d="M4 12h10"></path>
|
||||
<path d="M4 17h16"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ ArrowRightIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="m12 5 7 7-7 7"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ InfoIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ CloseIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ CheckIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ DownloadIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ CopyIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
|
||||
</svg>
|
||||
}
|
||||
|
||||
templ ExternalLinkIcon(class string) {
|
||||
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func UploadIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"17 8 12 3 7 8\"></polyline> <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func FileIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var5 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path> <path d=\"M14 2v6h6\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func AlignLeftIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var8 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 7h16\"></path> <path d=\"M4 12h10\"></path> <path d=\"M4 17h16\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func ArrowRightIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var10 == nil {
|
||||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var11 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 12h14\"></path> <path d=\"m12 5 7 7-7 7\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func InfoIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var13 == nil {
|
||||
templ_7745c5c3_Var13 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var14 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M12 16v-4\"></path> <path d=\"M12 8h.01\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func CloseIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var17 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var17).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func CheckIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var19 == nil {
|
||||
templ_7745c5c3_Var19 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var20 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 6 9 17l-5-5\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func DownloadIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var22 == nil {
|
||||
templ_7745c5c3_Var22 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var23 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"7 10 12 15 17 10\"></polyline> <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func CopyIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var25 == nil {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var26 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var26).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"></rect> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"></path></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func ExternalLinkIcon(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var28 == nil {
|
||||
templ_7745c5c3_Var28 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var29 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var29).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path> <polyline points=\"15 3 21 3 21 9\"></polyline> <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line></svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -0,0 +1,23 @@
|
||||
package ui
|
||||
|
||||
templ Layout(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ title } · md-to-html</title>
|
||||
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-[#fafafa] font-sans text-foreground antialiased">
|
||||
{ children... }
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func Layout(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 9, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · md-to-html</title><link rel=\"stylesheet\" href=\"/static/dist/app.css\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap\" rel=\"stylesheet\"><script src=\"https://unpkg.com/htmx.org@2.0.3\"></script></head><body class=\"min-h-screen bg-[#fafafa] font-sans text-foreground antialiased\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -0,0 +1,71 @@
|
||||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ Result(previewID, downloadID, fullHTML, filename string, sizeBytes int, lineCount int, elapsedMs int) {
|
||||
<div id="result" class="mt-6">
|
||||
<section class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
|
||||
<div class="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||
<div class="grid size-8 shrink-0 place-items-center rounded-md bg-emerald-50 text-emerald-600">
|
||||
@CheckIcon("size-4")
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">Готово — { filename }</div>
|
||||
<div class="text-xs text-muted-foreground font-mono">
|
||||
{ formatResultMeta(sizeBytes, lineCount, elapsedMs) }
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground font-mono">
|
||||
standalone
|
||||
</span>
|
||||
</div>
|
||||
<textarea id={ "result-html-" + previewID } class="sr-only" readonly>{ fullHTML }</textarea>
|
||||
<a href={ "/preview/" + previewID } target="_blank" rel="noreferrer" class="sr-only">Открыть превью</a>
|
||||
<iframe class="hidden" sandbox="" referrerpolicy="no-referrer" srcdoc={ fullHTML }></iframe>
|
||||
<div class="flex flex-wrap items-center gap-2 px-5 py-5">
|
||||
<a
|
||||
href={ "/download/" + downloadID }
|
||||
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
@DownloadIcon("size-4")
|
||||
<span>Скачать HTML</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
|
||||
data-copy-target={ "result-html-" + previewID }
|
||||
onclick="window.mdToHTMLCopyButton(this)"
|
||||
>
|
||||
@CopyIcon("size-4")
|
||||
<span>Скопировать</span>
|
||||
</button>
|
||||
<a
|
||||
href={ "/preview/" + previewID }
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
@ExternalLinkIcon("size-4")
|
||||
<span>Открыть в новой вкладке</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Error(msg string) {
|
||||
<div id="result" class="mt-6">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 shadow-xs">
|
||||
{ msg }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func formatResultMeta(sizeBytes int, lineCount int, elapsedMs int) string {
|
||||
kilobytes := float64(sizeBytes) / 1024
|
||||
seconds := float64(elapsedMs) / 1000
|
||||
if seconds < 0.1 {
|
||||
seconds = 0.1
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB · %d строки · сгенерирован %.1f сек назад", kilobytes, lineCount, seconds)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package ui
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "fmt"
|
||||
|
||||
func Result(previewID, downloadID, fullHTML, filename string, sizeBytes int, lineCount int, elapsedMs int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"result\" class=\"mt-6\"><section class=\"overflow-hidden rounded-xl border border-border bg-background shadow-xs\"><div class=\"flex items-center gap-3 border-b border-border px-5 py-4\"><div class=\"grid size-8 shrink-0 place-items-center rounded-md bg-emerald-50 text-emerald-600\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = CheckIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><div class=\"min-w-0 flex-1\"><div class=\"truncate text-sm font-medium text-foreground\">Готово — ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 13, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"text-xs text-muted-foreground font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(formatResultMeta(sizeBytes, lineCount, elapsedMs))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 15, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><span class=\"inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground font-mono\">standalone</span></div><textarea id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("result-html-" + previewID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 22, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"sr-only\" readonly>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 22, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</textarea> <a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs("/preview/" + previewID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 23, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" target=\"_blank\" rel=\"noreferrer\" class=\"sr-only\">Открыть превью</a> <iframe class=\"hidden\" sandbox=\"\" referrerpolicy=\"no-referrer\" srcdoc=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 24, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></iframe><div class=\"flex flex-wrap items-center gap-2 px-5 py-5\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + downloadID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 27, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = DownloadIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span>Скачать HTML</span></a> <button type=\"button\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted\" data-copy-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("result-html-" + previewID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 36, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" onclick=\"window.mdToHTMLCopyButton(this)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = CopyIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span>Скопировать</span></button> <a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 templ.SafeURL
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs("/preview/" + previewID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 43, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" target=\"_blank\" rel=\"noreferrer\" class=\"focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ExternalLinkIcon("size-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Открыть в новой вкладке</span></a></div></section></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Error(msg string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var11 == nil {
|
||||
templ_7745c5c3_Var11 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div id=\"result\" class=\"mt-6\"><div class=\"rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 shadow-xs\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 59, Col: 8}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func formatResultMeta(sizeBytes int, lineCount int, elapsedMs int) string {
|
||||
kilobytes := float64(sizeBytes) / 1024
|
||||
seconds := float64(elapsedMs) / 1000
|
||||
if seconds < 0.1 {
|
||||
seconds = 0.1
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB · %d строки · сгенерирован %.1f сек назад", kilobytes, lineCount, seconds)
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user