This commit is contained in:
maddalax 2024-10-10 17:00:20 -05:00
parent c0fabcedd2
commit 4880946515
33 changed files with 920 additions and 11 deletions

View file

@ -0,0 +1,11 @@
# Project exclude paths
/tmp/
node_modules/
dist/
js/dist
js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
__htmgo

6
examples/hackernews/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -0,0 +1,38 @@
# Stage 1: Build the Go binary
FROM golang:1.23-alpine AS builder
RUN apk update
RUN apk add git
RUN apk add curl
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download and cache the Go modules
RUN go mod download
# Copy the source code into the container
COPY . .
# Build the Go binary for Linux
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
# Stage 2: Create the smallest possible image
FROM gcr.io/distroless/base-debian11
# Set the working directory inside the container
WORKDIR /app
# Copy the Go binary from the builder stage
COPY --from=builder /app/dist .
# Expose the necessary port (replace with your server port)
EXPOSE 3000
# Command to run the binary
CMD ["./hackernews"]

View file

@ -0,0 +1,20 @@
version: '3'
tasks:
run:
cmds:
- htmgo run
silent: true
build:
cmds:
- htmgo build
docker:
cmds:
- docker build .
watch:
cmds:
- htmgo watch
silent: true

View file

@ -0,0 +1,13 @@
//go:build !prod
// +build !prod
package main
import (
"hackernews/internal/embedded"
"io/fs"
)
func GetStaticAssets() fs.FS {
return embedded.NewOsFs()
}

