Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 937f014ce0 | |||
| e6ff59c75b | |||
| 79a4bcb890 | |||
| 62f1ea5d36 | |||
| 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,87 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: fsadmin/md-to-html
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare tags
|
||||||
|
id: meta
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${{ vars.REGISTRY }}" ]; then
|
||||||
|
echo "::error::Set the REGISTRY repository variable to your Gitea registry host."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
image="${{ vars.REGISTRY }}/${IMAGE_NAME}"
|
||||||
|
short_sha="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "image=${image}"
|
||||||
|
echo "tags<<EOF"
|
||||||
|
echo "${image}:${short_sha}"
|
||||||
|
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
||||||
|
echo "${image}:latest"
|
||||||
|
fi
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
echo "${image}:${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
echo "EOF"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
run: |
|
||||||
|
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
| docker login "${{ vars.REGISTRY }}" \
|
||||||
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
tags=""
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
tags="${tags} -t ${tag}"
|
||||||
|
done <<'EOF'
|
||||||
|
${{ steps.meta.outputs.tags }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker build ${tags} .
|
||||||
|
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
docker push "${tag}"
|
||||||
|
done <<'EOF'
|
||||||
|
${{ steps.meta.outputs.tags }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Trigger Dokploy deployment
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${{ secrets.DOKPLOY_WEBHOOK_URL }}" ]; then
|
||||||
|
echo "::error::Set the DOKPLOY_WEBHOOK_URL repository secret."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
|
||||||
@@ -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@v3
|
||||||
|
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
|
||||||
|
|||||||
+52
-10
@@ -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 @@
|
|||||||
|
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
|||||||
|
Contact <dev@example.test> for details.
|
||||||
+1314
File diff suppressed because it is too large
Load Diff
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# Basic Example
|
||||||
|
|
||||||
|
Simple paragraph with **bold**, *italic*, and [docs](/docs).
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
|||||||
|
Ready to launch :rocket: today.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("hello")
|
||||||
|
}
|
||||||
|
```
|
||||||
+1321
File diff suppressed because it is too large
Load Diff
+3
@@ -0,0 +1,3 @@
|
|||||||
|
Footnote text.[^1]
|
||||||
|
|
||||||
|
[^1]: Extra details.
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
## <dev@example.test>
|
||||||
+1316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
## Install
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
## Сетап
|
||||||
+1315
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
# Привет
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
## :rocket: Launch
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
##  Title
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
## [API](/api)
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
## Using `go fmt`
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
|||||||
|
<script>alert(1)</script>
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
Use ~~old~~ new output.
|
||||||
+1330
File diff suppressed because it is too large
Load Diff
+4
@@ -0,0 +1,4 @@
|
|||||||
|
| Name | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Alpha | 1 |
|
||||||
|
| Beta | 2 |
|
||||||
+1316
File diff suppressed because it is too large
Load Diff
+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,368 @@
|
|||||||
|
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-end gap-3 border-t border-border px-4 py-4">
|
||||||
|
<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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user