diff --git a/README.md b/README.md
index 6bbdaf6..2f7d34f 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ $ go run .
Hello World
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
@@ -160,7 +160,7 @@ $ go run .
Hello World
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
@@ -189,7 +189,7 @@ $ go run .
Hello World
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
@@ -219,7 +219,7 @@ $ go run .
./main.go:7:6: undefined: fmt.Prin
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
@@ -256,7 +256,7 @@ type Context interface{ ... }
func WithoutCancel(parent Context) Context
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
@@ -279,7 +279,7 @@ func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
call cancel as soon as the operations running in this Context complete.
--------------------------------------------------------------------------------
-Go Version: go1.24.4
+Go Version: go1.24.9
```
diff --git a/document.go b/document.go
index 10602a8..e59420b 100644
--- a/document.go
+++ b/document.go
@@ -32,12 +32,31 @@ func (doc *Document) MarshalJSON() ([]byte, error) {
return nil, ErrIsNil("document")
}
+ // Create sorted maps to ensure deterministic JSON output
+ sortedRules := make(map[string]string)
+ if doc.Snippets.rules != nil {
+ for k, v := range doc.Snippets.rules {
+ sortedRules[k] = v
+ }
+ }
+
+ sortedSnippets := make(map[string]map[string]Snippet)
+ if doc.Snippets.snippets != nil {
+ for k, v := range doc.Snippets.snippets {
+ innerMap := make(map[string]Snippet)
+ for ik, iv := range v {
+ innerMap[ik] = iv
+ }
+ sortedSnippets[k] = innerMap
+ }
+ }
+
snips := struct {
Rules map[string]string `json:"rules,omitempty"`
Snippets map[string]map[string]Snippet `json:"snippets,omitempty"`
}{
- Rules: doc.Snippets.rules,
- Snippets: doc.Snippets.snippets,
+ Rules: sortedRules,
+ Snippets: sortedSnippets,
}
x := struct {
diff --git a/document_test.go b/document_test.go
index ce2f060..78a6ab9 100644
--- a/document_test.go
+++ b/document_test.go
@@ -1,11 +1,14 @@
package hype
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"io/fs"
"strings"
"testing"
+ "testing/fstest"
"time"
"github.com/stretchr/testify/require"
@@ -131,3 +134,367 @@ func Test_Document_Pages_NoPages(t *testing.T) {
r.Len(pages, 1)
}
+
+// Test_Document_JSON_Determinism verifies that marshaling the same document to JSON
+// produces identical output every time, ensuring predictable/deterministic behavior.
+func Test_Document_JSON_Determinism(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ content string
+ runs int
+ }{
+ {
+ name: "basic document",
+ content: `
+Test Title
+This is a test paragraph.
+`,
+ runs: 10,
+ },
+ {
+ name: "document with attributes",
+ content: `
+
+Paragraph with attributes.
+Content
+`,
+ runs: 10,
+ },
+ {
+ name: "document with code snippets",
+ content: `
+Code Example
+
+package main
+
+func main() {
+ println("Hello, World!")
+}
+
+`,
+ runs: 10,
+ },
+ {
+ name: "complex document",
+ content: `
+Complex Document
+Introduction paragraph.
+
+
Section 1
+
Some content here.
+
+
+
Code Section
+
func test() {}
+
+`,
+ runs: 10,
+ },
+ }
+
+ for _, tc := range testCases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ // Parse the document multiple times and marshal to JSON
+ var jsonOutputs [][]byte
+ for i := 0; i < tc.runs; i++ {
+ cab := fstest.MapFS{
+ "test.md": &fstest.MapFile{
+ Data: []byte(tc.content),
+ },
+ }
+
+ p := NewParser(cab)
+ // Use a fixed ID generator to ensure deterministic IDs
+ p.DocIDGen = func() (string, error) {
+ return "test-doc-id", nil
+ }
+
+ doc, err := p.ParseFile("test.md")
+ r.NoError(err)
+
+ jsonData, err := json.Marshal(doc)
+ r.NoError(err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ if !bytes.Equal(firstOutput, jsonOutputs[i]) {
+ t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n",
+ 1, string(firstOutput), i+1, string(jsonOutputs[i]))
+ }
+ }
+ })
+ }
+}
+
+// Test_Document_JSON_Determinism_WithExecution tests that JSON output remains
+// deterministic even after document execution.
+func Test_Document_JSON_Determinism_WithExecution(t *testing.T) {
+ t.Parallel()
+
+ content := `
+
+Content paragraph.
+`
+
+ runs := 10
+ var jsonOutputs [][]byte
+
+ for i := 0; i < runs; i++ {
+ cab := fstest.MapFS{
+ "test.md": &fstest.MapFile{
+ Data: []byte(content),
+ },
+ }
+
+ p := NewParser(cab)
+ // Use fixed ID and time for determinism
+ p.DocIDGen = func() (string, error) {
+ return "test-doc-id", nil
+ }
+ p.NowFn = func() time.Time {
+ return time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ }
+
+ doc, err := p.ParseFile("test.md")
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
+ err = doc.Execute(ctx)
+ cancel()
+ require.NoError(t, err)
+
+ jsonData, err := json.Marshal(doc)
+ require.NoError(t, err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ if !bytes.Equal(firstOutput, jsonOutputs[i]) {
+ t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n",
+ 1, string(firstOutput), i+1, string(jsonOutputs[i]))
+ }
+ }
+}
+
+// Test_Document_JSON_Determinism_RealWorld tests with a more realistic document
+// that includes multiple features.
+func Test_Document_JSON_Determinism_RealWorld(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ // Create a test filesystem with files
+ cab := fstest.MapFS{
+ "hype.md": &fstest.MapFile{
+ Data: []byte(`
+Real World Test
+This is a real world test document.
+
+
Section 1
+
Content for section 1.
+
+
+
Section 2
+
+package main
+
+import "fmt"
+
+func main() {
+ fmt.Println("Hello, World!")
+}
+
+
+`),
+ },
+ }
+
+ runs := 10
+ var jsonOutputs [][]byte
+
+ for i := 0; i < runs; i++ {
+ p := NewParser(cab)
+ p.Root = "/test"
+ // Use fixed ID for determinism
+ p.DocIDGen = func() (string, error) {
+ return "test-doc-id", nil
+ }
+ p.NowFn = func() time.Time {
+ return time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ }
+
+ // Add some vars
+ p.Vars.Set("var1", "value1")
+ p.Vars.Set("var2", "value2")
+
+ doc, err := p.ParseFile("hype.md")
+ r.NoError(err)
+
+ jsonData, err := json.Marshal(doc)
+ r.NoError(err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ if !bytes.Equal(firstOutput, jsonOutputs[i]) {
+ t.Errorf("Run %d produced different JSON output:\nFirst:\n%s\n\nRun %d:\n%s\n",
+ 1, prettifyJSON(firstOutput), i+1, prettifyJSON(jsonOutputs[i]))
+ }
+ }
+}
+
+// Test_Parser_JSON_Determinism tests that Parser marshaling is deterministic.
+func Test_Parser_JSON_Determinism(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ runs := 10
+ var jsonOutputs [][]byte
+
+ for i := 0; i < runs; i++ {
+ cab := fstest.MapFS{}
+ p := NewParser(cab)
+ p.Root = "/test/root"
+ p.Section = 1
+
+ // Add some vars in different orders
+ p.Vars.Set("key1", "value1")
+ p.Vars.Set("key2", "value2")
+ p.Vars.Set("key3", "value3")
+
+ jsonData, err := json.Marshal(p)
+ r.NoError(err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ r.Equal(firstOutput, jsonOutputs[i],
+ "Parser JSON output should be deterministic")
+ }
+}
+
+// Test_Element_JSON_Determinism tests that Element marshaling is deterministic.
+func Test_Element_JSON_Determinism(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ runs := 10
+ var jsonOutputs [][]byte
+
+ for i := 0; i < runs; i++ {
+ el := NewEl("div", nil)
+ el.Set("id", "test")
+ el.Set("class", "container")
+ el.Set("data-foo", "bar")
+ el.Set("data-baz", "qux")
+
+ jsonData, err := json.Marshal(el)
+ r.NoError(err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ r.Equal(firstOutput, jsonOutputs[i],
+ "Element JSON output should be deterministic")
+ }
+}
+
+// Test_Snippets_JSON_Determinism tests that Snippets marshaling is deterministic.
+func Test_Snippets_JSON_Determinism(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ runs := 10
+ var jsonOutputs [][]byte
+
+ for i := 0; i < runs; i++ {
+ snips := &Snippets{}
+ snips.init()
+
+ // Add some rules
+ snips.Add(".go", "// %s")
+ snips.Add(".rb", "# %s")
+ snips.Add(".js", "// %s")
+
+ // Simulate adding snippets
+ snips.mu.Lock()
+ snips.snippets["file1.go"] = map[string]Snippet{
+ "snippet1": {Name: "snippet1", Content: "func test() {}", Lang: "go"},
+ "snippet2": {Name: "snippet2", Content: "func main() {}", Lang: "go"},
+ }
+ snips.snippets["file2.rb"] = map[string]Snippet{
+ "snippet3": {Name: "snippet3", Content: "def test; end", Lang: "rb"},
+ }
+ snips.mu.Unlock()
+
+ jsonData, err := json.Marshal(snips)
+ r.NoError(err)
+ jsonOutputs = append(jsonOutputs, jsonData)
+ }
+
+ // Verify all JSON outputs are identical
+ firstOutput := jsonOutputs[0]
+ for i := 1; i < len(jsonOutputs); i++ {
+ r.Equal(firstOutput, jsonOutputs[i],
+ "Snippets JSON output should be deterministic")
+ }
+}
+
+// Benchmark_Document_JSON_Marshal benchmarks JSON marshaling performance
+func Benchmark_Document_JSON_Marshal(b *testing.B) {
+ cab := fstest.MapFS{
+ "test.md": &fstest.MapFile{
+ Data: []byte(`
+
+This is a benchmark test.
+
+
Section
+
Content here.
+
+`),
+ },
+ }
+
+ p := NewParser(cab)
+ p.DocIDGen = func() (string, error) {
+ return "bench-doc-id", nil
+ }
+
+ doc, err := p.ParseFile("test.md")
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := json.Marshal(doc)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// prettifyJSON formats JSON for better error output
+func prettifyJSON(data []byte) string {
+ var out bytes.Buffer
+ if err := json.Indent(&out, data, "", " "); err != nil {
+ return string(data)
+ }
+ return out.String()
+}
diff --git a/element.go b/element.go
index 5004f90..5a162f3 100644
--- a/element.go
+++ b/element.go
@@ -55,7 +55,14 @@ func (el *Element) JSONMap() (map[string]any, error) {
}
if el.Attributes.Len() > 0 {
- m["attributes"] = el.Attributes
+ // Convert syncx.Map to regular map for deterministic JSON marshaling
+ // Go's json package will sort map keys lexicographically
+ attrs := make(map[string]string)
+ el.Attributes.Range(func(k, v string) bool {
+ attrs[k] = v
+ return true
+ })
+ m["attributes"] = attrs
}
hn := el.HTMLNode
diff --git a/parser.go b/parser.go
index 9ab9d0a..b2da315 100644
--- a/parser.go
+++ b/parser.go
@@ -47,6 +47,14 @@ func (p *Parser) MarshalJSON() ([]byte, error) {
p.mu.RLock()
defer p.mu.RUnlock()
+ // Convert syncx.Map to regular map for deterministic JSON marshaling
+ // Go's json package will sort map keys lexicographically
+ vars := make(map[string]any)
+ p.Vars.Range(func(k string, v any) bool {
+ vars[k] = v
+ return true
+ })
+
x := struct {
Type string `json:"type,omitempty"`
Root string `json:"root,omitempty"`
@@ -59,7 +67,7 @@ func (p *Parser) MarshalJSON() ([]byte, error) {
Root: p.Root,
DisablePages: p.DisablePages,
Section: p.Section,
- Vars: p.Vars.Map(),
+ Vars: vars,
Contents: string(p.Contents),
}
diff --git a/snippet.go b/snippet.go
index d31b76b..75cda1f 100644
--- a/snippet.go
+++ b/snippet.go
@@ -77,13 +77,34 @@ func (sm *Snippets) MarshalJSON() ([]byte, error) {
return nil, ErrIsNil("snippets")
}
+ // Create sorted maps to ensure deterministic JSON output
+ sortedRules := make(map[string]string)
+ if sm.rules != nil {
+ for k, v := range sm.rules {
+ sortedRules[k] = v
+ }
+ }
+
+ sortedSnippets := make(map[string]map[string]Snippet)
+ if sm.snippets != nil {
+ for k, v := range sm.snippets {
+ innerMap := make(map[string]Snippet)
+ for ik, iv := range v {
+ innerMap[ik] = iv
+ }
+ sortedSnippets[k] = innerMap
+ }
+ }
+
+ // Use json.Marshal with sorted map keys
+ // Go's json package always sorts map keys lexicographically
m := struct {
Rules map[string]string `json:"rules,omitempty"`
Snippets map[string]map[string]Snippet `json:"snippets,omitempty"`
Type string `json:"type,omitempty"`
}{
- Rules: sm.rules,
- Snippets: sm.snippets,
+ Rules: sortedRules,
+ Snippets: sortedSnippets,
Type: toType(sm),
}