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
|
.git
|
||||||
|
.github
|
||||||
|
.review-sandboxes
|
||||||
|
.claude
|
||||||
|
.agents
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/
|
.venv
|
||||||
.agents/
|
venv
|
||||||
.review-sandboxes/
|
__pycache__
|
||||||
md/*.html
|
*.py[cod]
|
||||||
__pycache__/
|
archive
|
||||||
*.pyc
|
docs
|
||||||
venv/
|
tmp
|
||||||
.venv/
|
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
|
# Local markdown workspace
|
||||||
md/
|
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.
|
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
|
## [0.1.2] - 2026-04-18
|
||||||
|
|
||||||
### Added
|
### 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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends tini \
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /src
|
||||||
|
|
||||||
COPY requirements.txt .
|
ARG TARGETARCH
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
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 . .
|
||||||
|
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 \
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
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", "--"]
|
COPY --from=build /out/md-to-html /md-to-html
|
||||||
CMD ["python", "start.py"]
|
|
||||||
|
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
|
# 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`.
|
||||||
|
|
||||||
Есть два интерфейса:
|
## Запуск через Docker
|
||||||
|
|
||||||
- FastAPI на `http://localhost:8000`
|
|
||||||
- Streamlit UI на `http://localhost:8501` с двумя режимами ввода: загрузка `.md` файла или вставка Markdown-текста из буфера обмена
|
|
||||||
|
|
||||||
## Локальный запуск
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv venv .venv
|
docker run --rm -p 8080:8080 ghcr.io/fserg/md-to-html:latest
|
||||||
source .venv/bin/activate
|
|
||||||
uv pip install -r requirements.txt
|
|
||||||
uvicorn app.api:app --reload
|
|
||||||
streamlit run app/streamlit_app.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
CLI сохранился:
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
docker build -t md-to-html .
|
go install github.com/a-h/templ/cmd/templ@v0.3.1001
|
||||||
docker run --rm -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=your_token md-to-html
|
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`
|
`POST /convert`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/convert \
|
curl -X POST http://localhost:8080/convert \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-d '{"markdown":"# Hello"}'
|
-d '{"markdown":"# Привет"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /health`
|
Прочие эндпоинты:
|
||||||
|
|
||||||
```bash
|
- `GET /` — веб-интерфейс.
|
||||||
curl http://localhost:8000/health
|
- `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
|
```bash
|
||||||
git add VERSION CHANGELOG.md
|
git commit -am "Release vX.Y.Z"
|
||||||
git commit -m "Release v0.1.2"
|
git tag vX.Y.Z
|
||||||
git tag v0.1.2
|
|
||||||
git push origin main --tags
|
git push origin main --tags
|
||||||
gh release create v0.1.2 --notes-file CHANGELOG.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
После публикации релиза GitHub Actions автоматически собирает Docker-образ и публикует его в GitHub Container Registry:
|
GitHub Actions публикует Docker-образ для `linux/amd64` и `linux/arm64` в GHCR и прикладывает бинарники для `linux/amd64`, `linux/arm64` и `darwin/arm64` к GitHub Release.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/fserg/md-to-html:v0.1.2
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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