diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6cffaaf --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [maddalax] diff --git a/.github/workflows/release-chat-example.yml b/.github/workflows/release-chat-example.yml index 0302bd9..6f87767 100644 --- a/.github/workflows/release-chat-example.yml +++ b/.github/workflows/release-chat-example.yml @@ -1,6 +1,10 @@ name: Build and Deploy htmgo.dev chat example on: + workflow_run: + workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow + types: + - completed pull_request: branches: - master diff --git a/.github/workflows/release-hn-clone.yml b/.github/workflows/release-hn-clone.yml new file mode 100644 index 0000000..f25e942 --- /dev/null +++ b/.github/workflows/release-hn-clone.yml @@ -0,0 +1,52 @@ +name: Build and Deploy htmgo hackernews clone + +on: + workflow_run: + workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow + types: + - completed + workflow_dispatch: # Trigger on manual workflow_dispatch + push: + branches: + - master # Trigger on pushes to master + paths: + - 'examples/hackernews/**' # Trigger only if files in this directory change + - "framework-ui/**" + - "cli/**" + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get short commit hash + id: vars + run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)" + + - name: Build Docker image + run: | + cd ./examples/hackernews && docker build -t ghcr.io/${{ github.repository_owner }}/hackernews:${{ steps.vars.outputs.short_sha }} . + + - name: Tag as latest Docker image + run: | + docker tag ghcr.io/${{ github.repository_owner }}/hackernews:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/hackernews:latest + + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push Docker image + run: | + docker push ghcr.io/${{ github.repository_owner }}/hackernews:latest diff --git a/.github/workflows/release-site.yml b/.github/workflows/release-site.yml index 83b9c46..f3ab2ab 100644 --- a/.github/workflows/release-site.yml +++ b/.github/workflows/release-site.yml @@ -1,13 +1,16 @@ name: Build and Deploy htmgo.dev on: + workflow_run: + workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow + types: + - completed workflow_dispatch: # Trigger on manual workflow_dispatch push: branches: - master # Trigger on pushes to master paths: - 'htmgo-site/**' # Trigger only if files in this directory change - - "framework/**" - "framework-ui/**" - "cli/**" @@ -46,4 +49,4 @@ jobs: - name: Push Docker image run: | - docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest \ No newline at end of file + docker push ghcr.io/${{ github.repository_owner }}/htmgo-site:latest diff --git a/.github/workflows/release-starter-template.yml b/.github/workflows/release-starter-template.yml index da3167c..1d22898 100644 --- a/.github/workflows/release-starter-template.yml +++ b/.github/workflows/release-starter-template.yml @@ -1,6 +1,10 @@ name: Build and Deploy starter template on: + workflow_run: + workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow + types: + - completed workflow_dispatch: # Trigger on manual workflow_dispatch push: branches: @@ -43,4 +47,4 @@ jobs: - name: Push Docker image run: | - docker push ghcr.io/${{ github.repository_owner }}/starter-template:latest \ No newline at end of file + docker push ghcr.io/${{ github.repository_owner }}/starter-template:latest diff --git a/.github/workflows/release-todo-example.yml b/.github/workflows/release-todo-example.yml index 4fce8fb..a81c2af 100644 --- a/.github/workflows/release-todo-example.yml +++ b/.github/workflows/release-todo-example.yml @@ -1,6 +1,10 @@ name: Build and Deploy htmgo.dev todo example on: + workflow_run: + workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow + types: + - completed workflow_dispatch: # Trigger on manual workflow_dispatch push: branches: @@ -43,4 +47,4 @@ jobs: - name: Push Docker image run: | - docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest \ No newline at end of file + docker push ghcr.io/${{ github.repository_owner }}/htmgo-todo-example:latest diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7edfd10 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +maddox@htmgo.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/README.md b/README.md index a578770..c4be27b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!WARNING] -> htmgo is in alpha release and active development. API's likely will have breaking changes. Do not use this library at this time if you are expecting a rock solid stable api that will require no migrations. Please report any issues on GitHub. +> htmgo is in alpha release and active development. API's may have breaking changes between versions. Please report any issues on GitHub. ## **htmgo** @@ -40,3 +40,7 @@ func IndexPage(ctx *h.RequestContext) *h.Page { **get started:** View documentation on [htmgo.dev](https://htmgo.dev/docs). + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=maddalax/htmgo&type=Date)](https://star-history.com/#maddalax/htmgo&Date) diff --git a/cli/htmgo/go.mod b/cli/htmgo/go.mod index 6bad2bb..ba191b9 100644 --- a/cli/htmgo/go.mod +++ b/cli/htmgo/go.mod @@ -11,3 +11,5 @@ require ( golang.org/x/sys v0.25.0 golang.org/x/tools v0.25.0 ) + +require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect diff --git a/cli/htmgo/go.sum b/cli/htmgo/go.sum index b8b03a7..87e7902 100644 --- a/cli/htmgo/go.sum +++ b/cli/htmgo/go.sum @@ -1,3 +1,5 @@ +github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= +github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= diff --git a/cli/htmgo/htmltogo/entry.go b/cli/htmgo/htmltogo/entry.go deleted file mode 100644 index 9406939..0000000 --- a/cli/htmgo/htmltogo/entry.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "log" - "strings" - - "github.com/dave/jennifer/jen" - "golang.org/x/net/html" -) - -func main() { - // Example HTML input - htmlData := ` -

Manage Patients

Name: Sydne

Reason for visit: arm hurts

