5 Commits

Author SHA1 Message Date
Sergey Filkin 5bb488ccd0 Release v0.2.2
release / release (push) Has been cancelled
build / test (push) Successful in 11m1s
build / cross-compile (amd64, linux) (push) Failing after 5m43s
build / cross-compile (arm64, darwin) (push) Failing after 5m23s
build / cross-compile (arm64, linux) (push) Failing after 5m23s
2026-04-18 14:42:16 +03:00
Sergey Filkin 256d5c9e6d chore(progress): complete phase7 2026-04-18 13:43:58 +03:00
Sergey Filkin a90519807c phase7: release v0.2.1
release / release (push) Has been cancelled
2026-04-18 13:32:31 +03:00
Sergey Filkin 4cd85e3515 chore(progress): resume phase7 2026-04-18 13:30:37 +03:00
Sergey Filkin 9531730283 chore(progress): block phase7 2026-04-18 12:59:29 +03:00
19 changed files with 1446 additions and 592 deletions
+8 -2
View File
@@ -55,6 +55,12 @@ jobs:
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
@@ -66,8 +72,8 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
${{ steps.image.outputs.name }}:${{ github.ref_name }}
${{ steps.image.outputs.name }}:latest
- name: Create GitHub Release
env:
+14
View File
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and the project uses Semantic Versioning.
## [0.2.2] - 2026-04-18
### Changed
- Web UI redesigned into a single-column classic layout with tabbed file/text input, updated result card, and API snippet.
- Added a local release build script and Make targets for current-platform and cross-platform release artifacts.
- README updated with the current build, release, and run instructions.
## [0.2.1] - 2026-04-18
### Fixed
- GitHub release workflow now lowercases the GHCR image name before publishing, which fixes releases for repositories with uppercase owner names.
## [0.2.0] - 2026-04-18
### Changed
+7 -1
View File
@@ -3,7 +3,7 @@ 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
.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
@@ -31,6 +31,12 @@ dev:
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/
+47 -8
View File
@@ -1,16 +1,18 @@
# md-to-html
Сервис конвертации Markdown в самодостаточный HTML. Полностью офлайн, без обращений к внешним API.
Сервис конвертации Markdown в самодостаточный HTML. Конвертация выполняется локально, без внешних API.
Текущая версия: `0.2.0` (Go + goldmark + templUI)
![Превью интерфейса](screen.png)
Текущая версия: `0.2.2` (Go + goldmark + templUI)
## Возможности
- GFM + footnote + emoji + подсветка кода через chroma.
- Якоря в заголовках с ASCII-транслитом: `## Установка``#ustanovka`.
- Web UI на `http://localhost:8080/` с загрузкой файла или вставкой текста, HTMX-обновлением результата и одноразовыми ссылками на preview/download.
- CLI: `md-to-html cli file.md`.
- HTTP API: `POST /convert` совместим с `v0.1.x`.
- Web UI на `http://localhost:8080/` с inline-preview в sandbox iframe и одноразовыми ссылками на preview/download.
- HTTP API: `POST /convert`, совместим с `v0.1.x`.
- Якоря в заголовках с ASCII-транслитом: `## Установка``#ustanovka`.
## Запуск через Docker
@@ -18,12 +20,22 @@
docker run --rm -p 8080:8080 ghcr.io/fserg/md-to-html:latest
```
## Локальная разработка
Требования: Go 1.23+, `templ` CLI, Node.js для dev-режима Tailwind или standalone `tailwindcss`.
## Быстрый старт
```bash
go install github.com/a-h/templ/cmd/templ@v0.3.1001
npm install
make build
./bin/md-to-html serve
```
## Локальная разработка
Требования: Go 1.24+, Node.js, `templ` CLI.
```bash
go install github.com/a-h/templ/cmd/templ@v0.3.1001
npm install
make tailwind
make build
./bin/md-to-html serve
@@ -35,6 +47,33 @@ make build
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
+1 -1
View File
@@ -1 +1 @@
0.2.0
0.2.2
+1 -1
View File
@@ -16,7 +16,7 @@
| 4 | [UI на templUI](phases/phase-4-ui.md) | ✅ done | 2026-04-18 | 2026-04-18 | d6aef55 | |
| 5 | [CLI-подкоманда](phases/phase-5-cli.md) | ✅ done | 2026-04-18 | 2026-04-18 | 6aa19fe | |
| 6 | [Docker + CI](phases/phase-6-docker-ci.md) | ✅ done | 2026-04-18 | 2026-04-18 | 4b55661 | Tailwind standalone pinned to `v3.4.17`: `latest` did not emit `web/static/dist/app.css` for the current build pipeline. |
| 7 | [Документация + v0.2.0](phases/phase-7-docs.md) | 🔄 in_progress | 2026-04-18 | — | — | |
| 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` — не начата
+8 -1
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"path/filepath"
"strings"
"time"
"github.com/fserg/md-to-html/internal/converter"
"github.com/fserg/md-to-html/internal/ui"
@@ -79,6 +80,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
}
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, "Слишком большой файл или ошибка формы")
@@ -100,10 +102,15 @@ func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
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).Render(r.Context(), w)
_ = 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) {
+346 -133
View File
@@ -1,158 +1,371 @@
package ui
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
templ Home() {
@Layout("Markdown → HTML") {
<div class="panel-grid">
<section class="space-y-6">
<div class="space-y-4">
<div class="eyebrow">
<span>Go migration</span>
<span>goldmark + templUI</span>
@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="space-y-3">
<h1 class="max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-foreground sm:text-5xl">
Markdown → HTML без внешних зависимостей в результирующем документе.
</h1>
<p class="max-w-2xl text-base leading-7 text-muted-foreground sm:text-lg">
Загрузите `.md`-файл или вставьте текст вручную. Сервис отдаст автономный HTML, одноразовое превью и отдельную ссылку на скачивание.
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Самодостаточный HTML</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Результат открывается локально без CDN и без сетевых вызовов.</p>
</div>
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Одноразовые ссылки</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Preview и download живут до первого открытия или максимум один час.</p>
</div>
<div class="section-card p-4">
<div class="text-sm font-semibold text-foreground">Русский интерфейс</div>
<p class="mt-2 text-sm leading-6 text-muted-foreground">Форма ориентирована на быстрый ручной прогон документации и заметок.</p>
</div>
</div>
</section>
<section>
@card.Card(card.Props{Class: "section-card overflow-hidden"}) {
@card.Header(card.HeaderProps{Class: "space-y-2 border-b border-border/70 pb-6"}) {
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-muted-foreground">Конвертация</div>
@card.Title(card.TitleProps{Class: "text-2xl font-semibold tracking-tight text-foreground"}) {
Выберите источник Markdown
}
@card.Description(card.DescriptionProps{Class: "max-w-xl text-sm leading-6 text-muted-foreground"}) {
Форма отправляется через HTMX на `POST /ui/convert`, а результат подменяется прямо в блоке ниже.
}
}
@card.Content(card.ContentProps{Class: "space-y-5"}) {
<form
id="convert-form"
hx-post="/ui/convert"
hx-target="#result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
class="space-y-5"
>
<div class="space-y-2">
<div class="field-label">Источник</div>
<div class="grid grid-cols-2 gap-2 rounded-[1.35rem] border border-border/80 bg-muted/55 p-2">
<label
class="source-tab source-tab-active"
data-source-tab="file"
data-active-classes="source-tab source-tab-active"
data-inactive-classes="source-tab"
>
<input
type="radio"
name="source"
value="file"
class="sr-only"
checked
onchange="window.mdToHTMLSwitchSource(this.value)"
/>
Файл
</label>
<label
class="source-tab"
data-source-tab="text"
data-active-classes="source-tab source-tab-active"
data-inactive-classes="source-tab"
>
<input
type="radio"
name="source"
value="text"
class="sr-only"
onchange="window.mdToHTMLSwitchSource(this.value)"
/>
Текст
</label>
</div>
</div>
<div id="source-file" class="source-panel space-y-3">
<label class="field-label" for="markdown-file">Markdown-файл</label>
<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"
class="surface-input file:mr-4 file:rounded-xl file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
type="file"
name="markdown_file"
accept=".md,.markdown,.mdown,text/markdown"
class="sr-only"
onchange="window.mdToHTMLHandleFileChange(this)"
/>
<p class="field-hint">Используйте для загрузки существующего документа. Имя файла станет базой для имени HTML.</p>
<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 id="source-text" class="source-panel hidden space-y-3">
<label class="field-label" for="markdown-text">Markdown-текст</label>
</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"
class="surface-textarea"
name="markdown_text"
rows="14"
placeholder="# Привет, мир&#10;&#10;- списки&#10;- таблицы&#10;- код"
rows="10"
class="focus-ring min-h-[16rem] w-full resize-y rounded-md border border-border bg-background px-3 py-2.5 font-mono text-sm leading-6 text-foreground placeholder:text-muted-foreground"
placeholder="# Мой заголовок&#10;&#10;Здесь будет текст..."
oninput="window.mdToHTMLUpdateCharCount(this)"
></textarea>
<p class="field-hint">Подходит для быстрых заметок и вставок без промежуточного файла.</p>
<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>
<div class="flex flex-wrap items-center gap-3">
@button.Button(button.Props{
Type: button.TypeSubmit,
Class: "rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
Size: button.SizeDefault,
}) {
<span>Конвертировать</span>
}
<span class="field-hint">Лимиты тела запроса и markdown берутся из server config.</span>
</div>
</form>
<div id="result" class="min-h-[4rem]"></div>
}
}
<p class="flex items-center gap-2 text-[11px] text-muted-foreground">
@InfoIcon("size-3.5")
<span>Поддерживается CommonMark + GFM</span>
</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-4">
<p class="text-xs text-muted-foreground">
Конвертация использует публичный <code class="font-mono text-foreground">GitHub API</code>
</p>
<div class="flex items-center gap-2">
<button
type="reset"
class="focus-ring inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
>
Сбросить
</button>
<button
type="submit"
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<span>Конвертировать</span>
@ArrowRightIcon("size-4")
</button>
</div>
</div>
</section>
</form>
<div id="result" class="mt-6"></div>
<section class="mt-6 overflow-hidden rounded-xl border border-border bg-background shadow-xs">
<div class="flex items-center justify-between border-b border-border px-5 py-3.5">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground">API</span>
<span class="inline-flex items-center rounded-md border border-border bg-muted px-1.5 py-px text-[10px] font-medium text-foreground font-mono">
POST /convert
</span>
</div>
<button
type="button"
class="focus-ring inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="Скопировать curl"
data-copy-target="api-curl"
data-copy-label="curl"
onclick="window.mdToHTMLCopyButton(this)"
>
@CopyIcon("size-3.5")
</button>
</div>
<textarea id="api-curl" class="sr-only" readonly>curl -X POST http://localhost:8000/convert \
-H 'Content-Type: application/json' \
-d '&#123;"markdown":"# Hello"&#125;'</textarea>
<pre class="overflow-x-auto px-5 py-4 font-mono text-[12px] leading-relaxed text-foreground"><span class="text-muted-foreground">$</span> curl -X POST http://localhost:8000/convert \
-H 'Content-Type: application/json' \
-d '&#123;"markdown":"# Hello"&#125;'</pre>
</section>
</div>
<script>
window.mdToHTMLSwitchSource = function(value) {
const filePanel = document.getElementById("source-file");
const textPanel = document.getElementById("source-text");
if (!filePanel || !textPanel) {
return;
(() => {
function byId(id) {
return document.getElementById(id);
}
const showFile = value === "file";
filePanel.classList.toggle("hidden", !showFile);
textPanel.classList.toggle("hidden", showFile);
function formatBytes(bytes) {
if (bytes < 1024) {
return bytes + " B";
}
return (bytes / 1024).toFixed(1) + " KB";
}
document.querySelectorAll("[data-source-tab]").forEach((tab) => {
const tabValue = tab.getAttribute("data-source-tab");
const active = tabValue === value;
tab.className = active
? tab.getAttribute("data-active-classes")
: tab.getAttribute("data-inactive-classes");
});
};
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>
}
}
+60 -150
View File
File diff suppressed because one or more lines are too long
+75
View File
@@ -0,0 +1,75 @@
package ui
templ UploadIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
}
templ FileIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<path d="M14 2v6h6"></path>
</svg>
}
templ AlignLeftIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M4 12h10"></path>
<path d="M4 17h16"></path>
</svg>
}
templ ArrowRightIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
}
templ InfoIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
}
templ CloseIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
}
templ CheckIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
}
templ DownloadIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
}
templ CopyIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
}
templ ExternalLinkIcon(class string) {
<svg class={ class } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
}
+481
View File
@@ -0,0 +1,481 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package ui
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func UploadIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"17 8 12 3 7 8\"></polyline> <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func FileIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var5 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path> <path d=\"M14 2v6h6\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func AlignLeftIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var8 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 7h16\"></path> <path d=\"M4 12h10\"></path> <path d=\"M4 17h16\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ArrowRightIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var11 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 12h14\"></path> <path d=\"m12 5 7 7-7 7\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func InfoIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var14 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M12 16v-4\"></path> <path d=\"M12 8h.01\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CloseIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var17 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CheckIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var20 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var20).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 6 9 17l-5-5\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func DownloadIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var23 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"7 10 12 15 17 10\"></polyline> <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func CopyIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var26 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var26).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"></rect> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ExternalLinkIcon(class string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var29 = []any{class}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var29).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path> <polyline points=\"15 3 21 3 21 9\"></polyline> <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+12 -17
View File
@@ -1,28 +1,23 @@
package ui
import "github.com/fserg/md-to-html/internal/version"
templ Layout(title string) {
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<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"/>
<script src="/static/htmx.min.js"></script>
<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>
<div class="app-shell">
<div class="hero-panel">
<div class="relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10">
{ children... }
</div>
</div>
<footer class="mt-6 text-center text-sm text-muted-foreground">
Markdown → HTML · v{ version.Version }
</footer>
</div>
<body class="min-h-screen bg-[#fafafa] font-sans text-foreground antialiased">
{ children... }
</body>
</html>
}
+4 -19
View File
@@ -8,8 +8,6 @@ package ui
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/fserg/md-to-html/internal/version"
func Layout(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -31,20 +29,20 @@ func Layout(title string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"ru\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
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: 11, Col: 17}
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, "</title><link rel=\"stylesheet\" href=\"/static/dist/app.css\"><script src=\"/static/htmx.min.js\"></script></head><body><div class=\"app-shell\"><div class=\"hero-panel\"><div class=\"relative px-5 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10\">")
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
}
@@ -52,20 +50,7 @@ func Layout(title string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><footer class=\"mt-6 text-center text-sm text-muted-foreground\">Markdown → HTML · v")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/layout.templ`, Line: 23, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</footer></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+62 -47
View File
@@ -1,56 +1,71 @@
package ui
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
import "fmt"
templ Result(previewID, downloadID string, fullHTML string, filename string) {
@card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}) {
@card.Content(card.ContentProps{Class: "space-y-4"}) {
<div class="flex flex-wrap items-center gap-3">
@button.Button(button.Props{
Href: "/preview/" + previewID,
Target: "_blank",
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
}) {
Открыть превью
}
@button.Button(button.Props{
Href: "/download/" + downloadID,
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
Variant: button.VariantOutline,
}) {
Скачать HTML
}
<span class="text-sm text-muted-foreground">Файл: <span class="font-medium text-foreground">{ filename }</span></span>
</div>
<p class="text-sm leading-6 text-muted-foreground">
Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.
</p>
<details class="group overflow-hidden rounded-[1.25rem] border border-border bg-card/80">
<summary class="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground">
<span class="inline-flex items-center gap-2">
<span class="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">i</span>
Inline-превью в изолированном iframe
</span>
</summary>
<div class="border-t border-border/70 px-4 pb-4 pt-3">
<iframe
class="result-frame"
sandbox=""
referrerpolicy="no-referrer"
srcdoc={ fullHTML }
></iframe>
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>
</details>
}
}
<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 class="rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{ msg }
<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)
}
+170 -134
View File
@@ -8,12 +8,9 @@ package ui
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/fserg/md-to-html/internal/ui/components/button"
"github.com/fserg/md-to-html/internal/ui/components/card"
)
import "fmt"
func Result(previewID, downloadID string, fullHTML string, filename string) templ.Component {
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 {
@@ -34,126 +31,156 @@ func Result(previewID, downloadID string, fullHTML string, filename string) temp
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex flex-wrap items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Открыть превью")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = button.Button(button.Props{
Href: "/preview/" + previewID,
Target: "_blank",
Class: "rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90",
Variant: button.VariantDefault,
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Скачать HTML")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = button.Button(button.Props{
Href: "/download/" + downloadID,
Class: "rounded-2xl border border-border bg-card px-4 py-2.5 text-sm font-semibold text-foreground hover:bg-muted/60",
Variant: button.VariantOutline,
}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-sm text-muted-foreground\">Файл: <span class=\"font-medium text-foreground\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 27, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></span></div><p class=\"text-sm leading-6 text-muted-foreground\">Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.</p><details class=\"group overflow-hidden rounded-[1.25rem] border border-border bg-card/80\"><summary class=\"cursor-pointer list-none px-4 py-3 text-sm font-semibold text-foreground\"><span class=\"inline-flex items-center gap-2\"><span class=\"inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground\">i</span> Inline-превью в изолированном iframe</span></summary><div class=\"border-t border-border/70 px-4 pb-4 pt-3\"><iframe class=\"result-frame\" sandbox=\"\" referrerpolicy=\"no-referrer\" srcdoc=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fullHTML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/result.templ`, Line: 44, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></iframe></div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Content(card.ContentProps{Class: "space-y-4"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = card.Card(card.Props{Class: "section-card border-primary/20 bg-background/90"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
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
}
@@ -177,25 +204,25 @@ func Error(msg string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
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, 7, "<div class=\"rounded-[1.25rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800\">")
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_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
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: 54, Col: 7}
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_Var9))
_, 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, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -203,4 +230,13 @@ func Error(msg string) templ.Component {
})
}
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/release-build.sh
./scripts/release-build.sh --all
Options:
--all Build all release targets used in CI:
linux/amd64, linux/arm64, darwin/arm64
EOF
}
mode="current"
if [[ $# -gt 1 ]]; then
usage
exit 2
fi
if [[ $# -eq 1 ]]; then
case "$1" in
--all)
mode="all"
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
cd "${repo_root}"
version="$(tr -d '[:space:]' < VERSION)"
if [[ -z "${version}" ]]; then
echo "VERSION is empty" >&2
exit 1
fi
ldflags="-s -w -X github.com/fserg/md-to-html/internal/version.Version=${version}"
dist_dir="${repo_root}/dist"
mkdir -p "${dist_dir}" "${repo_root}/web/static/dist"
echo "==> Generating templ code"
go run github.com/a-h/templ/cmd/templ@v0.3.1001 generate ./...
echo "==> Building Tailwind bundle"
npx tailwindcss -c tailwind.config.js -i web/static/src/app.css -o web/static/dist/app.css --minify
echo "==> Running tests"
go test ./...
targets=()
if [[ "${mode}" == "all" ]]; then
targets+=("linux amd64")
targets+=("linux arm64")
targets+=("darwin arm64")
else
current_goos="$(go env GOOS)"
current_goarch="$(go env GOARCH)"
targets+=("${current_goos} ${current_goarch}")
fi
artifacts=()
for target in "${targets[@]}"; do
read -r goos goarch <<<"${target}"
output="${dist_dir}/md-to-html-${goos}-${goarch}"
echo "==> Building ${goos}/${goarch}"
CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" \
go build -trimpath -ldflags="${ldflags}" -o "${output}" ./cmd/md-to-html
artifacts+=("${output}")
done
checksum_file="${dist_dir}/SHA256SUMS"
(
cd "${dist_dir}"
shasum -a 256 "${artifacts[@]##${dist_dir}/}" > "${checksum_file}"
)
echo
echo "Artifacts:"
for artifact in "${artifacts[@]}"; do
echo " ${artifact}"
done
echo " ${checksum_file}"
if [[ "${mode}" == "current" ]]; then
echo
echo "Run to verify:"
echo " ${artifacts[0]} serve"
fi
+19 -19
View File
@@ -7,29 +7,29 @@ module.exports = {
theme: {
extend: {
colors: {
background: "#f5efe2",
foreground: "#221f1a",
card: "#fffdf8",
"card-foreground": "#221f1a",
primary: "#b85c38",
"primary-foreground": "#fffaf4",
secondary: "#ead7b0",
"secondary-foreground": "#3f3528",
muted: "#efe4d2",
"muted-foreground": "#6c6254",
accent: "#d0b38a",
"accent-foreground": "#2e2417",
border: "#d8c6ab",
ring: "#b85c38",
input: "#fffaf4",
destructive: "#b42318",
background: "#ffffff",
foreground: "#0a0a0a",
card: "#ffffff",
"card-foreground": "#0a0a0a",
primary: "#171717",
"primary-foreground": "#fafafa",
secondary: "#f5f5f5",
"secondary-foreground": "#171717",
muted: "#f5f5f5",
"muted-foreground": "#737373",
accent: "#f5f5f5",
"accent-foreground": "#171717",
border: "#e5e5e5",
ring: "#0a0a0a",
input: "#e5e5e5",
destructive: "#dc2626",
},
boxShadow: {
xs: "0 1px 2px rgba(34, 31, 26, 0.08)",
xs: "0 1px 2px 0 rgb(0 0 0 / 0.04)",
},
fontFamily: {
sans: ["IBM Plex Sans", "Avenir Next", "Segoe UI", "sans-serif"],
mono: ["IBM Plex Mono", "SFMono-Regular", "monospace"],
sans: ["Geist", "ui-sans-serif", "system-ui", "sans-serif"],
mono: ["Geist Mono", "ui-monospace", "Menlo", "monospace"],
},
},
},
+31 -59
View File
@@ -5,83 +5,55 @@
@layer base {
:root {
color-scheme: light;
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-muted: #f5f5f5;
--color-muted-foreground: #737373;
--color-border: #e5e5e5;
--color-input: #e5e5e5;
--color-ring: #0a0a0a;
--color-primary: #171717;
--color-primary-foreground: #fafafa;
--color-secondary: #f5f5f5;
--color-secondary-foreground: #171717;
--color-accent: #f5f5f5;
--color-accent-foreground: #171717;
--color-card: #ffffff;
--color-card-foreground: #0a0a0a;
--color-destructive: #dc2626;
}
html {
background:
radial-gradient(circle at top left, rgba(234, 215, 176, 0.55), transparent 34rem),
radial-gradient(circle at top right, rgba(184, 92, 56, 0.14), transparent 24rem),
linear-gradient(180deg, #fbf7ef 0%, #f3eadb 100%);
@apply bg-[#fafafa];
}
body {
@apply min-h-screen bg-transparent font-sans text-foreground antialiased;
}
a {
@apply transition-colors;
@apply min-h-screen bg-transparent text-foreground antialiased;
}
::selection {
background: rgba(184, 92, 56, 0.18);
background: rgba(23, 23, 23, 0.14);
}
}
@layer components {
.app-shell {
@apply mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8;
.dropzone {
background-image: repeating-linear-gradient(
45deg,
rgba(245, 245, 245, 0.9) 0 10px,
rgba(255, 255, 255, 0.9) 10px 20px
);
}
.hero-panel {
@apply relative overflow-hidden rounded-[2rem] border border-border/70 bg-card/95 shadow-xl shadow-stone-900/5 backdrop-blur;
.tabs-trigger {
@apply inline-flex h-9 items-center justify-center gap-2 rounded-md px-3 text-xs font-medium text-muted-foreground transition;
}
.hero-panel::before {
content: "";
@apply absolute inset-x-0 top-0 h-40 bg-gradient-to-r from-secondary/80 via-card to-transparent;
.tabs-trigger[data-state="active"] {
@apply bg-background text-foreground shadow-xs;
}
.panel-grid {
@apply grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(21rem,0.9fr)];
}
.eyebrow {
@apply inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground;
}
.section-card {
@apply rounded-[1.5rem] border border-border/80 bg-card/90 shadow-lg shadow-stone-900/5;
}
.field-label {
@apply text-sm font-semibold text-foreground;
}
.field-hint {
@apply text-sm text-muted-foreground;
}
.surface-input {
@apply block w-full rounded-2xl border border-border bg-background/95 px-4 py-3 text-sm text-foreground shadow-xs outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20;
}
.surface-textarea {
@apply surface-input min-h-[18rem] resize-y font-mono leading-6;
}
.source-tab {
@apply inline-flex flex-1 cursor-pointer items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold text-muted-foreground transition;
}
.source-tab-active {
@apply bg-primary text-primary-foreground shadow-sm;
}
.source-panel {
@apply rounded-[1.5rem] border border-dashed border-border/80 bg-background/70 p-4;
}
.result-frame {
@apply mt-3 h-[36rem] w-full rounded-[1.25rem] border border-border bg-white shadow-inner;
.focus-ring {
@apply outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}