+templ Result(previewID, downloadID, fullHTML, filename string, sizeBytes int, lineCount int, elapsedMs int) {
+
- { msg }
+
}
+
+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)
+}
diff --git a/internal/ui/result_templ.go b/internal/ui/result_templ.go
index d2d6458..a28888a 100644
--- a/internal/ui/result_templ.go
+++ b/internal/ui/result_templ.go
@@ -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, "
")
- 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, "Файл: ")
- 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, "
Ссылки одноразовые: после первого успешного открытия соответствующий UUID удаляется из preview-store.
i Inline-превью в изолированном iframe
")
- 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, "
")
+ 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, "
Готово — ")
+ 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, "
")
+ 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, "
standalone Открыть превью ")
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, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
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, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
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
diff --git a/screen.png b/screen.png
new file mode 100644
index 0000000..f7e192f
Binary files /dev/null and b/screen.png differ
diff --git a/scripts/release-build.sh b/scripts/release-build.sh
new file mode 100755
index 0000000..996b4d4
--- /dev/null
+++ b/scripts/release-build.sh
@@ -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
diff --git a/tailwind.config.js b/tailwind.config.js
index dc17457..3054f1a 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -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"],
},
},
},
diff --git a/web/static/src/app.css b/web/static/src/app.css
index 43f4f2a..f710b06 100644
--- a/web/static/src/app.css
+++ b/web/static/src/app.css
@@ -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;
}
}