View file

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,16 @@
//go:build prod
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed assets/dist/*
var staticAssets embed.FS
func GetStaticAssets() fs.FS {
return staticAssets
}

View file

@ -0,0 +1,14 @@
package components
import "github.com/maddalax/htmgo/framework/h"
func Badge(text string, active bool, children ...h.Ren) *h.Element {
return h.Button(
h.Text(text),
h.ClassX("font-semibold px-3 py-1 rounded-full cursor-pointer h-[32px]", h.ClassMap{
"bg-rose-500 text-white": active,
"bg-neutral-300": !active,
}),
h.Children(children...),
)
}

View file

@ -0,0 +1,10 @@
module hackernews
go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/maddalax/htmgo/framework v0.0.0-20241009153712-95f9b43395b7
)
require github.com/google/uuid v1.6.0 // indirect

View file

@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241009153712-95f9b43395b7 h1:KzbU4UIVDc+ppklnwEEAAGLXpED16hUoGbbx1qhN7eo=
github.com/maddalax/htmgo/framework v0.0.0-20241009153712-95f9b43395b7/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,30 @@
package batch
import (
"sync"
)
func ParallelProcess[T any, Z any](items []T, concurrency int, cb func(item T) Z) []Z {
if len(items) == 0 {
return []Z{}
}
if len(items) == 1 {
return []Z{cb(items[0])}
}
results := make([]Z, len(items))
wg := sync.WaitGroup{}
sem := make(chan struct{}, concurrency)
for i, item := range items {
wg.Add(1)
sem <- struct{}{}
go func(item T) {
defer func() {
wg.Done()
<-sem
}()
results[i] = cb(item)
}(item)
}
wg.Wait()
return results
}

View file

@ -0,0 +1,17 @@
package embedded
import (
"io/fs"
"os"
)
type OsFs struct {
}
func (receiver OsFs) Open(name string) (fs.File, error) {
return os.Open(name)
}
func NewOsFs() OsFs {
return OsFs{}
}

View file

@ -0,0 +1,115 @@
package httpjson
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
var (
client *http.Client
once sync.Once // Consider allowing configuration parameters for the singleton
)
func getClient() *http.Client {
once.Do(func() {
client = &http.Client{
Timeout: 10 * time.Second,
}
})
return client
}
func Get[T any](url string) (*T, error) {
client := getClient()
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result T
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return &result, nil
}
func Post[T any](url string, data T) (*http.Response, error) {
client := getClient()
body, err := json.Marshal(data)
if err != nil {
return nil, err
}
resp, err := client.Post(url, "application/json", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp, nil
}
func Patch[T any](url string, data T) error {
client := getClient()
body, err := json.Marshal(data)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
func Delete(url string) error {
client := getClient()
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}

View file

@ -0,0 +1,137 @@
package news
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch"
"hackernews/internal/httpjson"
"hackernews/internal/timeformat"
"log/slog"
"strconv"
"time"
)
const baseUrl = "https://hacker-news.firebaseio.com/v0/"
func url(path string, qs *h.Qs) string {
return baseUrl + path + ".json?" + qs.ToString()
}
type Category struct {
Name string
Path string
}
var Categories = []Category{
{"Top Stories", "topstories"},
{"Best Stories", "beststories"},
{"New Stories", "newstories"},
}
type Comment struct {
By string `json:"by"`
Text string `json:"text"`
TimeRaw int64 `json:"time"`
Time time.Time `json:"-"`
Type string `json:"type"`
Kids []int `json:"kids"`
Parent int `json:"parent"`
Id int `json:"id"`
}
type Story struct {
Id int `json:"id"`
By string `json:"by"`
Text string `json:"text"`
Title string `json:"title"`
Type string `json:"type"`
Descendents int `json:"descendants"`
Score int `json:"score"`
Url string
TimeRaw int64 `json:"time"`
Time time.Time `json:"-"`
// comment ids
Kids []int
}
type GetTopStoriesRequest struct {
Limit int
Page int
}
func MustItemId(ctx *h.RequestContext) int {
raw := h.GetQueryParam(ctx, "item")
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return int(parsed)
}
func GetStories(category string, page int, limit int) []Story {
top, err := httpjson.Get[[]int](url(category, h.NewQs()))
if err != nil {
slog.Error("failed to load top stories", slog.String("err", err.Error()))
return make([]Story, 0)
}
ids := *top
start := page * limit
end := start + limit
return batch.ParallelProcess[int, Story](
ids[start:end],
50,
func(id int) Story {
story, err := GetStory(id)
if err != nil {
slog.Error("failed to load story", slog.Int("id", id), slog.String("err", err.Error()))
return Story{}
}
return *story
},
)
}
func GetTopStories(page int, limit int) []Story {
return GetStories("topstories", page, limit)
}
func GetBestStories(page int, limit int) []Story {
return GetStories("beststories", page, limit)
}
func GetNewStories(page int, limit int) []Story {
return GetStories("newstories", page, limit)
}
func GetComments(ids []int) []Comment {
return batch.ParallelProcess(
ids,
50,
func(id int) Comment {
comment, err := GetComment(id)
if err != nil {
slog.Error("failed to load comment", slog.Int("id", id), slog.String("err", err.Error()))
return Comment{}
}
return *comment
},
)
}
func GetComment(id int) (*Comment, error) {
c, err := httpjson.Get[Comment](url(fmt.Sprintf("item/%d", id), h.NewQs()))
if err != nil {
return nil, err
}
c.Time = timeformat.ParseUnix(c.TimeRaw)
return c, nil
}
func GetStory(id int) (*Story, error) {
s, err := httpjson.Get[Story](url(fmt.Sprintf("item/%d", id), h.NewQs()))
if err != nil {
return nil, err
}
s.Time = timeformat.ParseUnix(s.TimeRaw)
return s, nil
}

View file

@ -0,0 +1,13 @@
package internal
import "math/rand"
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

View file

@ -0,0 +1,39 @@
package timeformat
import (
"fmt"
"time"
)
func ParseUnix(t int64) time.Time {
return time.UnixMilli(t * 1000)
}
func RelativeTime(t time.Time) string {
now := time.Now()
diff := now.Sub(t)
var pluralize = func(s string) string {
if s[0] == '1' {
return s[:len(s)-5] + " ago"
}
return s
}
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
return pluralize(fmt.Sprintf("%d minutes ago", int(diff.Minutes())))
case diff < time.Hour*24:
return pluralize(fmt.Sprintf("%d hours ago", int(diff.Hours())))
case diff < time.Hour*24*7:
return pluralize(fmt.Sprintf("%d days ago", int(diff.Hours()/24)))
case diff < time.Hour*24*30:
return pluralize(fmt.Sprintf("%d weeks ago", int(diff.Hours()/(24*7))))
case diff < time.Hour*24*365:
return pluralize(fmt.Sprintf("%d months ago", int(diff.Hours()/(24*30))))
default:
return pluralize(fmt.Sprintf("%d years ago", int(diff.Hours()/(24*365))))
}
}

View file

@ -0,0 +1,30 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"hackernews/__htmgo"
"io/fs"
"net/http"
)
func main() {
locator := service.NewLocator()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -0,0 +1,21 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"hackernews/partials"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
h.Div(
h.Class("flex gap-2 min-h-screen"),
partials.StorySidebar(ctx),
h.Main(
h.Class("flex justify-center items-start p-6 max-w-3xl min-w-3xl mx-auto"),
partials.Story(ctx),
),
),
),
)
}

View file

@ -0,0 +1,32 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) h.Ren {
return h.Html(
h.HxExtensions(h.BaseExtensions()),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"),
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
h.Meta("title", "htmgo template"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
h.Meta("description", "this is a template"),
h.Meta("og:title", "htmgo template"),
h.Meta("og:url", "https://htmgo.dev"),
h.Link("canonical", "https://htmgo.dev"),
h.Meta("og:description", "this is a template"),
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
h.Script("/public/custom.js"),
),
h.Body(
h.Div(
h.Fragment(children...),
),
),
)
}

View file

@ -0,0 +1,84 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/batch"
"hackernews/internal/news"
"hackernews/internal/timeformat"
"strings"
"time"
)
func StoryComments(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Class("flex flex-col gap-3 prose max-w-none"),
CachedStoryComments(news.MustItemId(ctx)),
))
}
var CachedStoryComments = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) {
return fmt.Sprintf("story-comments-%d", itemId), func() *h.Element {
story, err := news.GetStory(itemId)
if err != nil {
return h.Div(
h.Text("Failed to load story"),
)
}
comments := news.GetComments(story.Kids)
// parallel process because each comment needs to load its children comments
items := batch.ParallelProcess[news.Comment, *h.Element](comments, 50, func(item news.Comment) *h.Element {
return Comment(item, 0)
})
return h.List(items, func(item *h.Element, index int) *h.Element {
return item
})
}
})
func Comment(item news.Comment, nesting int) *h.Element {
if item.Text == "" {
return h.Empty()
}
children := news.GetComments(item.Kids)
return h.Div(
h.ClassX("block bg-white pb-2 pt-2", h.ClassMap{
"border-b border-gray-200": nesting == 0,
"border-l border-gray-200": nesting > 0,
}),
h.If(nesting > 0, h.Attribute("style", fmt.Sprintf("margin-left: %dpx", (nesting-1)*15))),
h.Div(
h.If(nesting > 0, h.Class("pl-4")),
h.Div(
h.Class("flex gap-1 items-center"),
h.Div(
h.Class("font-bold text-rose-500"),
h.UnsafeRaw(item.By),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw("&bull;"),
h.TextF(" %s", timeformat.RelativeTime(item.Time)),
),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(strings.TrimSpace(item.Text)),
),
),
h.If(len(children) > 0, h.List(
children, func(child news.Comment, index int) *h.Element {
return h.Div(
Comment(child, nesting+1),
)
},
)),
)
}

View file

@ -0,0 +1,126 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/hx"
"hackernews/components"
"hackernews/internal/news"
"hackernews/internal/timeformat"
"time"
)
// @lang js
var ScrollJs = `
const scrollContainer = self;
let isDown = false;
let startX;
let scrollLeft;
scrollContainer.addEventListener("mousedown", (e) => {
isDown = true;
scrollContainer.classList.add("active");
startX = e.pageX - scrollContainer.offsetLeft;
scrollLeft = scrollContainer.scrollLeft;
});
scrollContainer.addEventListener("mouseleave", () => {
isDown = false;
scrollContainer.classList.remove("active");
});
scrollContainer.addEventListener("mouseup", () => {
isDown = false;
scrollContainer.classList.remove("active");
});
scrollContainer.addEventListener("mousemove", (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - scrollContainer.offsetLeft;
const walk = (x - startX) * 3; // Adjust scroll speed here
scrollContainer.scrollLeft = scrollLeft - walk;
});
`
func StorySidebar(ctx *h.RequestContext) *h.Partial {
category := h.GetQueryParam(ctx, "category")
if category == "" {
category = "topstories"
}
body := h.Aside(
h.Id("story-sidebar"),
h.JoinExtensions(h.TriggerChildren()),
h.Class("sticky top-0 h-screen p-1 bg-gray-100 overflow-y-auto max-w-80 min-w-80"),
h.Div(
h.Class("flex flex-col gap-1"),
SidebarTitle(category),
CachedStoryList(category, 0, 50),
),
)
if ctx.IsHxRequest() {
return h.SwapManyPartial(ctx, body)
}
return h.NewPartial(body)
}
func SidebarTitle(defaultCategory string) *h.Element {
today := time.Now().Format("Mon, 02 Jan 2006")
return h.Div(
h.Class("flex flex-col px-2 pt-4 pb-2"),
h.Div(
h.Class("text-sm text-gray-600"),
h.Text(today),
),
h.Div(
h.Class("font-bold text-xl"),
h.Text("Hacker News"),
),
h.Div(
h.OnEvent(hx.LoadDomEvent, h.EvalJs(ScrollJs)),
h.OnEvent(hx.LoadEvent, h.EvalJs(ScrollJs)),
h.Class("scroll-container mt-2 flex gap-1 no-scrollbar overflow-y-hidden whitespace-nowrap overflow-x-auto"),
h.List(news.Categories, func(item news.Category, index int) *h.Element {
return CategoryBadge(defaultCategory, item)
}),
),
)
}
func CategoryBadge(defaultCategory string, category news.Category) *h.Element {
selected := category.Path == defaultCategory
return components.Badge(
category.Name,
selected,
h.Attribute("hx-swap", "none"),
h.If(!selected, h.PostPartialOnClickQs(StorySidebar, h.NewQs("category", category.Path))),
)
}
var CachedStoryList = h.CachedPerKeyT3(time.Minute*5, func(category string, page int, limit int) (string, h.GetElementFunc) {
return fmt.Sprintf("%s-stories-%d-%d", category, page, limit), func() *h.Element {
stories := news.GetStories(category, page, limit)
return h.List(stories, func(item news.Story, index int) *h.Element {
return h.Div(
h.Attribute("hx-swap", "none"),
h.PostPartialOnClickQs(Story, h.NewQs("item", fmt.Sprintf("%d", item.Id))),
h.A(h.Href(item.Url)),
h.Class("block p-2 bg-white rounded-md shadow cursor-pointer"),
h.Div(
h.Class("font-bold"),
h.UnsafeRaw(item.Title),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.Div(h.TextF("%s ", item.By), h.UnsafeRaw("&bull;"), h.TextF(" %s", timeformat.RelativeTime(item.Time))),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(fmt.Sprintf("%d upvotes &bull; %d comments", item.Score, item.Descendents)),
),
)
})
}
})

View file

@ -0,0 +1,79 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"hackernews/internal/news"
"hackernews/internal/timeformat"
"strconv"
)
func Story(ctx *h.RequestContext) *h.Partial {
storyId, err := strconv.ParseInt(ctx.QueryParam("item"), 10, 64)
if err != nil {
return h.SwapManyPartial(
ctx,
h.Div(
h.Text("Invalid story id"),
),
)
}
story, err := news.GetStory(int(storyId))
if err != nil {
return h.SwapManyPartial(
ctx,
h.Div(
h.Text("Failed to load story"),
),
)
}
if ctx.IsHxRequest() {
return h.SwapManyPartialWithHeaders(
ctx,
h.PushUrlHeader(fmt.Sprintf("/?item=%d", storyId)),
StoryBody(story),
)
}
return h.NewPartial(
StoryBody(story),
)
}
func StoryBody(story *news.Story) *h.Element {
return h.Div(
h.Id("story-body"),
h.Div(
h.Class("prose prose-2xl bg-white border-b border-gray-200 pb-3 max-w-3xl"),
h.H5(
h.Class("flex gap-2 items-center font-bold"),
h.UnsafeRaw(story.Title),
),
h.A(
h.Href(story.Url),
h.Class("text-sm text-rose-400 no-underline"),
h.Text(story.Url),
),
h.Div(
h.Class("text-sm text-gray-600"),
h.UnsafeRaw(story.Text),
),
h.Div(
h.Class("text-sm text-gray-600 mt-2"),
h.TextF("%d upvotes ", story.Score),
h.UnsafeRaw("&bull;"),
h.TextF(" %s ", story.By),
h.UnsafeRaw("&bull;"),
h.TextF(" %s", timeformat.RelativeTime(story.Time)),
),
),
h.Div(
h.Class("mt-2 max-w-3xl"),
h.GetPartial(StoryComments, "load"),
),
)
}

View file

@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["**/*.go"],
plugins: [
require('@tailwindcss/typography')
],
};

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,22 @@ import "./htmxextensions/livereload"
import "./htmxextensions/htmgo";
import "./htmxextensions/sse"
/**
* Browser doesn't support onload for all elements, so we need to manually trigger it
* this is useful for locality of behavior
*/
window.onload = function() {
const ignored = ['SCRIPT', 'LINK', 'STYLE', 'META', 'BASE', 'TITLE', 'HEAD', 'HTML', 'BODY'];
for (let element of Array.from(document.querySelectorAll(`[onload]`))) {
if(element != null && element instanceof HTMLElement) {
if(ignored.includes(element.tagName)) {
continue
}
element.onload!(new Event("load"));
}
}
}
// @ts-ignore
window.htmx = htmx;

View file

@ -4,7 +4,7 @@ function kebabEventName(str: string) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:beforeOnLoad', 'htmx:afterOnLoad', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
function makeEvent(eventName: string, detail: any) {
let evt
@ -28,13 +28,15 @@ function triggerChildren(target: HTMLElement, name: string, event: CustomEvent,
const eventName = kehab.replace("htmx:", "hx-on::")
if (!triggered.has(e as HTMLElement)) {
if(e.hasAttribute(eventName)) {
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), {
...event.detail,
target: e,
})
newEvent.detail.meta = 'trigger-children'
e.dispatchEvent(newEvent)
triggered.add(e as HTMLElement);
setTimeout(() => {
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), {
...event.detail,
target: e,
})
newEvent.detail.meta = 'trigger-children'
e.dispatchEvent(newEvent)
triggered.add(e as HTMLElement);
}, 1)
}
if (e.children) {
triggerChildren(e as HTMLElement, name, event, triggered);

View file

@ -6,7 +6,7 @@ import (
func RootPage(children ...h.Ren) h.Ren {
return h.Html(
h.HxExtension(h.BaseExtensions()),
h.HxExtensions(h.BaseExtensions()),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"),