- ` - - // Parse the HTML - doc, err := html.Parse(bytes.NewReader([]byte(htmlData))) - if err != nil { - log.Fatal(err) - } - - // Create a new Jennifer file - f := jen.NewFile("main") - - // Generate Jennifer code for the parsed HTML tree - generatedCode := processNode(doc.FirstChild) - - // Add the generated code to the file - f.Func().Id("Render").Params().Block(generatedCode...) - - // Render the generated code - var buf bytes.Buffer - err = f.Render(&buf) - if err != nil { - log.Fatal(err) - } - - //// Format the generated code - //formattedCode, err := format.Source(buf.Bytes()) - //if err != nil { - // log.Fatal(err) - //} - - // Output the formatted code - fmt.Println(string(buf.Bytes())) -} - -// Recursively process the HTML nodes and generate Jennifer code -func processNode(n *html.Node) []jen.Code { - var code []jen.Code - - // Only process element nodes - if n.Type == html.ElementNode { - // Create a dynamic method call based on the tag name - tagMethod := strings.Title(n.Data) // Capitalize the first letter of the tag - - // Add dynamic method call for the tag (e.g., h.Div(), h.Button(), etc.) - code = append(code, jen.Id("h").Dot(tagMethod).Call(mergeArgs(n)...)) - } - - return code -} - -// Merge attributes and children into a single slice for Call() -func mergeArgs(n *html.Node) []jen.Code { - // Process attributes - attrs := processAttributes(n.Attr) - - // Process children - children := processChildren(n) - - // Combine attributes and children into one slice - return append(attrs, children...) -} - -// Process child nodes of a given HTML node -func processChildren(n *html.Node) []jen.Code { - var children []jen.Code - - for c := n.FirstChild; c != nil; c = c.NextSibling { - children = append(children, processNode(c)...) - } - - return children -} - -func FormatFieldName(name string) string { - split := strings.Split(name, "_") - if strings.Contains(name, "-") { - split = strings.Split(name, "-") - } - parts := make([]string, 0) - for _, s := range split { - parts = append(parts, PascalCase(s)) - } - return strings.Join(parts, "") -} - -func PascalCase(s string) string { - if s == "" { - return s - } - // Convert the first rune (character) to uppercase and concatenate with the rest of the string - return strings.ToUpper(string(s[0])) + s[1:] -} - -// Process the attributes of an HTML node and return Jennifer code -func processAttributes(attrs []html.Attribute) []jen.Code { - var args []jen.Code - for _, attr := range attrs { - // Dynamically handle all attributes - attrMethod := FormatFieldName(attr.Key) // E.g., convert "data-role" to "DataRole" - args = append(args, jen.Id("h").Dot(attrMethod).Call(jen.Lit(attr.Val))) - } - return args -} diff --git a/cli/htmgo/internal/dirutil/dir.go b/cli/htmgo/internal/dirutil/dir.go index 2715277..7063e83 100644 --- a/cli/htmgo/internal/dirutil/dir.go +++ b/cli/htmgo/internal/dirutil/dir.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "github.com/maddalax/htmgo/cli/htmgo/tasks/process" + "github.com/maddalax/htmgo/framework/config" "io" "log/slog" "os" @@ -17,6 +18,10 @@ func HasFileFromRoot(file string) bool { return err == nil } +func GetConfig() *config.ProjectConfig { + return config.FromConfigFile(process.GetWorkingDir()) +} + func CreateHtmgoDir() { if !HasFileFromRoot("__htmgo") { CreateDirFromRoot("__htmgo") diff --git a/cli/htmgo/internal/dirutil/glob.go b/cli/htmgo/internal/dirutil/glob.go new file mode 100644 index 0000000..1315c66 --- /dev/null +++ b/cli/htmgo/internal/dirutil/glob.go @@ -0,0 +1,31 @@ +package dirutil + +import ( + "fmt" + "github.com/bmatcuk/doublestar/v4" +) + +func matchesAny(patterns []string, path string) bool { + for _, pattern := range patterns { + matched, err := doublestar.Match(pattern, path) + if err != nil { + fmt.Printf("Error matching pattern: %v\n", err) + return false + } + if matched { + return true + } + } + return false +} + +func IsGlobExclude(path string, excludePatterns []string) bool { + return matchesAny(excludePatterns, path) +} + +func IsGlobMatch(path string, patterns []string, excludePatterns []string) bool { + if matchesAny(excludePatterns, path) { + return false + } + return matchesAny(patterns, path) +} diff --git a/cli/htmgo/tasks/copyassets/bundle.go b/cli/htmgo/tasks/copyassets/bundle.go index a24da76..f0f7699 100644 --- a/cli/htmgo/tasks/copyassets/bundle.go +++ b/cli/htmgo/tasks/copyassets/bundle.go @@ -92,7 +92,7 @@ func CopyAssets() { }) } - if !dirutil.HasFileFromRoot("tailwind.config.js") { + if dirutil.GetConfig().Tailwind && !dirutil.HasFileFromRoot("tailwind.config.js") { err = dirutil.CopyFile( filepath.Join(assetCssDir, "tailwind.config.js"), filepath.Join(process.GetWorkingDir(), "tailwind.config.js"), diff --git a/cli/htmgo/tasks/css/css.go b/cli/htmgo/tasks/css/css.go index 0bdaf58..1c07ec2 100644 --- a/cli/htmgo/tasks/css/css.go +++ b/cli/htmgo/tasks/css/css.go @@ -12,7 +12,7 @@ import ( ) func IsTailwindEnabled() bool { - return dirutil.HasFileFromRoot("tailwind.config.js") + return dirutil.GetConfig().Tailwind && dirutil.HasFileFromRoot("tailwind.config.js") } func Setup() bool { diff --git a/cli/htmgo/watcher.go b/cli/htmgo/watcher.go index f2a0ee8..4838d14 100644 --- a/cli/htmgo/watcher.go +++ b/cli/htmgo/watcher.go @@ -4,6 +4,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/google/uuid" "github.com/maddalax/htmgo/cli/htmgo/internal" + "github.com/maddalax/htmgo/cli/htmgo/internal/dirutil" "github.com/maddalax/htmgo/cli/htmgo/tasks/module" "log" "log/slog" @@ -13,11 +14,10 @@ import ( "time" ) -var ignoredDirs = []string{".git", ".idea", "node_modules", "vendor"} - func startWatcher(cb func(version string, file []*fsnotify.Event)) { events := make([]*fsnotify.Event, 0) debouncer := internal.NewDebouncer(500 * time.Millisecond) + config := dirutil.GetConfig() defer func() { if r := recover(); r != nil { @@ -38,8 +38,38 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) { if !ok { return } - slog.Debug("event:", slog.String("name", event.Name), slog.String("op", event.Op.String())) + + if event.Has(fsnotify.Remove) { + if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) { + watcher.Remove(event.Name) + continue + } + } + + if event.Has(fsnotify.Create) { + if dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) { + watcher.Add(event.Name) + continue + } + info, err := os.Stat(event.Name) + if err != nil { + slog.Error("Error getting file info:", slog.String("path", event.Name), slog.String("error", err.Error())) + continue + } + if info.IsDir() { + err = watcher.Add(event.Name) + if err != nil { + slog.Error("Error adding directory to watcher:", slog.String("path", event.Name), slog.String("error", err.Error())) + } else { + slog.Debug("Watching directory:", slog.String("path", event.Name)) + } + } + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + if !dirutil.IsGlobMatch(event.Name, config.WatchFiles, config.WatchIgnore) { + continue + } events = append(events, &event) debouncer.Do(func() { seen := make(map[string]bool) @@ -54,6 +84,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) { events = make([]*fsnotify.Event, 0) }) } + case err, ok := <-watcher.Errors: if !ok { return @@ -79,11 +110,10 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) { return err } // Ignore directories in the ignoredDirs list - for _, ignoredDir := range ignoredDirs { - if ignoredDir == info.Name() { - return filepath.SkipDir - } + if dirutil.IsGlobExclude(path, config.WatchIgnore) { + return filepath.SkipDir } + // Only watch directories if info.IsDir() { err = watcher.Add(path) @@ -95,6 +125,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) { } return nil }) + if err != nil { log.Fatal(err) } diff --git a/examples/chat/go.mod b/examples/chat/go.mod index c769fb2..4add787 100644 --- a/examples/chat/go.mod +++ b/examples/chat/go.mod @@ -5,7 +5,7 @@ go 1.23.0 require ( github.com/go-chi/chi/v5 v5.1.0 github.com/google/uuid v1.6.0 - github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b + github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d github.com/mattn/go-sqlite3 v1.14.23 github.com/puzpuzpuz/xsync/v3 v3.4.0 ) diff --git a/examples/chat/go.sum b/examples/chat/go.sum index 53ff112..c07b1b7 100644 --- a/examples/chat/go.sum +++ b/examples/chat/go.sum @@ -4,8 +4,8 @@ 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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/examples/sse-with-state/.dockerignore b/examples/hackernews/.dockerignore similarity index 100% rename from examples/sse-with-state/.dockerignore rename to examples/hackernews/.dockerignore diff --git a/examples/sse-with-state/.gitignore b/examples/hackernews/.gitignore similarity index 100% rename from examples/sse-with-state/.gitignore rename to examples/hackernews/.gitignore diff --git a/examples/sse-with-state/Dockerfile b/examples/hackernews/Dockerfile similarity index 97% rename from examples/sse-with-state/Dockerfile rename to examples/hackernews/Dockerfile index a522f64..37c299d 100644 --- a/examples/sse-with-state/Dockerfile +++ b/examples/hackernews/Dockerfile @@ -35,4 +35,4 @@ EXPOSE 3000 # Command to run the binary -CMD ["./sse-with-state"] +CMD ["./hackernews"] diff --git a/examples/hackernews/Taskfile.yml b/examples/hackernews/Taskfile.yml new file mode 100644 index 0000000..28f1902 --- /dev/null +++ b/examples/hackernews/Taskfile.yml @@ -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 diff --git a/examples/sse-with-state/assets.go b/examples/hackernews/assets.go similarity index 78% rename from examples/sse-with-state/assets.go rename to examples/hackernews/assets.go index 63021d1..8104b98 100644 --- a/examples/sse-with-state/assets.go +++ b/examples/hackernews/assets.go @@ -4,8 +4,8 @@ package main import ( + "hackernews/internal/embedded" "io/fs" - "sse-with-state/internal/embedded" ) func GetStaticAssets() fs.FS { diff --git a/examples/hackernews/assets/css/input.css b/examples/hackernews/assets/css/input.css new file mode 100644 index 0000000..404b710 --- /dev/null +++ b/examples/hackernews/assets/css/input.css @@ -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 */ + } +} diff --git a/examples/hackernews/assets/public/apple-touch-icon.png b/examples/hackernews/assets/public/apple-touch-icon.png new file mode 100644 index 0000000..d10e9fe Binary files /dev/null and b/examples/hackernews/assets/public/apple-touch-icon.png differ diff --git a/examples/hackernews/assets/public/favicon.ico b/examples/hackernews/assets/public/favicon.ico new file mode 100644 index 0000000..040cccf Binary files /dev/null and b/examples/hackernews/assets/public/favicon.ico differ diff --git a/examples/hackernews/assets/public/icon-192-maskable.png b/examples/hackernews/assets/public/icon-192-maskable.png new file mode 100644 index 0000000..d4d6efb Binary files /dev/null and b/examples/hackernews/assets/public/icon-192-maskable.png differ diff --git a/examples/hackernews/assets/public/icon-192.png b/examples/hackernews/assets/public/icon-192.png new file mode 100644 index 0000000..f533435 Binary files /dev/null and b/examples/hackernews/assets/public/icon-192.png differ diff --git a/examples/hackernews/assets/public/icon-512-maskable.png b/examples/hackernews/assets/public/icon-512-maskable.png new file mode 100644 index 0000000..db61f3d Binary files /dev/null and b/examples/hackernews/assets/public/icon-512-maskable.png differ diff --git a/examples/hackernews/assets/public/icon-512.png b/examples/hackernews/assets/public/icon-512.png new file mode 100644 index 0000000..ba0665d Binary files /dev/null and b/examples/hackernews/assets/public/icon-512.png differ diff --git a/examples/sse-with-state/assets_prod.go b/examples/hackernews/assets_prod.go similarity index 100% rename from examples/sse-with-state/assets_prod.go rename to examples/hackernews/assets_prod.go diff --git a/examples/hackernews/components/badge.go b/examples/hackernews/components/badge.go new file mode 100644 index 0000000..dd97f60 --- /dev/null +++ b/examples/hackernews/components/badge.go @@ -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...), + ) +} diff --git a/examples/hackernews/go.mod b/examples/hackernews/go.mod new file mode 100644 index 0000000..8b1ea31 --- /dev/null +++ b/examples/hackernews/go.mod @@ -0,0 +1,10 @@ +module hackernews + +go 1.23.0 + +require github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d + +require ( + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect +) diff --git a/examples/sse-with-state/go.sum b/examples/hackernews/go.sum similarity index 56% rename from examples/sse-with-state/go.sum rename to examples/hackernews/go.sum index 94d5634..531d9d1 100644 --- a/examples/sse-with-state/go.sum +++ b/examples/hackernews/go.sum @@ -2,23 +2,15 @@ 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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= -github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/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/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= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hackernews/internal/batch/parallel.go b/examples/hackernews/internal/batch/parallel.go new file mode 100644 index 0000000..6f03e5f --- /dev/null +++ b/examples/hackernews/internal/batch/parallel.go @@ -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 +} diff --git a/examples/sse-with-state/internal/embedded/os.go b/examples/hackernews/internal/embedded/os.go similarity index 100% rename from examples/sse-with-state/internal/embedded/os.go rename to examples/hackernews/internal/embedded/os.go diff --git a/examples/hackernews/internal/httpjson/client.go b/examples/hackernews/internal/httpjson/client.go new file mode 100644 index 0000000..4c1e5ce --- /dev/null +++ b/examples/hackernews/internal/httpjson/client.go @@ -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 +} diff --git a/examples/hackernews/internal/news/news.go b/examples/hackernews/internal/news/news.go new file mode 100644 index 0000000..1b0c778 --- /dev/null +++ b/examples/hackernews/internal/news/news.go @@ -0,0 +1,146 @@ +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 + + if start > len(ids) { + return make([]Story, 0) + } + + if end > len(ids) { + end = len(ids) + } + + 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 +} diff --git a/examples/hackernews/internal/parse/parse.go b/examples/hackernews/internal/parse/parse.go new file mode 100644 index 0000000..ffd1281 --- /dev/null +++ b/examples/hackernews/internal/parse/parse.go @@ -0,0 +1,11 @@ +package parse + +import "strconv" + +func MustParseInt(s string, fallback int) int { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fallback + } + return int(v) +} diff --git a/examples/sse-with-state/internal/random.go b/examples/hackernews/internal/random.go similarity index 100% rename from examples/sse-with-state/internal/random.go rename to examples/hackernews/internal/random.go diff --git a/examples/hackernews/internal/timeformat/time.go b/examples/hackernews/internal/timeformat/time.go new file mode 100644 index 0000000..62af620 --- /dev/null +++ b/examples/hackernews/internal/timeformat/time.go @@ -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)))) + } +} diff --git a/examples/hackernews/main.go b/examples/hackernews/main.go new file mode 100644 index 0000000..1d38712 --- /dev/null +++ b/examples/hackernews/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "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("/item", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + w.Header().Set("Location", fmt.Sprintf("/?item=%s", id)) + w.WriteHeader(302) + })) + app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub))) + __htmgo.Register(app.Router) + }, + }) +} diff --git a/examples/hackernews/pages/index.go b/examples/hackernews/pages/index.go new file mode 100644 index 0000000..44cc3fd --- /dev/null +++ b/examples/hackernews/pages/index.go @@ -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-left items-start p-6 w-full"), + partials.Story(ctx), + ), + ), + ), + ) +} diff --git a/examples/hackernews/pages/root.go b/examples/hackernews/pages/root.go new file mode 100644 index 0000000..1df43ef --- /dev/null +++ b/examples/hackernews/pages/root.go @@ -0,0 +1,38 @@ +package pages + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func RootPage(children ...h.Ren) h.Ren { + banner := h.A(h.Class("bg-neutral-200 text-neutral-600 text-center p-2 flex items-center justify-center"), + h.Href("https://github.com/maddalax/htmgo"), + h.Attribute("target", "_blank"), + h.Text("Built with htmgo.dev"), + ) + + 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", "hackernews"), + h.Meta("charset", "utf-8"), + h.Meta("author", "htmgo"), + h.Meta("description", "hacker news reader, built with htmgo"), + h.Meta("og:title", "hacker news reader"), + h.Meta("og:url", "https://hn.htmgo.dev"), + h.Link("canonical", "https://hn.htmgo.dev"), + h.Meta("og:description", "hacker news reader, built with htmgo"), + h.Link("/public/main.css", "stylesheet"), + h.Script("/public/htmgo.js"), + ), + h.Body( + banner, + h.Div( + h.Fragment(children...), + ), + ), + ) +} diff --git a/examples/hackernews/partials/comments.go b/examples/hackernews/partials/comments.go new file mode 100644 index 0000000..c0583fe --- /dev/null +++ b/examples/hackernews/partials/comments.go @@ -0,0 +1,88 @@ +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.Fragment( + h.OobSwap(ctx, h.Div(h.Id("comments-loader"))), + 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("•"), + 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), + ) + }, + )), + ) +} diff --git a/examples/hackernews/partials/sidebar.go b/examples/hackernews/partials/sidebar.go new file mode 100644 index 0000000..c850d09 --- /dev/null +++ b/examples/hackernews/partials/sidebar.go @@ -0,0 +1,151 @@ +package partials + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "hackernews/components" + "hackernews/internal/news" + "hackernews/internal/parse" + "hackernews/internal/timeformat" + "time" +) + +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") + pageRaw := h.GetQueryParam(ctx, "page") + mode := h.GetQueryParam(ctx, "mode") + + if pageRaw == "" { + pageRaw = "0" + } + + if category == "" { + category = "topstories" + } + + page := parse.MustParseInt(pageRaw, 0) + + fetchMorePath := h.GetPartialPathWithQs(StorySidebar, h.NewQs("mode", "infinite", "page", fmt.Sprintf("%d", page+1), "category", category)) + + list := CachedStoryList(category, page, 50, fetchMorePath) + + 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), + h.Id("story-list"), + list, + ), + ) + + if mode == "infinite" { + return h.NewPartial( + list, + ) + } + + 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.OnLoad(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.CachedPerKeyT4(time.Minute*5, func(category string, page int, limit int, fetchMorePath string) (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("•"), h.TextF(" %s", timeformat.RelativeTime(item.Time))), + ), + h.Div( + h.Class("text-sm text-gray-600"), + h.UnsafeRaw(fmt.Sprintf("%d upvotes • %d comments", item.Score, item.Descendents)), + ), + h.If(index == len(stories)-1, h.Div( + h.Id("load-more"), + h.Attribute("hx-swap", "beforeend"), + h.HxTarget("#story-list"), + h.Get(fetchMorePath, "intersect once"), + )), + ) + }) + } +}) diff --git a/examples/hackernews/partials/story.go b/examples/hackernews/partials/story.go new file mode 100644 index 0000000..8b72272 --- /dev/null +++ b/examples/hackernews/partials/story.go @@ -0,0 +1,92 @@ +package partials + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "hackernews/internal/news" + "hackernews/internal/timeformat" + "time" +) + +func Story(ctx *h.RequestContext) *h.Partial { + storyId := news.MustItemId(ctx) + if storyId == 0 { + return h.NewPartial( + h.Div( + h.Class("flex justify-center bg-neutral-300"), + h.Id("story-body"), + ), + ) + } + + if ctx.IsHxRequest() { + return h.SwapManyPartialWithHeaders( + ctx, + h.PushUrlHeader(fmt.Sprintf("/?item=%d", storyId)), + h.Div( + h.Class("w-full"), + h.Id("story-body"), + CachedStoryBody(storyId), + ), + ) + } + + return h.NewPartial( + CachedStoryBody(storyId), + ) +} + +var CachedStoryBody = h.CachedPerKeyT[string, int](time.Minute*3, func(itemId int) (string, h.GetElementFunc) { + return fmt.Sprintf("story-%d", itemId), func() *h.Element { + story, err := news.GetStory(itemId) + if err != nil { + return h.Div( + h.Id("story-body"), + h.Text("Failed to load story"), + ) + } + return StoryBody(story) + } +}) + +func StoryBody(story *news.Story) *h.Element { + return h.Div( + h.Class("w-full"), + h.Id("story-body"), + h.Div( + h.Class("prose prose-2xl border-b border-gray-200 pb-3 max-w-none w-full"), + h.H5( + h.Class("flex gap-2 items-left 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("•"), + h.TextF(" %s ", story.By), + h.UnsafeRaw("•"), + h.TextF(" %s", timeformat.RelativeTime(story.Time)), + ), + ), + h.Div( + h.Id("comments-loader"), + h.Class("flex justify-center items-center h-24"), + h.Div( + h.Class("animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-rose-500"), + ), + ), + h.Div( + h.Class("mt-2 min-w-3xl max-w-3xl"), + h.GetPartial(StoryComments, "load"), + ), + ) +} diff --git a/examples/sse-with-state/tailwind.config.js b/examples/hackernews/tailwind.config.js similarity index 61% rename from examples/sse-with-state/tailwind.config.js rename to examples/hackernews/tailwind.config.js index b18125c..f1b453a 100644 --- a/examples/sse-with-state/tailwind.config.js +++ b/examples/hackernews/tailwind.config.js @@ -1,5 +1,7 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: ["**/*.go"], - plugins: [], + plugins: [ + require('@tailwindcss/typography') + ], }; diff --git a/examples/sse-with-state/Taskfile.yml b/examples/sse-with-state/Taskfile.yml deleted file mode 100644 index 695006f..0000000 --- a/examples/sse-with-state/Taskfile.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: '3' - -tasks: - run: - cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest run - silent: true - - build: - cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest build - - docker: - cmds: - - docker build . - - watch: - cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest watch - silent: true \ No newline at end of file diff --git a/examples/sse-with-state/assets/css/input.css b/examples/sse-with-state/assets/css/input.css deleted file mode 100644 index bd6213e..0000000 --- a/examples/sse-with-state/assets/css/input.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; \ No newline at end of file diff --git a/examples/sse-with-state/go.mod b/examples/sse-with-state/go.mod deleted file mode 100644 index f10fa8a..0000000 --- a/examples/sse-with-state/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module sse-with-state - -go 1.23.0 - -require github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b - -require ( - github.com/go-chi/chi/v5 v5.1.0 // indirect - github.com/gobwas/httphead v0.1.0 // indirect - github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect - golang.org/x/sys v0.6.0 // indirect -) diff --git a/examples/sse-with-state/main.go b/examples/sse-with-state/main.go deleted file mode 100644 index 3213f4f..0000000 --- a/examples/sse-with-state/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/maddalax/htmgo/framework/h" - "github.com/maddalax/htmgo/framework/service" - "io/fs" - "net/http" - "sse-with-state/__htmgo" - "sse-with-state/event" - "sse-with-state/sse" -) - -func main() { - locator := service.NewLocator() - - service.Set[sse.SocketManager](locator, service.Singleton, func() *sse.SocketManager { - return sse.NewSocketManager() - }) - - event.StartListener(locator) - - 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))) - app.Router.Handle("/ws/test", sse.HandleWs()) - app.Router.Get("/metrics", func(writer http.ResponseWriter, request *http.Request) { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - metrics := event.GetMetrics() - serialized, _ := json.Marshal(metrics) - _, _ = writer.Write(serialized) - }) - __htmgo.Register(app.Router) - }, - }) -} diff --git a/examples/todo-list/go.mod b/examples/todo-list/go.mod index b07b81e..bde8528 100644 --- a/examples/todo-list/go.mod +++ b/examples/todo-list/go.mod @@ -5,7 +5,7 @@ go 1.23.0 require ( entgo.io/ent v0.14.1 github.com/google/uuid v1.6.0 - github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b + github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d github.com/mattn/go-sqlite3 v1.14.23 ) diff --git a/examples/todo-list/go.sum b/examples/todo-list/go.sum index cd0a040..34c7ebc 100644 --- a/examples/todo-list/go.sum +++ b/examples/todo-list/go.sum @@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= diff --git a/framework-ui/go.mod b/framework-ui/go.mod index 628bf48..b84f8e1 100644 --- a/framework-ui/go.mod +++ b/framework-ui/go.mod @@ -2,7 +2,7 @@ module github.com/maddalax/htmgo/framework-ui go 1.23.0 -require github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b +require github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d require ( github.com/go-chi/chi/v5 v5.1.0 // indirect diff --git a/framework-ui/go.sum b/framework-ui/go.sum index 5767c88..531d9d1 100644 --- a/framework-ui/go.sum +++ b/framework-ui/go.sum @@ -4,8 +4,8 @@ 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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= diff --git a/framework/assets/js/htmgo.ts b/framework/assets/js/htmgo.ts index b2feebb..322501e 100644 --- a/framework/assets/js/htmgo.ts +++ b/framework/assets/js/htmgo.ts @@ -6,8 +6,7 @@ import "./htmxextensions/response-targets"; import "./htmxextensions/mutation-error"; import "./htmxextensions/livereload" import "./htmxextensions/htmgo"; -import "./htmxextensions/ws" -import "./htmxextensions/ws-event-handler" +import "./htmxextensions/sse" // @ts-ignore window.htmx = htmx; @@ -19,7 +18,7 @@ function watchUrl(callback: (oldUrl: string, newUrl: string) => void) { callback(lastUrl, window.location.href); lastUrl = window.location.href; } - }, 100); + }, 101); } watchUrl((_, newUrl) => { diff --git a/framework/assets/js/htmxextensions/htmgo.ts b/framework/assets/js/htmxextensions/htmgo.ts index 6b0ae7e..a711734 100644 --- a/framework/assets/js/htmxextensions/htmgo.ts +++ b/framework/assets/js/htmxextensions/htmgo.ts @@ -8,9 +8,30 @@ htmx.defineExtension("htmgo", { if(name === "htmx:beforeCleanupElement" && evt.target) { removeAssociatedScripts(evt.target as HTMLElement); } + if(name === "htmx:load" && evt.target) { + invokeOnLoad(evt.target as HTMLElement); + } }, }); +/** + * Browser doesn't support onload for all elements, so we need to manually trigger it + * this is useful for locality of behavior + */ +function invokeOnLoad(element : Element) { + if(element == null || !(element instanceof HTMLElement)) { + return + } + const ignored = ['SCRIPT', 'LINK', 'STYLE', 'META', 'BASE', 'TITLE', 'HEAD', 'HTML', 'BODY']; + if(!ignored.includes(element.tagName)) { + if(element.hasAttribute("onload")) { + element.onload!(new Event("load")); + } + } + // check its children + element.querySelectorAll('[onload]').forEach(invokeOnLoad) +} + export function removeAssociatedScripts(element: HTMLElement) { const attributes = Array.from(element.attributes) for (let attribute of attributes) { diff --git a/framework/assets/js/htmxextensions/sse.ts b/framework/assets/js/htmxextensions/sse.ts new file mode 100644 index 0000000..b4b7a19 --- /dev/null +++ b/framework/assets/js/htmxextensions/sse.ts @@ -0,0 +1,72 @@ +import htmx from 'htmx.org' +import {removeAssociatedScripts} from "./htmgo"; + +let api : any = null; +let processed = new Set() + +htmx.defineExtension("sse", { + init: function (apiRef) { + api = apiRef; + }, + // @ts-ignore + onEvent: function (name, evt) { + const target = evt.target; + if(!(target instanceof HTMLElement)) { + return + } + + if(name === 'htmx:beforeCleanupElement') { + removeAssociatedScripts(target); + } + + if(name === 'htmx:beforeProcessNode') { + const elements = document.querySelectorAll('[sse-connect]'); + for (let element of Array.from(elements)) { + const url = element.getAttribute("sse-connect")!; + if(url && !processed.has(url)) { + connectEventSource(element, url) + processed.add(url) + } + } + } + } +}) + +function connectEventSource(ele: Element, url: string) { + if(!url) { + return + } + console.info('Connecting to EventSource', url) + const eventSource = new EventSource(url); + + eventSource.addEventListener("close", function(event) { + htmx.trigger(ele, "htmx:sseClose", {event: event}); + }) + + eventSource.onopen = function(event) { + htmx.trigger(ele, "htmx:sseOpen", {event: event}); + } + + eventSource.onerror = function(event) { + htmx.trigger(ele, "htmx:sseError", {event: event}); + if (eventSource.readyState == EventSource.CLOSED) { + htmx.trigger(ele, "htmx:sseClose", {event: event}); + } + } + + eventSource.onmessage = function(event) { + const settleInfo = api.makeSettleInfo(ele); + htmx.trigger(ele, "htmx:sseBeforeMessage", {event: event}); + const response = event.data + const fragment = api.makeFragment(response) as DocumentFragment; + const children = Array.from(fragment.children); + for (let child of children) { + api.oobSwap(api.getAttributeValue(child, 'hx-swap-oob') || 'true', child, settleInfo); + // support htmgo eval__ scripts + if(child.tagName === 'SCRIPT' && child.id.startsWith("__eval")) { + document.body.appendChild(child); + } + } + htmx.trigger(ele, "htmx:sseAfterMessage", {event: event}); + } +} diff --git a/framework/assets/js/htmxextensions/trigger-children.ts b/framework/assets/js/htmxextensions/trigger-children.ts index 7f5b8b5..c32c738 100644 --- a/framework/assets/js/htmxextensions/trigger-children.ts +++ b/framework/assets/js/htmxextensions/trigger-children.ts @@ -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); diff --git a/framework/assets/js/util/dom.ts b/framework/assets/js/util/dom.ts deleted file mode 100644 index e69de29..0000000 diff --git a/framework/config/project.go b/framework/config/project.go new file mode 100644 index 0000000..72e222d --- /dev/null +++ b/framework/config/project.go @@ -0,0 +1,58 @@ +package config + +import ( + "gopkg.in/yaml.v3" + "log/slog" + "os" + "path" +) + +type ProjectConfig struct { + Tailwind bool `yaml:"tailwind"` + WatchIgnore []string `yaml:"watch_ignore"` + WatchFiles []string `yaml:"watch_files"` +} + +func DefaultProjectConfig() *ProjectConfig { + return &ProjectConfig{ + Tailwind: true, + WatchIgnore: []string{ + "node_modules", ".git", ".idea", "assets/dist", + }, + WatchFiles: []string{ + "**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md", + }, + } +} + +func (cfg *ProjectConfig) EnhanceWithDefaults() *ProjectConfig { + defaultCfg := DefaultProjectConfig() + if len(cfg.WatchFiles) == 0 { + cfg.WatchFiles = defaultCfg.WatchFiles + } + if len(cfg.WatchIgnore) == 0 { + cfg.WatchIgnore = defaultCfg.WatchIgnore + } + return cfg +} + +func FromConfigFile(workingDir string) *ProjectConfig { + defaultCfg := DefaultProjectConfig() + names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"} + for _, name := range names { + filePath := path.Join(workingDir, name) + if _, err := os.Stat(filePath); err == nil { + cfg := &ProjectConfig{} + bytes, err := os.ReadFile(filePath) + if err == nil { + err = yaml.Unmarshal(bytes, cfg) + if err != nil { + slog.Error("Error parsing config file", slog.String("file", filePath), slog.String("error", err.Error())) + os.Exit(1) + } + return cfg.EnhanceWithDefaults() + } + } + } + return defaultCfg +} diff --git a/framework/config/project_test.go b/framework/config/project_test.go new file mode 100644 index 0000000..f015635 --- /dev/null +++ b/framework/config/project_test.go @@ -0,0 +1,50 @@ +package config + +import ( + "github.com/stretchr/testify/assert" + "os" + "path" + "testing" +) + +func TestDefaultProjectConfig(t *testing.T) { + t.Parallel() + cfg := DefaultProjectConfig() + assert.Equal(t, true, cfg.Tailwind) + assert.Equal(t, 4, len(cfg.WatchIgnore)) + assert.Equal(t, 8, len(cfg.WatchFiles)) +} + +func TestNoConfigFileUsesDefault(t *testing.T) { + t.Parallel() + cfg := FromConfigFile("non-existing-dir") + assert.Equal(t, true, cfg.Tailwind) + assert.Equal(t, 4, len(cfg.WatchIgnore)) + assert.Equal(t, 8, len(cfg.WatchFiles)) +} + +func TestPartialConfigMerges(t *testing.T) { + t.Parallel() + dir := writeConfigFile(t, "tailwind: false") + cfg := FromConfigFile(dir) + assert.Equal(t, false, cfg.Tailwind) + assert.Equal(t, 4, len(cfg.WatchIgnore)) + assert.Equal(t, 8, len(cfg.WatchFiles)) +} + +func TestShouldNotSetTailwindTrue(t *testing.T) { + t.Parallel() + dir := writeConfigFile(t, "someValue: true") + cfg := FromConfigFile(dir) + assert.Equal(t, false, cfg.Tailwind) + assert.Equal(t, 4, len(cfg.WatchIgnore)) + assert.Equal(t, 8, len(cfg.WatchFiles)) +} + +func writeConfigFile(t *testing.T, content string) string { + temp := os.TempDir() + os.Mkdir(temp, 0755) + err := os.WriteFile(path.Join(temp, "htmgo.yml"), []byte(content), 0644) + assert.Nil(t, err) + return temp +} diff --git a/framework/go.mod b/framework/go.mod index a6a7b64..4e9fd74 100644 --- a/framework/go.mod +++ b/framework/go.mod @@ -7,10 +7,10 @@ require ( github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.9.0 golang.org/x/net v0.29.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/framework/h/app.go b/framework/h/app.go index d369f7d..f32c560 100644 --- a/framework/h/app.go +++ b/framework/h/app.go @@ -20,7 +20,7 @@ type RequestContext struct { Response http.ResponseWriter locator *service.Locator isBoosted bool - CurrentBrowserUrl string + currentBrowserUrl string hxPromptResponse string isHxRequest bool hxTargetId string @@ -29,14 +29,54 @@ type RequestContext struct { kv map[string]interface{} } +func GetRequestContext(r *http.Request) *RequestContext { + return r.Context().Value(RequestContextKey).(*RequestContext) +} + func (c *RequestContext) FormValue(key string) string { return c.Request.FormValue(key) } +func (c *RequestContext) Header(key string) string { + return c.Request.Header.Get(key) +} + +func (c *RequestContext) UrlParam(key string) string { + return chi.URLParam(c.Request, key) +} + func (c *RequestContext) QueryParam(key string) string { return c.Request.URL.Query().Get(key) } +func (c *RequestContext) IsBoosted() bool { + return c.isBoosted +} + +func (c *RequestContext) IsHxRequest() bool { + return c.isHxRequest +} + +func (c *RequestContext) HxPromptResponse() string { + return c.hxPromptResponse +} + +func (c *RequestContext) HxTargetId() string { + return c.hxTargetId +} + +func (c *RequestContext) HxTriggerName() string { + return c.hxTriggerName +} + +func (c *RequestContext) HxTriggerId() string { + return c.hxTriggerId +} + +func (c *RequestContext) HxCurrentBrowserUrl() string { + return c.currentBrowserUrl +} + func (c *RequestContext) Set(key string, value interface{}) { if c.kv == nil { c.kv = make(map[string]interface{}) @@ -79,8 +119,7 @@ const RequestContextKey = "htmgo.request.context" func populateHxFields(cc *RequestContext) { cc.isBoosted = cc.Request.Header.Get(hx.BoostedHeader) == "true" - cc.isBoosted = cc.Request.Header.Get(hx.BoostedHeader) == "true" - cc.CurrentBrowserUrl = cc.Request.Header.Get(hx.CurrentUrlHeader) + cc.currentBrowserUrl = cc.Request.Header.Get(hx.CurrentUrlHeader) cc.hxPromptResponse = cc.Request.Header.Get(hx.PromptResponseHeader) cc.isHxRequest = cc.Request.Header.Get(hx.RequestHeader) == "true" cc.hxTargetId = cc.Request.Header.Get(hx.TargetIdHeader) diff --git a/framework/h/base.go b/framework/h/base.go index 1ec82a1..bbb8006 100644 --- a/framework/h/base.go +++ b/framework/h/base.go @@ -1,7 +1,6 @@ package h import ( - "html" "net/http" "reflect" "runtime" @@ -85,9 +84,9 @@ func SwapManyXPartial(ctx *RequestContext, swaps ...SwapArg) *Partial { } func GetPartialPath(partial PartialFunc) string { - return runtime.FuncForPC(reflect.ValueOf(partial).Pointer()).Name() + return "/" + runtime.FuncForPC(reflect.ValueOf(partial).Pointer()).Name() } func GetPartialPathWithQs(partial func(ctx *RequestContext) *Partial, qs *Qs) string { - return html.EscapeString(GetPartialPath(partial) + "?" + qs.ToString()) + return GetPartialPath(partial) + "?" + qs.ToString() } diff --git a/framework/h/extensions.go b/framework/h/extensions.go index 52cbe12..8eda90d 100644 --- a/framework/h/extensions.go +++ b/framework/h/extensions.go @@ -3,7 +3,7 @@ package h import "strings" func BaseExtensions() string { - extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse", "ws"} + extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse"} if IsDevelopment() { extensions = append(extensions, "livereload") } diff --git a/framework/h/header.go b/framework/h/header.go index ed13f9a..aba7c0a 100644 --- a/framework/h/header.go +++ b/framework/h/header.go @@ -16,7 +16,7 @@ func PushUrlHeader(url string) *Headers { } func PushQsHeader(ctx *RequestContext, qs *Qs) *Headers { - parsed, err := url.Parse(ctx.CurrentBrowserUrl) + parsed, err := url.Parse(ctx.currentBrowserUrl) if err != nil { return NewHeaders() } diff --git a/framework/h/lifecycle.go b/framework/h/lifecycle.go index 1dd199a..c9ceb7d 100644 --- a/framework/h/lifecycle.go +++ b/framework/h/lifecycle.go @@ -51,6 +51,7 @@ func (l *LifeCycle) OnEvent(event hx.Event, cmd ...Command) *LifeCycle { return l } +// OnLoad This will work on any element because of the htmgo htmx extension to trigger it, instead of the browser. func OnLoad(cmd ...Command) *LifeCycle { return NewLifeCycle().OnEvent(hx.LoadDomEvent, cmd...) } diff --git a/framework/h/qs.go b/framework/h/qs.go index a7cfb3e..75fad83 100644 --- a/framework/h/qs.go +++ b/framework/h/qs.go @@ -51,7 +51,7 @@ func (q *Qs) ToString() string { func GetQueryParam(ctx *RequestContext, key string) string { value, ok := ctx.Request.URL.Query()[key] if value == nil || !ok { - current := ctx.CurrentBrowserUrl + current := ctx.currentBrowserUrl if current != "" { u, err := url.Parse(current) if err == nil { diff --git a/framework/h/tag.go b/framework/h/tag.go index 55c3dd1..611fc55 100644 --- a/framework/h/tag.go +++ b/framework/h/tag.go @@ -2,7 +2,6 @@ package h import ( "fmt" - "github.com/maddalax/htmgo/framework/internal/util" "strconv" ) @@ -154,10 +153,6 @@ func Div(children ...Ren) *Element { return Tag("div", children...) } -func GenId() string { - return util.RandSeq(6) -} - func Article(children ...Ren) *Element { return Tag("article", children...) } @@ -187,6 +182,10 @@ func Input(inputType string, children ...Ren) *Element { } } +func TextArea(children ...Ren) *Element { + return Tag("textarea", children...) +} + func TextInput(children ...Ren) *Element { return Input("text", children...) } @@ -467,6 +466,10 @@ func THead(children ...Ren) *Element { return Tag("thead", children...) } +func I(children ...Ren) *Element { + return Tag("i", children...) +} + func TFoot(children ...Ren) *Element { return Tag("tfoot", children...) } diff --git a/framework/hx/htmx.go b/framework/hx/htmx.go index 9ce7393..20063e6 100644 --- a/framework/hx/htmx.go +++ b/framework/hx/htmx.go @@ -86,7 +86,6 @@ const ( HistoryCacheMissLoadEvent Event = "htmx:historyCacheMissLoad" HistoryRestoreEvent Event = "htmx:historyRestore" BeforeHistorySaveEvent Event = "htmx:beforeHistorySave" - LoadEvent Event = "htmx:load" NoSSESourceErrorEvent Event = "htmx:noSSESourceError" OnLoadErrorEvent Event = "htmx:onLoadError" OobAfterSwapEvent Event = "htmx:oobAfterSwap" @@ -131,6 +130,7 @@ const ( KeyPressEvent Event = "onkeypress" SubmitEvent Event = "onsubmit" LoadDomEvent Event = "onload" + LoadEvent Event = "onload" UnloadEvent Event = "onunload" ResizeEvent Event = "onresize" ScrollEvent Event = "onscroll" diff --git a/htmgo-site/Taskfile.yml b/htmgo-site/Taskfile.yml index a6d5700..caf16b9 100644 --- a/htmgo-site/Taskfile.yml +++ b/htmgo-site/Taskfile.yml @@ -3,14 +3,14 @@ version: '3' tasks: run: cmds: - - go run github.com/maddalax/htmgo/cli@latest run + - htmgo run silent: true build: cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest build + - htmgo build watch: cmds: - - go run github.com/maddalax/htmgo/cli@latest watch - silent: true \ No newline at end of file + - htmgo watch + silent: true diff --git a/htmgo-site/assets/public/apple-touch-icon.png b/htmgo-site/assets/public/apple-touch-icon.png new file mode 100644 index 0000000..d10e9fe Binary files /dev/null and b/htmgo-site/assets/public/apple-touch-icon.png differ diff --git a/htmgo-site/assets/public/favicon.ico b/htmgo-site/assets/public/favicon.ico new file mode 100644 index 0000000..040cccf Binary files /dev/null and b/htmgo-site/assets/public/favicon.ico differ diff --git a/htmgo-site/assets/public/hn-example.jpg b/htmgo-site/assets/public/hn-example.jpg new file mode 100644 index 0000000..85f5f88 Binary files /dev/null and b/htmgo-site/assets/public/hn-example.jpg differ diff --git a/htmgo-site/assets/public/icon-192-maskable.png b/htmgo-site/assets/public/icon-192-maskable.png new file mode 100644 index 0000000..d4d6efb Binary files /dev/null and b/htmgo-site/assets/public/icon-192-maskable.png differ diff --git a/htmgo-site/assets/public/icon-192.png b/htmgo-site/assets/public/icon-192.png new file mode 100644 index 0000000..f533435 Binary files /dev/null and b/htmgo-site/assets/public/icon-192.png differ diff --git a/htmgo-site/assets/public/icon-512-maskable.png b/htmgo-site/assets/public/icon-512-maskable.png new file mode 100644 index 0000000..db61f3d Binary files /dev/null and b/htmgo-site/assets/public/icon-512-maskable.png differ diff --git a/htmgo-site/assets/public/icon-512.png b/htmgo-site/assets/public/icon-512.png new file mode 100644 index 0000000..ba0665d Binary files /dev/null and b/htmgo-site/assets/public/icon-512.png differ diff --git a/htmgo-site/assets/public/tailwind-intellisense.png b/htmgo-site/assets/public/tailwind-intellisense.png new file mode 100644 index 0000000..962c0b4 Binary files /dev/null and b/htmgo-site/assets/public/tailwind-intellisense.png differ diff --git a/htmgo-site/go.mod b/htmgo-site/go.mod index 8cb2012..45b0ca4 100644 --- a/htmgo-site/go.mod +++ b/htmgo-site/go.mod @@ -3,14 +3,17 @@ module htmgo-site go 1.23.0 require ( + github.com/alecthomas/chroma/v2 v2.14.0 github.com/google/uuid v1.6.0 - github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b + github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d + github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490 github.com/yuin/goldmark v1.7.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc ) require ( - github.com/alecthomas/chroma/v2 v2.2.0 // indirect - github.com/dlclark/regexp2 v1.7.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/text v0.19.0 // indirect ) diff --git a/htmgo-site/go.sum b/htmgo-site/go.sum index cf6bb62..cb6ec6e 100644 --- a/htmgo-site/go.sum +++ b/htmgo-site/go.sum @@ -1,19 +1,28 @@ -github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490 h1:D7jkugRnEtKACr4kQH6eSNxB8cKXgrhLm+5yeLsvscg= +github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20241011161932-8b9e536f1490/go.mod h1:hpDNkFnNT0FIgmQsVjMeQOzLuPxaqmkbNuws3zh4gWs= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -25,8 +34,10 @@ github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/htmgo-site/htmgo.yml b/htmgo-site/htmgo.yml new file mode 100644 index 0000000..d60d2ff --- /dev/null +++ b/htmgo-site/htmgo.yml @@ -0,0 +1,10 @@ +# htmgo configuration + +# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist +tailwind: true + +# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar +watch_ignore: [".git", "node_modules", "dist/*"] + +# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar +watch_files: ["**/*.go", "**/*.css", "**/*.md"] diff --git a/htmgo-site/internal/dirwalk/walk.go b/htmgo-site/internal/dirwalk/walk.go index 59df766..1c52090 100644 --- a/htmgo-site/internal/dirwalk/walk.go +++ b/htmgo-site/internal/dirwalk/walk.go @@ -4,8 +4,6 @@ import ( "github.com/maddalax/htmgo/framework/h" "io/fs" "os" - "slices" - "strconv" "strings" ) @@ -36,22 +34,5 @@ func WalkPages(dir string, system fs.FS) []*Page { return nil }) - var getRouteOrder = func(page *Page) int { - fileName := page.Parts[len(page.Parts)-1] - if len(fileName) > 1 && fileName[1] == '_' { - num, err := strconv.ParseInt(fileName[0:1], 10, 64) - if err != nil { - return 0 - } - page.Parts[len(page.Parts)-1] = fileName[2:] - return int(num) - } - return 0 - } - - slices.SortFunc(pages, func(a *Page, b *Page) int { - return getRouteOrder(a) - getRouteOrder(b) - }) - return pages } diff --git a/htmgo-site/internal/sitemap/generate.go b/htmgo-site/internal/sitemap/generate.go new file mode 100644 index 0000000..8a52fd6 --- /dev/null +++ b/htmgo-site/internal/sitemap/generate.go @@ -0,0 +1,64 @@ +package sitemap + +import ( + "bytes" + "encoding/xml" + "fmt" +) + +type URL struct { + Loc string `xml:"loc"` + ChangeFreq string `xml:"changefreq,omitempty"` + Priority float32 `xml:"priority,omitempty"` +} + +type URLSet struct { + XMLName xml.Name `xml:"urlset"` + XmlNS string `xml:"xmlns,attr"` + URLs []URL `xml:"url"` +} + +func NewSitemap(urls []URL) *URLSet { + return &URLSet{ + XmlNS: "https://www.sitemaps.org/schemas/sitemap/0.9", + URLs: urls, + } +} + +func serialize(sitemap *URLSet) ([]byte, error) { + buffer := bytes.Buffer{} + enc := xml.NewEncoder(&buffer) + enc.Indent("", " ") + if err := enc.Encode(sitemap); err != nil { + return make([]byte, 0), fmt.Errorf("could not encode sitemap: %w", err) + } + return buffer.Bytes(), nil +} + +func Generate() ([]byte, error) { + + urls := []URL{ + { + Loc: "/", + Priority: 0.5, + ChangeFreq: "weekly", + }, + { + Loc: "/docs", + Priority: 1.0, + ChangeFreq: "daily", + }, + { + Loc: "/examples", + Priority: 0.7, + ChangeFreq: "daily", + }, + { + Loc: "/html-to-go", + Priority: 0.5, + ChangeFreq: "weekly", + }, + } + sitemap := NewSitemap(urls) + return serialize(sitemap) +} diff --git a/htmgo-site/main.go b/htmgo-site/main.go index 50b0b30..2ee2dc7 100644 --- a/htmgo-site/main.go +++ b/htmgo-site/main.go @@ -6,6 +6,7 @@ import ( "htmgo-site/__htmgo" "htmgo-site/internal/cache" "htmgo-site/internal/markdown" + "htmgo-site/internal/sitemap" "io/fs" "net/http" ) @@ -35,6 +36,16 @@ func main() { http.FileServerFS(sub) + app.Router.Handle("/sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, err := sitemap.Generate() + if err != nil { + http.Error(w, "failed to generate sitemap", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml") + w.Write(s) + })) + app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub))) __htmgo.Register(app.Router) diff --git a/htmgo-site/md/docs/1_quick-start/1_introduction.md b/htmgo-site/md/docs/1_quick-start/1_introduction.md index a1b0025..992cdd9 100644 --- a/htmgo-site/md/docs/1_quick-start/1_introduction.md +++ b/htmgo-site/md/docs/1_quick-start/1_introduction.md @@ -1,4 +1,4 @@ -## **Introduction** +## Introduction htmgo is a lightweight pure go way to build interactive websites / web applications using go & htmx. We give you the utilities to build html using pure go code in a reusable way (go functions are components) while also providing htmx functions to add interactivity to your app. diff --git a/htmgo-site/md/docs/1_quick-start/2_installation.md b/htmgo-site/md/docs/1_quick-start/2_installation.md index a12f523..30b1a3e 100644 --- a/htmgo-site/md/docs/1_quick-start/2_installation.md +++ b/htmgo-site/md/docs/1_quick-start/2_installation.md @@ -1,4 +1,4 @@ -## **Getting Started** +## Getting Started ##### **Prerequisites:** diff --git a/htmgo-site/md/docs/2_core-concepts/1_pages.md b/htmgo-site/md/docs/2_core-concepts/1_pages.md index 78140c1..20c0338 100644 --- a/htmgo-site/md/docs/2_core-concepts/1_pages.md +++ b/htmgo-site/md/docs/2_core-concepts/1_pages.md @@ -1,4 +1,4 @@ -## Pages ## +## Pages Pages are the entry point of an htmgo application. diff --git a/htmgo-site/md/docs/2_core-concepts/2_partials.md b/htmgo-site/md/docs/2_core-concepts/2_partials.md index 51a14ef..9757880 100644 --- a/htmgo-site/md/docs/2_core-concepts/2_partials.md +++ b/htmgo-site/md/docs/2_core-concepts/2_partials.md @@ -1,4 +1,4 @@ -## Partials ## +## Partials Partials are where things get interesting. Partials allow you to start adding interactivity to your website by swapping in content, setting headers, redirecting, etc. diff --git a/htmgo-site/md/docs/2_core-concepts/3_components.md b/htmgo-site/md/docs/2_core-concepts/3_components.md index 01c4d62..9cbf2d2 100644 --- a/htmgo-site/md/docs/2_core-concepts/3_components.md +++ b/htmgo-site/md/docs/2_core-concepts/3_components.md @@ -1,4 +1,4 @@ -**Components** +## Components Components are re-usable bits of logic to render HTML. Similar to how in React components are Javascript functions, in htmgo, components are pure go functions. @@ -26,4 +26,4 @@ If you are familiar with React, then you would likely place this fetch logic ins With **htmgo**, the only way to update content on the page is to use htmx to swap out the content from loading a partial. Therefore you control exactly when this Card component is called, not the framework behind the scenes. -See [#interactivity-swapping](#interactivity-swapping) for more information \ No newline at end of file +See [#interactivity-swapping](#interactivity-swapping) for more information diff --git a/htmgo-site/md/docs/2_core-concepts/4_tags.md b/htmgo-site/md/docs/2_core-concepts/4_tags.md index 92912eb..aaeb8ba 100644 --- a/htmgo-site/md/docs/2_core-concepts/4_tags.md +++ b/htmgo-site/md/docs/2_core-concepts/4_tags.md @@ -1,4 +1,4 @@ -**HTML Tags** +## HTML Tags htmgo provides many methods to render html tags: diff --git a/htmgo-site/md/docs/2_core-concepts/5_attributes.md b/htmgo-site/md/docs/2_core-concepts/5_attributes.md index 75e0437..a0b4c73 100644 --- a/htmgo-site/md/docs/2_core-concepts/5_attributes.md +++ b/htmgo-site/md/docs/2_core-concepts/5_attributes.md @@ -1,4 +1,4 @@ -**Attributes** +## Attributes Attributes are one of the main ways we can add interactivity to the pages with [htmx](http://htmx.org). If you have not read over the htmx documentation, please do so before continuing. diff --git a/htmgo-site/md/docs/2_core-concepts/6_raw_html.md b/htmgo-site/md/docs/2_core-concepts/6_raw_html.md index 3e3e7d2..055639c 100644 --- a/htmgo-site/md/docs/2_core-concepts/6_raw_html.md +++ b/htmgo-site/md/docs/2_core-concepts/6_raw_html.md @@ -1,4 +1,4 @@ -**Rendering Raw Html** +## Rendering Raw Html In some cases, you may want to render raw HTML instead of using htmgo's functions. This can be done by using the following methods: @@ -19,4 +19,4 @@ h.UnsafeRawScript("alert('Hello World')") Important: Be careful when using these methods, these methods do not escape the HTML content and should **never** be used with user input unless you have sanitized the input. -Sanitizing input can be done using the `html.EscapeString` function or by using https://github.com/microcosm-cc/bluemonday. \ No newline at end of file +Sanitizing input can be done using the `html.EscapeString` function or by using https://github.com/microcosm-cc/bluemonday. diff --git a/htmgo-site/md/docs/3_control/1_If Else.md b/htmgo-site/md/docs/3_control/1_If Else.md index 2e441cc..3477237 100644 --- a/htmgo-site/md/docs/3_control/1_If Else.md +++ b/htmgo-site/md/docs/3_control/1_If Else.md @@ -1,4 +1,4 @@ -**If / Else Statements** +## Conditional Statements If / else statements are useful when you want to conditionally render attributes or elements / components. diff --git a/htmgo-site/md/docs/3_control/2_loops.md b/htmgo-site/md/docs/3_control/2_loops.md index e3ec62a..c42e87c 100644 --- a/htmgo-site/md/docs/3_control/2_loops.md +++ b/htmgo-site/md/docs/3_control/2_loops.md @@ -1,4 +1,4 @@ -**Loops / Dealing With Lists** +## Loops / Dealing With Lists Very commonly you will need to render a list or slice of items onto the page. Frameworks generally solve this in different ways, such as React uses regular JS .map function to solve it. diff --git a/htmgo-site/md/docs/4_interactivity/1_swapping.md b/htmgo-site/md/docs/4_interactivity/1_swapping.md index 6a9cdc6..d8ba5eb 100644 --- a/htmgo-site/md/docs/4_interactivity/1_swapping.md +++ b/htmgo-site/md/docs/4_interactivity/1_swapping.md @@ -1,4 +1,4 @@ -### Interactivity +## Interactivity / Swapping 1. Adding interactivity to your website is done through [htmx](http://htmx.org) by utilizing various attributes/headers. This should cover most use cases. htmgo offers utility methods to make this process a bit easier @@ -82,4 +82,4 @@ When the **CompleteAll** button is clicked, a **POST** will be sent to the **Com Note: These partial swap methods use https://htmx.org/attributes/hx-swap-oob/ behind the scenes, so it must match the swap target by id. -**If** you are only wanting to swap the element that made the xhr request for the partial in the first place, just use `h.NewPartial` instead, it will use the default htmx swapping, and not hx-swap-oob. \ No newline at end of file +**If** you are only wanting to swap the element that made the xhr request for the partial in the first place, just use `h.NewPartial` instead, it will use the default htmx swapping, and not hx-swap-oob. diff --git a/htmgo-site/md/docs/4_interactivity/2_events.md b/htmgo-site/md/docs/4_interactivity/2_events.md index 40664b5..ed750d8 100644 --- a/htmgo-site/md/docs/4_interactivity/2_events.md +++ b/htmgo-site/md/docs/4_interactivity/2_events.md @@ -1,4 +1,4 @@ -**Events** +## Events Handlers / Commands Sometimes you need to update elements client side without having to do a network call. For this you generally have to target an element with javascript and set an attribute, change the innerHTML, etc. @@ -42,81 +42,4 @@ OnClick(cmd ...Command) *LifeCycle HxOnAfterSwap(cmd ...Command) *LifeCycle HxOnLoad(cmd ...Command) *LifeCycle ``` -**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments. -'self' is the current element, and 'event' is the event object. - -If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/). - -Commands: - -```go -js.AddAttribute(string, value) -js.RemoveAttribute(string) -js.AddClass(string, value) -js.SetText(string) -js.Increment(count) -js.SetInnerHtml(Ren) -js.SetOuterHtml(Ren) -js.SetDisabled(bool) -js.RemoveClass(string) -js.Alert(string) -js.EvalJs(string) // eval arbitrary js, use 'self' to get the current element as a reference -js.InjectScript(string) -js.InjectScriptIfNotExist(string) -js.GetPartial(PartialFunc) -js.GetPartialWithQs(PartialFunc, Qs) -js.PostPartial(PartialFunc) -js.PostPartialWithQs(PartialFunc, Qs) -js.GetWithQs(string, Qs) -js.PostWithQs(string, Qs) -js.ToggleClass(string) -js.ToggleClassOnElement(string, string) - -// The following methods are used to evaluate JS on nearby elements. -// Use 'element' to get the element as a reference for the EvalJs methods. -js.EvalJsOnParent(string) -js.EvalJsOnSibling(string, string) -js.EvalJsOnChildren(string, string) -js.SetClassOnParent(string) -js.RemoveClassOnParent(string) -js.SetClassOnChildren(string, string) -js.RemoveClassOnChildren(string, string) -js.SetClassOnSibling(string, string) -js.RemoveClassOnSibling(string, string) - -``` -For more usages: see https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go - - -**Example:** Evaluating arbitrary JS - -```go -func MyButton() *h.Element { - return h.Button( - h.Text("Submit"), - h.OnClick( - // make sure you use 'self' instead of 'this' - // for referencing the current element - h.EvalJs(` - if(Math.random() > 0.5) { - self.innerHTML = "Success!"; - } - `, - ), - ), - ) -} -``` - -tip: If you are using Jetbrains IDE's, you can write `// language=js` as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS. - -```go -// language=js -h.EvalJs(` - if(Math.random() > 0.5) { - self.innerHTML = "Success!"; - } - `, -), -``` diff --git a/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md b/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md new file mode 100644 index 0000000..37affdb --- /dev/null +++ b/htmgo-site/md/docs/4_interactivity/3_evaluating_javascript.md @@ -0,0 +1,85 @@ +## Evaluating Javascript In Event Handlers + +Event handlers are useful by attaching **commands** to elements to execute javascript on the client side. + +See [#interactivity-events](#interactivity-events) for more information on event handlers. + +
+ +**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments. +'self' is the current element, and 'event' is the event object. + +If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/). + +Commands: + +```go +js.AddAttribute(string, value) +js.RemoveAttribute(string) +js.AddClass(string, value) +js.SetText(string) +js.Increment(count) +js.SetInnerHtml(Ren) +js.SetOuterHtml(Ren) +js.SetDisabled(bool) +js.RemoveClass(string) +js.Alert(string) +js.EvalJs(string) // eval arbitrary js, use 'self' to get the current element as a reference +js.InjectScript(string) +js.InjectScriptIfNotExist(string) +js.GetPartial(PartialFunc) +js.GetPartialWithQs(PartialFunc, Qs) +js.PostPartial(PartialFunc) +js.PostPartialWithQs(PartialFunc, Qs) +js.GetWithQs(string, Qs) +js.PostWithQs(string, Qs) +js.ToggleClass(string) +js.ToggleClassOnElement(string, string) + +// The following methods are used to evaluate JS on nearby elements. +// Use 'element' to get the element as a reference for the EvalJs methods. +js.EvalJsOnParent(string) +js.EvalJsOnSibling(string, string) +js.EvalJsOnChildren(string, string) +js.SetClassOnParent(string) +js.RemoveClassOnParent(string) +js.SetClassOnChildren(string, string) +js.RemoveClassOnChildren(string, string) +js.SetClassOnSibling(string, string) +js.RemoveClassOnSibling(string, string) + +``` +For more usages: see https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go + + +**Example:** Evaluating arbitrary JS + +```go +func MyButton() *h.Element { + return h.Button( + h.Text("Submit"), + h.OnClick( + // make sure you use 'self' instead of 'this' + // for referencing the current element + h.EvalJs(` + if(Math.random() > 0.5) { + self.innerHTML = "Success!"; + } + `, + ), + ), + ) +} +``` + +tip: If you are using Jetbrains IDE's, you can write `// language=js` as a comment above the function call (h.EvalJS) and it will automatically give you syntax highlighting on the raw JS. + +```go +// language=js +h.EvalJs(` + if(Math.random() > 0.5) { + self.innerHTML = "Success!"; + } + `, +), +``` diff --git a/htmgo-site/md/docs/5_performance/1_caching_globally.md b/htmgo-site/md/docs/5_performance/1_caching_globally.md index 8ba3258..d8c7e9c 100644 --- a/htmgo-site/md/docs/5_performance/1_caching_globally.md +++ b/htmgo-site/md/docs/5_performance/1_caching_globally.md @@ -1,4 +1,5 @@ -**Caching Components Globally** +## Performance +### Caching Components Globally You may want to cache components to improve performance. This is especially useful for components that are expensive to render or make external requests for data. diff --git a/htmgo-site/md/docs/5_performance/1_caching_per_user.md b/htmgo-site/md/docs/5_performance/1_caching_per_user.md index 5052fef..6ba40b0 100644 --- a/htmgo-site/md/docs/5_performance/1_caching_per_user.md +++ b/htmgo-site/md/docs/5_performance/1_caching_per_user.md @@ -1,4 +1,4 @@ -**Caching Components Per User** +### Caching Components Per User If you need to cache a component per user, you can use the `CachedPerKey` functions. These functions allow you to cache a component by a specific key. This key can be any string that uniquely identifies the user. diff --git a/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md b/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md index 2e64f89..2a185ab 100644 --- a/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md +++ b/htmgo-site/md/docs/6_pushing_data/1_server_sent_events.md @@ -1,4 +1,4 @@ -**Server Sent Events (SSE)** +## Server Sent Events (SSE) htmgo supports server-sent events (SSE) out of the box. This allows you to push data from the server to the client in real-time. diff --git a/htmgo-site/md/docs/7_htmx_extensions/1_overview.md b/htmgo-site/md/docs/7_htmx_extensions/1_overview.md new file mode 100644 index 0000000..b47de67 --- /dev/null +++ b/htmgo-site/md/docs/7_htmx_extensions/1_overview.md @@ -0,0 +1,34 @@ +## HTMX Extensions + +htmgo provides a few extra htmx extensions to make common tasks easier. +Some of these extensions are optional, and some of these are required for htmgo to work correctly. + +The following extensions are provided by htmgo: +- [Trigger Children](#htmx-extensions-trigger-children) +- [Mutation Error](#htmx-extensions-mutation-error) +- [SSE](#pushing-data-server-sent-events) +- [Path Deps](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/path-deps/README.md) + +Default extensions should be included in your project by adding the following attribute to your html tag. +```go +h.Html( + h.HxExtension(h.BaseExtensions()) +) +``` + +If you need to combine multiple extensions, you can use: + +```go +h.HxExtensions(h.BaseExtensions(), "my-extension"), +``` +or +```go +h.JoinExtensions( + h.HxExtension("sse"), + h.HxExtension("my-extension"), +), +``` + + +**Important**: h.BaseExtensions will add the the 'htmgo' extension, which is a required extension for inline scripts to work properly, please always include it in your project. + diff --git a/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md b/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md new file mode 100644 index 0000000..0caf026 --- /dev/null +++ b/htmgo-site/md/docs/7_htmx_extensions/2_trigger_children.md @@ -0,0 +1,13 @@ +## HTMX Extensions - Trigger Children + +The `trigger-children` extension allows you to trigger an event on all children and siblings of an element. + +This is useful for things such as: +1. Letting a child element (such as a button) inside a form know the form was submitted + +
+ +**Example:** https://github.com/maddalax/htmgo/blob/master/htmgo-site/pages/form.go#L17 + +In this example: The trigger-children extension will trigger **hx-before-request** and **hx-after-request** +on all children of the form when the form is submitted, and the button reacts to that by showing a loading state. diff --git a/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md b/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md new file mode 100644 index 0000000..467206a --- /dev/null +++ b/htmgo-site/md/docs/7_htmx_extensions/3_mutation_error.md @@ -0,0 +1,24 @@ +## HTMX Extensions - Mutation Error + +The `mutation-error` extension allows you to trigger an event when a request returns a >= 400 status code. + +This is useful for things such as: +1. Letting a child element (such as a button) inside a form know there was an error. + +
+ +**Example:** +```go +h.Form( + h.HxTriggerChildren(), + h.HxMutationError( + js.Alert("An error occurred"), + ), + h.Button( + h.Type("submit"), + h.Text("Submit"), + ), +) +``` + +It can also be used on children elements that do not make an xhr request, if you combine it with the `hx-trigger-children` extension. diff --git a/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md b/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md new file mode 100644 index 0000000..8c958dc --- /dev/null +++ b/htmgo-site/md/docs/8_miscellaneous/1_tailwind_intellisense.md @@ -0,0 +1,53 @@ +## Tailwind intellisense + +Tailwind's language server allows you to specify custom configuration on what it should match to start giving you tailwind intellisense. + + +![](/public/tailwind-intellisense.png) + +To make this work, you will need to update the tailwind lsp config with the config below: + +Main thing to note here is +1. "go" is added to the includeLanguages list +2. classRegex is updated to match the tailwind classes in the go code. + +### Jetbrains IDE's (GoLand) +```json +{ + "includeLanguages": { + "go": "html" + }, + "experimental": { + "configFile": null, + "classRegex": [ + ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"] + ] + } +} +``` +To find this configuration in GoLand you can go to `Settings -> Languages & Frameworks -> Style Sheets -> Tailwind CSS` and update the configuration there. +These changes are additive, add these options to your existing tailwind lsp config, instead of replacing the entire file. + +See more: https://github.com/tailwindlabs/tailwindcss/issues/7553#issuecomment-735915659 + +
+ +### Visual Studio Code +For VSCode, you should be able to update your settings.json with the following values: + +```json +{ + "tailwindCSS.includeLanguages": { + "go": "html" + }, + "tailwindCSS.experimental.classRegex": [ + ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`"] + ] +} +``` diff --git a/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md b/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md new file mode 100644 index 0000000..54fb86c --- /dev/null +++ b/htmgo-site/md/docs/8_miscellaneous/2_converting_raw_html_to_go.md @@ -0,0 +1,4 @@ +## Converting Raw HTML to Go + +In some cases, you may want to convert raw HTML to Go code. +A tool to do this automatically is available here: https://htmgo.dev/html-to-go diff --git a/htmgo-site/md/docs/8_troubleshooting/1_common_issues.md b/htmgo-site/md/docs/9_troubleshooting/1_common_issues.md similarity index 75% rename from htmgo-site/md/docs/8_troubleshooting/1_common_issues.md rename to htmgo-site/md/docs/9_troubleshooting/1_common_issues.md index 3e64a82..e9d9792 100644 --- a/htmgo-site/md/docs/8_troubleshooting/1_common_issues.md +++ b/htmgo-site/md/docs/9_troubleshooting/1_common_issues.md @@ -1,4 +1,4 @@ -## **Troubleshooting:** +## Troubleshooting: **command not found: htmgo** -ensure you installed htmgo above and ensure GOPATH is set in your shell \ No newline at end of file +ensure you installed htmgo above and ensure GOPATH is set in your shell diff --git a/htmgo-site/md/index.md b/htmgo-site/md/index.md index b967f01..3e9ca0c 100644 --- a/htmgo-site/md/index.md +++ b/htmgo-site/md/index.md @@ -23,4 +23,4 @@ func IndexPage(ctx *h.RequestContext) *h.Page { 3. automatic page and partial registration based on file path 4. built in tailwindcss support, no need to configure anything by default 5. plugin architecture to include optional plugins to streamline development, such as http://entgo.io -6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/b610aefa36e648b98a13823a6f8d87566120cfcc/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks +6. custom [htmx extensions](https://github.com/maddalax/htmgo/tree/master/framework/assets/js/htmxextensions) to reduce boilerplate with common tasks diff --git a/htmgo-site/pages/base/root.go b/htmgo-site/pages/base/root.go index 04b79a6..4b0e185 100644 --- a/htmgo-site/pages/base/root.go +++ b/htmgo-site/pages/base/root.go @@ -17,12 +17,15 @@ func RootPage(ctx *h.RequestContext, children ...h.Ren) *h.Element { h.Head( h.Meta("viewport", "width=device-width, initial-scale=1"), h.Meta("title", title), + h.Link("/public/favicon.ico", "icon"), + h.Link("/public/apple-touch-icon.png", "apple-touch-icon"), h.Meta("charset", "utf-8"), h.Meta("author", "htmgo"), h.Meta("description", description), h.Meta("og:title", title), h.Meta("og:url", "https://htmgo.dev"), h.Link("canonical", "https://htmgo.dev"), + h.Link("https://cdn.jsdelivr.net/npm/@docsearch/css@3", "stylesheet"), h.Meta("og:description", description), h.LinkWithVersion("/public/main.css", "stylesheet", Version), h.ScriptWithVersion("/public/htmgo.js", Version), @@ -33,9 +36,33 @@ func RootPage(ctx *h.RequestContext, children ...h.Ren) *h.Element { `), ), h.Body( - h.Class("bg-stone-50 min-h-screen overflow-x-hidden"), - partials.NavBar(ctx, false), + h.Class("bg-stone-50 h-screen"), h.Fragment(children...), + h.Script("https://cdn.jsdelivr.net/npm/@docsearch/js@3"), + h.UnsafeRawScript(` + docsearch({ + insights: true, + appId: "9IO2WZA8L1", + apiKey: "d8cd8b6f8f8a0c961ce971e09dbde90a", + indexName: "htmgo", + container: "#search-container", + debug: false + }); + `), + ), + ) +} + +func PageWithNav(ctx *h.RequestContext, children ...h.Ren) *h.Element { + return RootPage(ctx, + h.Fragment( + partials.NavBar(ctx, partials.NavBarProps{ + Expanded: false, + ShowPreRelease: true, + }), + h.Div( + children..., + ), ), ) } diff --git a/htmgo-site/pages/docs.go b/htmgo-site/pages/docs.go index 6f55173..f85e689 100644 --- a/htmgo-site/pages/docs.go +++ b/htmgo-site/pages/docs.go @@ -15,37 +15,48 @@ func DocsPage(ctx *h.RequestContext) *h.Page { return h.NewPage(base.RootPage( ctx, h.Div( - h.Class("flex flex-col md:flex-row gap-6 justify-center overflow-x-hidden"), + h.Class("flex h-full"), h.Aside( - h.Class("md:h-screen md:sticky md:top-0 md:w-42"), // Applied sticky positioning here + h.Class("hidden md:block md:min-w-60 text-white overflow-y-auto"), partials.DocSidebar(pages), ), - h.Main( - h.Class("md:flex gap-4 justify-center mb-6"), - h.Div( - h.Class("flex flex-col"), + h.Div( + h.Class("flex flex-col flex-1 overflow-hidden"), + partials.NavBar(ctx, partials.NavBarProps{ + Expanded: false, + ShowPreRelease: false, + }), + h.Main( h.Div( - h.Class("flex flex-col justify-center items-center md:mt-6 "), - h.List(pages, func(page *dirwalk.Page, index int) *h.Element { - anchor := partials.CreateAnchor(page.Parts) - return h.Div( - h.Class("border-b border-b-slate-300 w-full pb-8 p-4 md:px-0 -mb-2"), - MarkdownContent(ctx, page.FilePath, anchor), - h.Div( - h.Class("ml-4 pl-1 mt-2 bg-rose-200"), - h.If(anchor == "core-concepts-partials", - h.GetPartial(partials.CurrentTimePartial, "load, every 1s"), - ), - ), - ) - }), + h.Class("w-full md:hidden bg-neutral-50 overflow-y-auto"), + partials.DocSidebar(pages), ), + h.Class("overflow-y-auto justify-center md:mx-auto overflow-x-hidden pb-6"), h.Div( - h.Class("flex justify-center items-center mt-6"), - h.A( - h.Text("Back to Top"), - h.Class("py-2 px-3 bg-slate-800 rounded text-white"), - h.Href("#"), + h.Class("flex flex-col"), + h.Div( + h.Class("flex flex-col justify-center items-center md:mt-6 "), + h.List(pages, func(page *dirwalk.Page, index int) *h.Element { + anchor := partials.CreateAnchor(page.Parts) + return h.Div( + h.Class("border-b border-b-slate-300 w-full pb-8 p-4 md:px-0 -mb-2"), + MarkdownContent(ctx, page.FilePath, anchor), + h.Div( + h.Class("ml-4 pl-1 mt-2 bg-rose-200"), + h.If(anchor == "core-concepts-partials", + h.GetPartial(partials.CurrentTimePartial, "load, every 1s"), + ), + ), + ) + }), + ), + h.Div( + h.Class("flex justify-center items-center mt-6"), + h.A( + h.Text("Back to Top"), + h.Class("py-2 px-3 bg-slate-800 rounded text-white"), + h.Href("#quick-start-introduction"), + ), ), ), ), diff --git a/htmgo-site/pages/examples.go b/htmgo-site/pages/examples.go index 73aaf2b..6922abc 100644 --- a/htmgo-site/pages/examples.go +++ b/htmgo-site/pages/examples.go @@ -14,6 +14,13 @@ type Example struct { } var examples = []Example{ + { + Title: "Hacker News Clone", + Github: "https://github.com/maddalax/htmgo/tree/master/examples/hackernews", + Description: "A hacker news reader clone built with htmgo", + Demo: "https://hn.htmgo.dev", + Image: "public/hn-example.jpg", + }, { Title: "Chat App Example", Github: "https://github.com/maddalax/htmgo/tree/master/examples/chat", @@ -43,7 +50,7 @@ var examples = []Example{ func ExamplesPage(ctx *h.RequestContext) *h.Page { return h.NewPage( - base.RootPage(ctx, h.Div( + base.PageWithNav(ctx, h.Div( h.Class("flex items-center justify-center"), h.Div( h.Class("w-full px-4 flex flex-col prose max-w-[95vw] md:max-w-3xl mt-6"), @@ -74,7 +81,7 @@ func ExamplesPage(ctx *h.RequestContext) *h.Page { func ExampleCards() *h.Element { return h.Div( - h.Class("prose-h2:my-1 prose-img:my-1 grid grid-cols-1 md:grid-cols-2 gap-6 text-center pb-8"), // Using grid for 3-column layout + h.Class("prose-h2:my-1 prose-img:my-1 grid grid-cols-1 gap-6 text-center pb-8"), h.List(examples, func(example Example, index int) *h.Element { return h.Div( h.Class("border border-gray-200 shadow-sm rounded-md px-4 pb-4 bg-neutral-100"), // Removed specific width, handled by grid @@ -90,22 +97,24 @@ func ExampleCards() *h.Element { h.Class("not-prose"), h.Img( h.Src(example.Image), - h.Class("md:w-full rounded-md mx-auto"), + h.Class("w-[75%] rounded-md mx-auto"), ), ), // Ensures image is centered within the card )), - h.If(example.Description != "", h.Pf(example.Description)), + h.If(example.Description != "", h.Div( + h.Pf(example.Description), + )), h.Div( h.Div( - h.Class("flex gap-2 justify-center mt-2"), // Slight margin-top for spacing from the image + h.Class("flex gap-2 justify-center mt-2"), h.A( h.Href(example.Github), - h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), // Reduced padding for the buttons + h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), h.Text("Github"), ), h.A( h.Href(example.Demo), - h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), // Reduced padding for the buttons + h.Class("not-prose p-2 bg-slate-900 text-white rounded-md"), h.Text("Demo"), ), ), diff --git a/htmgo-site/pages/html-to-go.go b/htmgo-site/pages/html-to-go.go new file mode 100644 index 0000000..644d836 --- /dev/null +++ b/htmgo-site/pages/html-to-go.go @@ -0,0 +1,26 @@ +package pages + +import ( + "github.com/maddalax/htmgo/framework/h" + "htmgo-site/pages/base" + "htmgo-site/partials" +) + +func HtmlToGoPage(ctx *h.RequestContext) *h.Page { + return h.NewPage( + base.PageWithNav(ctx, + h.Div( + h.Class("flex flex-col h-full items-center justify-center w-full pt-6"), + h.H3( + h.Text("Convert raw html to htmgo code"), + h.Class("text-2xl font-bold"), + ), + h.Div( + h.Class("h-full w-full flex gap-4 p-8"), + partials.HtmlInput(), + partials.GoOutput(""), + ), + ), + ), + ) +} diff --git a/htmgo-site/pages/index.go b/htmgo-site/pages/index.go index 91cde87..8470795 100644 --- a/htmgo-site/pages/index.go +++ b/htmgo-site/pages/index.go @@ -7,7 +7,7 @@ import ( func IndexPage(ctx *h.RequestContext) *h.Page { return h.NewPage( - base.RootPage(ctx, h.Div( + base.PageWithNav(ctx, h.Div( h.Class("flex items-center justify-center"), h.Div( h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"), diff --git a/htmgo-site/pages/markdown.go b/htmgo-site/pages/markdown.go index 7239048..6bdc933 100644 --- a/htmgo-site/pages/markdown.go +++ b/htmgo-site/pages/markdown.go @@ -22,7 +22,7 @@ func MarkdownContent(ctx *h.RequestContext, path string, id string) *h.Element { return h.Div( h.If(id != "", h.Id(id)), h.Div( - h.Class("w-full flex flex-col prose max-w-[95vw] md:max-w-3xl prose-code:text-black prose-p:my-1 prose:p-0 prose-li:m-0 prose-ul:m-0 prose-ol:m-0"), + h.Class("w-full flex flex-col prose max-w-md md:max-w-xl lg:max-w-3xl prose-code:text-black prose-p:my-1 prose:p-0 prose-li:m-0 prose-ul:m-0 prose-ol:m-0"), h.UnsafeRaw(renderer.RenderFile(path, embeddedMd)), ), ) diff --git a/htmgo-site/partials/html-to-go.go b/htmgo-site/partials/html-to-go.go new file mode 100644 index 0000000..3669db8 --- /dev/null +++ b/htmgo-site/partials/html-to-go.go @@ -0,0 +1,41 @@ +package partials + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/tools/html-to-htmgo/htmltogo" + "htmgo-site/ui" +) + +func ConvertHtmlToGo(ctx *h.RequestContext) *h.Partial { + value := ctx.FormValue("html-input") + parsed := htmltogo.Parse([]byte(value)) + + formatted := ui.FormatCode(string(parsed), "height: 100%;") + + return h.SwapPartial(ctx, GoOutput(formatted)) +} + +func HtmlInput() *h.Element { + return h.Div( + h.Class("h-[90%] w-1/2 min-w-1/2"), + h.TextArea( + h.Name("html-input"), + h.MaxLength(500*1000), + h.PostPartial(ConvertHtmlToGo, "keyup delay:300ms"), + h.Class("h-[90%] w-full p-4 rounded border border-slate-200"), + h.Placeholder("Paste your HTML here"), + h.Rows(10), + ), + ) +} + +func GoOutput(content string) *h.Element { + return h.Div( + h.Class("h-[90%] w-1/2 min-w-1/2"), + h.Id("go-output"), + h.Div( + h.Class("h-[90%] w-full rounded border border-slate-200"), + h.UnsafeRaw(content), + ), + ) +} diff --git a/htmgo-site/partials/navbar.go b/htmgo-site/partials/navbar.go index c384b88..913e09f 100644 --- a/htmgo-site/partials/navbar.go +++ b/htmgo-site/partials/navbar.go @@ -15,6 +15,12 @@ type NavItem struct { var navItems = []NavItem{ {Name: "Docs", Url: "/docs"}, {Name: "Examples", Url: "/examples"}, + {Name: "Convert HTML", Url: "/html-to-go"}, +} + +type NavBarProps struct { + Expanded bool + ShowPreRelease bool } func ToggleNavbar(ctx *h.RequestContext) *h.Partial { @@ -68,15 +74,16 @@ func Star(ctx *h.RequestContext) *h.Element { ) } -func NavBar(ctx *h.RequestContext, expanded bool) *h.Element { - prelease := h.A(h.Class("bg-yellow-200 text-yellow-800 text-center p-2 flex items-center justify-center"), +func NavBar(ctx *h.RequestContext, props NavBarProps) *h.Element { + prelease := h.If(props.ShowPreRelease, h.A( + h.Class("bg-yellow-200 text-yellow-800 text-center p-2 flex items-center justify-center"), h.Href("https://github.com/maddalax/htmgo/issues"), h.Attribute("target", "_blank"), h.Text("htmgo is in alpha release. Please report any issues on GitHub."), - ) + )) desktopNav := h.Nav( - h.Class("hidden sm:block bg-neutral-100 border border-b-slate-300 p-4 md:p-3"), + h.Class("hidden sm:block bg-neutral-100 border border-b-slate-300 p-4 md:p-3 max-h-[100vh - 9rem] overflow-y-auto"), h.Div( h.Class("max-w-[95%] md:max-w-3xl px-4 mx-auto"), h.Div( @@ -88,6 +95,9 @@ func NavBar(ctx *h.RequestContext, expanded bool) *h.Element { h.Href("/"), h.Text("htmgo"), )), + h.Div( + h.Id("search-container"), + ), h.Div( h.Class("flex gap-4 items-center"), h.List(navItems, func(item NavItem, index int) *h.Element { @@ -109,7 +119,7 @@ func NavBar(ctx *h.RequestContext, expanded bool) *h.Element { return h.Div( h.Id("navbar"), prelease, - MobileNav(ctx, expanded), + MobileNav(ctx, props.Expanded), desktopNav, ) } diff --git a/htmgo-site/partials/sidebar.go b/htmgo-site/partials/sidebar.go index 8a38d63..8387f48 100644 --- a/htmgo-site/partials/sidebar.go +++ b/htmgo-site/partials/sidebar.go @@ -57,9 +57,16 @@ func DocSidebar(pages []*dirwalk.Page) *h.Element { grouped := groupByFirstPart(pages) return h.Div( - h.Class("px-3 py-2 pr-6 md:min-h-[(calc(100%))] md:min-h-screen bg-neutral-50 border-r border-r-slate-300"), + h.Class("px-3 py-2 pr-6 min-h-screen bg-neutral-50 border-r border-r-slate-300 overflow-y-auto"), h.Div( - h.H4(h.Text("Contents"), h.Class("mt-4 text-slate-900 font-bold mb-3")), + h.Div( + h.Class("mb-3"), + h.A( + h.Href("#quick-start-introduction"), + h.Text("Documentation"), + h.Class("md:mt-4 text-xl text-slate-900 font-bold"), + ), + ), h.Div( h.Class("flex flex-col gap-4"), h.List(grouped.Entries(), func(entry datastructures.Entry[string, []*dirwalk.Page], index int) *h.Element { diff --git a/htmgo-site/ui/snippet.go b/htmgo-site/ui/snippet.go new file mode 100644 index 0000000..68c9c1c --- /dev/null +++ b/htmgo-site/ui/snippet.go @@ -0,0 +1,34 @@ +package ui + +import ( + "bytes" + "fmt" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/maddalax/htmgo/framework/h" + "strings" +) + +func FormatCode(code string, customStyles ...string) string { + var buf bytes.Buffer + lexer := lexers.Get("go") + style := styles.Get("github") + formatter := html.New( + html.WithCustomCSS(map[chroma.TokenType]string{ + chroma.PreWrapper: fmt.Sprintf("padding: 12px; overflow: auto; %s", strings.Join(customStyles, ";")), + })) + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return "" + } + err = formatter.Format(&buf, style, iterator) + return buf.String() +} + +func CodeSnippet(code string) *h.Element { + return h.Div( + h.UnsafeRaw(FormatCode(code)), + ) +} diff --git a/tailwind-lsp-config.json b/tailwind-lsp-config.json index f6afb22..ae70df6 100644 --- a/tailwind-lsp-config.json +++ b/tailwind-lsp-config.json @@ -38,9 +38,11 @@ }, "experimental": { "configFile": null, - "classRegex": [[ - "Class|h.Class\\(([^)]*)\\)", - "[\"'`]([^\"'`]*).*?[\"'`]" - ]] + "classRegex": [ + ["Class\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassX\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["ClassIf\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"], + ["Classes\\(([^)]*)\\)", "[\"`]([^\"`]*)[\"`]"] + ] } -} \ No newline at end of file +} diff --git a/templates/starter/Taskfile.yml b/templates/starter/Taskfile.yml index 695006f..28f1902 100644 --- a/templates/starter/Taskfile.yml +++ b/templates/starter/Taskfile.yml @@ -3,12 +3,12 @@ version: '3' tasks: run: cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest run + - htmgo run silent: true build: cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest build + - htmgo build docker: cmds: @@ -16,5 +16,5 @@ tasks: watch: cmds: - - go run github.com/maddalax/htmgo/cli/htmgo@latest watch - silent: true \ No newline at end of file + - htmgo watch + silent: true diff --git a/templates/starter/assets/public/apple-touch-icon.png b/templates/starter/assets/public/apple-touch-icon.png new file mode 100644 index 0000000..d10e9fe Binary files /dev/null and b/templates/starter/assets/public/apple-touch-icon.png differ diff --git a/templates/starter/assets/public/favicon.ico b/templates/starter/assets/public/favicon.ico new file mode 100644 index 0000000..040cccf Binary files /dev/null and b/templates/starter/assets/public/favicon.ico differ diff --git a/templates/starter/assets/public/icon-192-maskable.png b/templates/starter/assets/public/icon-192-maskable.png new file mode 100644 index 0000000..d4d6efb Binary files /dev/null and b/templates/starter/assets/public/icon-192-maskable.png differ diff --git a/templates/starter/assets/public/icon-192.png b/templates/starter/assets/public/icon-192.png new file mode 100644 index 0000000..f533435 Binary files /dev/null and b/templates/starter/assets/public/icon-192.png differ diff --git a/templates/starter/assets/public/icon-512-maskable.png b/templates/starter/assets/public/icon-512-maskable.png new file mode 100644 index 0000000..db61f3d Binary files /dev/null and b/templates/starter/assets/public/icon-512-maskable.png differ diff --git a/templates/starter/assets/public/icon-512.png b/templates/starter/assets/public/icon-512.png new file mode 100644 index 0000000..ba0665d Binary files /dev/null and b/templates/starter/assets/public/icon-512.png differ diff --git a/templates/starter/go.mod b/templates/starter/go.mod index d1e3c32..9c33612 100644 --- a/templates/starter/go.mod +++ b/templates/starter/go.mod @@ -2,7 +2,7 @@ module starter-template go 1.23.0 -require github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b +require github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d require ( github.com/go-chi/chi/v5 v5.1.0 // indirect diff --git a/templates/starter/go.sum b/templates/starter/go.sum index 5767c88..531d9d1 100644 --- a/templates/starter/go.sum +++ b/templates/starter/go.sum @@ -4,8 +4,8 @@ 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-20241006162137-150c87b4560b h1:LzZTNwIGe0RHiEJZlpnpN8GRnKg2lCZppMX+JIyeF/g= -github.com/maddalax/htmgo/framework v0.0.0-20241006162137-150c87b4560b/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d h1:oysEaiKB7/WbvEklkyQ7SEE1xmDeGLrBUvF3BAsBUns= +github.com/maddalax/htmgo/framework v0.0.0-20241014151703-8503dffa4e7d/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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= diff --git a/templates/starter/htmgo.yml b/templates/starter/htmgo.yml new file mode 100644 index 0000000..d60d2ff --- /dev/null +++ b/templates/starter/htmgo.yml @@ -0,0 +1,10 @@ +# htmgo configuration + +# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist +tailwind: true + +# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar +watch_ignore: [".git", "node_modules", "dist/*"] + +# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar +watch_files: ["**/*.go", "**/*.css", "**/*.md"] diff --git a/templates/starter/pages/root.go b/templates/starter/pages/root.go index 63374f1..bacdd61 100644 --- a/templates/starter/pages/root.go +++ b/templates/starter/pages/root.go @@ -6,8 +6,19 @@ 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"), + 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"), ), diff --git a/tools/html-to-htmgo/go.mod b/tools/html-to-htmgo/go.mod new file mode 100644 index 0000000..6320b14 --- /dev/null +++ b/tools/html-to-htmgo/go.mod @@ -0,0 +1,8 @@ +module github.com/maddalax/htmgo/tools/html-to-htmgo + +go 1.23.0 + +require ( + golang.org/x/net v0.30.0 + golang.org/x/text v0.19.0 +) diff --git a/tools/html-to-htmgo/go.sum b/tools/html-to-htmgo/go.sum new file mode 100644 index 0000000..613920c --- /dev/null +++ b/tools/html-to-htmgo/go.sum @@ -0,0 +1,8 @@ +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/tools/html-to-htmgo/htmltogo/main.go b/tools/html-to-htmgo/htmltogo/main.go new file mode 100644 index 0000000..ce382c1 --- /dev/null +++ b/tools/html-to-htmgo/htmltogo/main.go @@ -0,0 +1,22 @@ +// Forked from https://github.com/PiotrKowalski/html-to-gomponents + +package htmltogo + +import ( + serviceformatter "github.com/maddalax/htmgo/tools/html-to-htmgo/internal/adapters/services/formatter" + serviceparser "github.com/maddalax/htmgo/tools/html-to-htmgo/internal/adapters/services/parser" +) + +func Parse(input []byte) []byte { + parser := serviceparser.New() + formatter := serviceformatter.New() + parsed, err := parser.FromBytes( + input, + ) + + if err != nil { + return nil + } + + return []byte(formatter.Format(parsed)) +} diff --git a/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go b/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go new file mode 100644 index 0000000..295a0af --- /dev/null +++ b/tools/html-to-htmgo/internal/adapters/services/formatter/formatter.go @@ -0,0 +1,29 @@ +package formatter + +import ( + "github.com/maddalax/htmgo/tools/html-to-htmgo/internal/domain" + "go/format" +) + +type Formatter struct { +} + +func (f Formatter) Format(node *domain.CustomNode) string { + b := []byte(`package main +import ( + "github.com/maddalax/htmgo/framework/h" +) +func MyComponent() *h.Element { + return ` + node.String() + ` +}`) + dist, err := format.Source(b) + if err != nil { + return string(b) + } + + return string(dist) +} + +func New() Formatter { + return Formatter{} +} diff --git a/tools/html-to-htmgo/internal/adapters/services/parser/parser.go b/tools/html-to-htmgo/internal/adapters/services/parser/parser.go new file mode 100644 index 0000000..0fe45e3 --- /dev/null +++ b/tools/html-to-htmgo/internal/adapters/services/parser/parser.go @@ -0,0 +1,69 @@ +package parser + +import ( + "bytes" + "errors" + "fmt" + "github.com/maddalax/htmgo/tools/html-to-htmgo/internal/domain" + "golang.org/x/net/html" + "strings" +) + +type Parser struct { +} + +var ParseErr = errors.New("parse error") + +func (p Parser) FromBytes(in []byte) (*domain.CustomNode, error) { + hNode, err := html.Parse(bytes.NewReader(in)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ParseErr, err) + } + var findBody func(n *html.Node) *html.Node + findBody = func(n *html.Node) *html.Node { + if n.Data == "body" { + return n + } + var e *html.Node + for c := n.FirstChild; c != nil; c = c.NextSibling { + e = findBody(c) + } + return e + } + + body := findBody(hNode) + if body == nil { + return nil, fmt.Errorf("%w", ParseErr) + } + + var f func(*html.Node, *domain.CustomNode) *domain.CustomNode + f = func(n *html.Node, cNode *domain.CustomNode) *domain.CustomNode { + if n.Type == html.ElementNode { + cNode.SetType(n.Data) + + for _, attr := range n.Attr { + cNode.AddAttr(attr.Key, attr.Val) + } + } + + if n.Type == html.TextNode && len(strings.TrimSpace(n.Data)) > 0 { + cNode.ParentNode.AddAttr("h.Text", strings.TrimSpace(n.Data)) + } + + var i uint + for c := n.FirstChild; c != nil; c = c.NextSibling { + cNode.Nodes = append(cNode.Nodes, &domain.CustomNode{ParentNode: cNode, Level: cNode.Level + 1}) + cNode.Nodes[i] = f(c, cNode.Nodes[i]) + i++ + } + return cNode + } + output := &domain.CustomNode{} + out := f(body, output) + + return out, nil +} + +func New() Parser { + return Parser{} +} diff --git a/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go b/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go new file mode 100644 index 0000000..5d2de8d --- /dev/null +++ b/tools/html-to-htmgo/internal/adapters/services/parser/parser_test.go @@ -0,0 +1,33 @@ +package parser + +import ( + "errors" + "testing" +) + +func FuzzFromBytes(f *testing.F) { + serviceParser := New() + f.Add([]byte("Hello World")) + f.Add([]byte("TestSample")) + f.Add([]byte("
Some random text
")) + f.Add([]byte("Invalid HTML")) + f.Add([]byte("

")) + f.Add([]byte(" 10000 { // (10KB) + t.Skip() + } + _, err := serviceParser.FromBytes(data) + if err != nil { + return + } + if err != nil && !isExpectedError(err) { + t.Errorf("Unexpected error: %v", err) + } + }) +} + +func isExpectedError(err error) bool { + return err != nil && errors.Is(err, ParseErr) +} diff --git a/tools/html-to-htmgo/internal/domain/formatter.go b/tools/html-to-htmgo/internal/domain/formatter.go new file mode 100644 index 0000000..35c4709 --- /dev/null +++ b/tools/html-to-htmgo/internal/domain/formatter.go @@ -0,0 +1,5 @@ +package domain + +type Formatter interface { + Format(node *CustomNode) (string, error) +} diff --git a/tools/html-to-htmgo/internal/domain/node.go b/tools/html-to-htmgo/internal/domain/node.go new file mode 100644 index 0000000..a90e52e --- /dev/null +++ b/tools/html-to-htmgo/internal/domain/node.go @@ -0,0 +1,178 @@ +package domain + +import ( + "fmt" + "slices" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type CustomNode struct { + ParentNode *CustomNode + Level uint + customType bool + Type string + Attrs []Attr + Nodes []*CustomNode +} + +func (n *CustomNode) SetType(in string) { + switch in { + case "textarea": + n.Type = "h.TextArea" + case "head": + n.Type = "h.Head" + case "thead": + n.Type = "h.THead" + case "tbody": + n.Type = "h.TBody" + case "id": + n.Type = "h.Id" + case "path": + n.Type = "path" + n.customType = true + case "circle": + n.Type = "circle" + n.customType = true + case "rect": + n.Type = "rect" + n.customType = true + case "line": + n.Type = "line" + n.customType = true + case "polyline": + n.Type = "line" + n.customType = true + case "svg": + n.Type = "h.Svg" + default: + n.Type = fmt.Sprintf("h.%s", cases.Title(language.English).String(in)) + } +} + +func (n *CustomNode) AddAttr(key, value string) { + if slices.Contains([]string{"xmlns", "fill", "viewBox", "stroke", "stroke-width", "fill-rule", "d", "stroke-linecap", "stroke-linejoin", "cx", "cy", "r", "x", "y", "rx", "ry", "x1", "x2", "y1", "y2", "points"}, key) { + n.Attrs = append(n.Attrs, Attr{ + custom: true, + key: key, + value: value, + }) + return + } + + switch { + case key == "autocomplete": + n.Attrs = append(n.Attrs, Attr{key: "h.AutoComplete", value: value}) + case key == "id": + n.Attrs = append(n.Attrs, Attr{key: "h.Id", value: value}) + case key == "tabindex": + n.Attrs = append(n.Attrs, Attr{key: "h.TabIndex", value: value}) + case key == "h.Text": + n.Attrs = append(n.Attrs, Attr{key: key, value: value}) + case strings.ContainsRune(key, '-'): + n.Attrs = append(n.Attrs, Attr{ + custom: true, + key: key, + value: value, + }) + fmt.Printf("key: %s, value: %s\n", key, value) + default: + n.Attrs = append(n.Attrs, Attr{key: "h." + cases.Title(language.English).String(key), value: value}) + } +} + +func (n *CustomNode) String() string { + str := "" + + if n.customType { + str += "h.Tag(\"" + n.Type + "\"," + } else { + str += n.Type + "(" + } + + if str == "h.Input(" { + if len(n.Attrs) > 0 { + for i, attr := range n.Attrs { + if attr.key == "h.Type" { + str = str + fmt.Sprintf(`"%s"`, attr.value) + "," + n.Attrs = append(n.Attrs[:i], n.Attrs[i+1:]...) + } + } + } + } + + if str == "h.Script(" { + if len(n.Attrs) > 0 { + for _, attr := range n.Attrs { + if attr.key == "h.Src" { + str = str + fmt.Sprintf(`"%s"`, attr.value) + "," + n.Attrs = make([]Attr, 0) + } + } + } + } + + booleanAttributes := []string{ + "h.AllowFullscreen", + "h.Async", + "h.Autofocus", + "h.Autoplay", + "h.Checked", + "h.Controls", + "h.Default", + "h.Defer", + "h.Disabled", + "h.FormNoValidate", + "h.Hidden", + "h.IsMap", + "h.Loop", + "h.Multiple", + "h.Muted", + "h.NoModule", + "h.NoValidate", + "h.Open", + "h.ReadOnly", + "h.Required", + "h.Reversed", + "h.Selected", + } + + if len(n.Attrs) > 0 { + for _, v := range n.Attrs { + switch { + case v.custom: + str = fmt.Sprintf("%sh.Attribute(\"%s\",\"%s\"),", str, v.key, v.value) + case v.hyphenated: + str = fmt.Sprintf("%s%s(\"%s\", \"%s\"),", str, v.key, v.arg, v.value) + case len(v.value) > 0: + if strings.Contains(v.value, "\n") { + str = fmt.Sprintf("%s%s(`%s`),", str, v.key, v.value) + } else { + str = fmt.Sprintf("%s%s(\"%s\"),", str, v.key, v.value) + } + case v.value == "" && !slices.Contains(booleanAttributes, v.key): + str = fmt.Sprintf("%s%s(\"\"),", str, v.key) + default: + str = fmt.Sprintf("%s%s(),", str, v.key) + } + } + } + + if len(n.Nodes) > 0 { + for _, v := range n.Nodes { + if v.Type != "" { + str = fmt.Sprintf("%s\n%s%s,", str, strings.Repeat(" ", int(n.Level)), v) + } + } + } + + str = fmt.Sprintf("%s\n%s)", str, strings.Repeat(" ", int(n.Level))) + return str +} + +type Attr struct { + custom, hyphenated bool + key, value, arg string +} diff --git a/tools/html-to-htmgo/internal/domain/parser.go b/tools/html-to-htmgo/internal/domain/parser.go new file mode 100644 index 0000000..e7c1597 --- /dev/null +++ b/tools/html-to-htmgo/internal/domain/parser.go @@ -0,0 +1,5 @@ +package domain + +type HTMLParser interface { + FromBytes(bytes []byte) (*CustomNode, error) +}