-
Notifications
You must be signed in to change notification settings - Fork 391
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(examples): add p/moul/dynreplacer (#3710)
See #3249 (comment) Expected usage: homepages that rely on other realms for parts of their rendered content. This approach simplifies updating the content layout, enables freeform content, and allows users to toggle blocks, change their order, or modify the layout itself (including titles, delimiters, etc.). --------- Signed-off-by: moul <[email protected]>
- Loading branch information
Showing
3 changed files
with
347 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
// Package dynreplacer provides a simple template engine for handling dynamic | ||
// content replacement. It is similar to strings.Replacer but with lazy | ||
// execution of replacements, making it more optimization-friendly in several | ||
// cases. While strings.Replacer requires all replacement values to be computed | ||
// upfront, dynreplacer only executes the callback functions for placeholders | ||
// that actually exist in the template, avoiding unnecessary computations. | ||
// | ||
// The package ensures efficient, non-recursive replacement of placeholders in a | ||
// single pass. This lazy evaluation approach is particularly beneficial when: | ||
// - Some replacement values are expensive to compute | ||
// - Not all placeholders are guaranteed to be present in the template | ||
// - Templates are reused with different content | ||
// | ||
// Example usage: | ||
// | ||
// r := dynreplacer.New( | ||
// dynreplacer.Pair{":name:", func() string { return "World" }}, | ||
// dynreplacer.Pair{":greeting:", func() string { return "Hello" }}, | ||
// ) | ||
// result := r.Replace("Hello :name:!") // Returns "Hello World!" | ||
// | ||
// The replacer caches computed values, so subsequent calls with the same | ||
// placeholder will reuse the cached value instead of executing the callback | ||
// again: | ||
// | ||
// r := dynreplacer.New() | ||
// r.RegisterCallback(":expensive:", func() string { return "computed" }) | ||
// r.Replace("Value1: :expensive:") // Computes the value | ||
// r.Replace("Value2: :expensive:") // Uses cached value | ||
// r.ClearCache() // Force re-computation on next use | ||
package dynreplacer | ||
|
||
import ( | ||
"strings" | ||
) | ||
|
||
// Replacer manages dynamic placeholders, their associated functions, and cached | ||
// values. | ||
type Replacer struct { | ||
callbacks map[string]func() string | ||
cachedValues map[string]string | ||
} | ||
|
||
// Pair represents a placeholder and its callback function | ||
type Pair struct { | ||
Placeholder string | ||
Callback func() string | ||
} | ||
|
||
// New creates a new Replacer instance with optional initial replacements. | ||
// It accepts pairs where each pair consists of a placeholder string and | ||
// its corresponding callback function. | ||
// | ||
// Example: | ||
// | ||
// New( | ||
// Pair{":name:", func() string { return "World" }}, | ||
// Pair{":greeting:", func() string { return "Hello" }}, | ||
// ) | ||
func New(pairs ...Pair) *Replacer { | ||
r := &Replacer{ | ||
callbacks: make(map[string]func() string), | ||
cachedValues: make(map[string]string), | ||
} | ||
|
||
for _, pair := range pairs { | ||
r.RegisterCallback(pair.Placeholder, pair.Callback) | ||
} | ||
|
||
return r | ||
} | ||
|
||
// RegisterCallback associates a placeholder with a function to generate its | ||
// content. | ||
func (r *Replacer) RegisterCallback(placeholder string, callback func() string) { | ||
r.callbacks[placeholder] = callback | ||
} | ||
|
||
// Replace processes the given layout, replacing placeholders with cached or | ||
// newly computed values. | ||
func (r *Replacer) Replace(layout string) string { | ||
replacements := []string{} | ||
|
||
// Check for placeholders and compute/retrieve values | ||
hasReplacements := false | ||
for placeholder, callback := range r.callbacks { | ||
if strings.Contains(layout, placeholder) { | ||
value, exists := r.cachedValues[placeholder] | ||
if !exists { | ||
value = callback() | ||
r.cachedValues[placeholder] = value | ||
} | ||
replacements = append(replacements, placeholder, value) | ||
hasReplacements = true | ||
} | ||
} | ||
|
||
// If no replacements were found, return the original layout | ||
if !hasReplacements { | ||
return layout | ||
} | ||
|
||
// Create a strings.Replacer with all computed replacements | ||
replacer := strings.NewReplacer(replacements...) | ||
return replacer.Replace(layout) | ||
} | ||
|
||
// ClearCache clears all cached values, forcing re-computation on next Replace. | ||
func (r *Replacer) ClearCache() { | ||
r.cachedValues = make(map[string]string) | ||
} |
235 changes: 235 additions & 0 deletions
235
examples/gno.land/p/moul/dynreplacer/dynreplacer_test.gno
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,235 @@ | ||
package dynreplacer | ||
|
||
import ( | ||
"strings" | ||
"testing" | ||
|
||
"gno.land/p/demo/uassert" | ||
) | ||
|
||
func TestNew(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
pairs []Pair | ||
}{ | ||
{ | ||
name: "empty constructor", | ||
pairs: []Pair{}, | ||
}, | ||
{ | ||
name: "single pair", | ||
pairs: []Pair{ | ||
{":name:", func() string { return "World" }}, | ||
}, | ||
}, | ||
{ | ||
name: "multiple pairs", | ||
pairs: []Pair{ | ||
{":greeting:", func() string { return "Hello" }}, | ||
{":name:", func() string { return "World" }}, | ||
}, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
r := New(tt.pairs...) | ||
uassert.True(t, r.callbacks != nil, "callbacks map should be initialized") | ||
uassert.True(t, r.cachedValues != nil, "cachedValues map should be initialized") | ||
|
||
// Verify all callbacks were registered | ||
for _, pair := range tt.pairs { | ||
_, exists := r.callbacks[pair.Placeholder] | ||
uassert.True(t, exists, "callback should be registered for "+pair.Placeholder) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestReplace(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
layout string | ||
setup func(*Replacer) | ||
expected string | ||
}{ | ||
{ | ||
name: "empty layout", | ||
layout: "", | ||
setup: func(r *Replacer) {}, | ||
expected: "", | ||
}, | ||
{ | ||
name: "single replacement", | ||
layout: "Hello :name:!", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":name:", func() string { return "World" }) | ||
}, | ||
expected: "Hello World!", | ||
}, | ||
{ | ||
name: "multiple replacements", | ||
layout: ":greeting: :name:!", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":greeting:", func() string { return "Hello" }) | ||
r.RegisterCallback(":name:", func() string { return "World" }) | ||
}, | ||
expected: "Hello World!", | ||
}, | ||
{ | ||
name: "no recursive replacement", | ||
layout: ":outer:", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":outer:", func() string { return ":inner:" }) | ||
r.RegisterCallback(":inner:", func() string { return "content" }) | ||
}, | ||
expected: ":inner:", | ||
}, | ||
{ | ||
name: "unused callbacks", | ||
layout: "Hello :name:!", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":name:", func() string { return "World" }) | ||
r.RegisterCallback(":unused:", func() string { return "Never Called" }) | ||
}, | ||
expected: "Hello World!", | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
r := New() | ||
tt.setup(r) | ||
result := r.Replace(tt.layout) | ||
uassert.Equal(t, tt.expected, result) | ||
}) | ||
} | ||
} | ||
|
||
func TestCaching(t *testing.T) { | ||
r := New() | ||
callCount := 0 | ||
r.RegisterCallback(":expensive:", func() string { | ||
callCount++ | ||
return "computed" | ||
}) | ||
|
||
layout := "Value: :expensive:" | ||
|
||
// First call should compute | ||
result1 := r.Replace(layout) | ||
uassert.Equal(t, "Value: computed", result1) | ||
uassert.Equal(t, 1, callCount) | ||
|
||
// Second call should use cache | ||
result2 := r.Replace(layout) | ||
uassert.Equal(t, "Value: computed", result2) | ||
uassert.Equal(t, 1, callCount) | ||
|
||
// After clearing cache, should recompute | ||
r.ClearCache() | ||
result3 := r.Replace(layout) | ||
uassert.Equal(t, "Value: computed", result3) | ||
uassert.Equal(t, 2, callCount) | ||
} | ||
|
||
func TestComplexExample(t *testing.T) { | ||
layout := ` | ||
# Welcome to gno.land | ||
## Blog | ||
:latest-blogposts: | ||
## Events | ||
:next-events: | ||
## Awesome Gno | ||
:awesome-gno: | ||
` | ||
|
||
r := New( | ||
Pair{":latest-blogposts:", func() string { return "Latest blog posts content here" }}, | ||
Pair{":next-events:", func() string { return "Upcoming events listed here" }}, | ||
Pair{":awesome-gno:", func() string { return ":latest-blogposts: (This should NOT be replaced again)" }}, | ||
) | ||
|
||
result := r.Replace(layout) | ||
|
||
// Check that original placeholders are replaced | ||
uassert.True(t, !strings.Contains(result, ":latest-blogposts:\n"), "':latest-blogposts:' placeholder should be replaced") | ||
uassert.True(t, !strings.Contains(result, ":next-events:\n"), "':next-events:' placeholder should be replaced") | ||
uassert.True(t, !strings.Contains(result, ":awesome-gno:\n"), "':awesome-gno:' placeholder should be replaced") | ||
|
||
// Check that the replacement content is present | ||
uassert.True(t, strings.Contains(result, "Latest blog posts content here"), "Blog posts content should be present") | ||
uassert.True(t, strings.Contains(result, "Upcoming events listed here"), "Events content should be present") | ||
uassert.True(t, strings.Contains(result, ":latest-blogposts: (This should NOT be replaced again)"), | ||
"Nested placeholder should not be replaced") | ||
} | ||
|
||
func TestEdgeCases(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
layout string | ||
setup func(*Replacer) | ||
expected string | ||
}{ | ||
{ | ||
name: "empty string placeholder", | ||
layout: "Hello :", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback("", func() string { return "World" }) | ||
}, | ||
expected: "WorldHWorldeWorldlWorldlWorldoWorld World:World", | ||
}, | ||
{ | ||
name: "overlapping placeholders", | ||
layout: "Hello :name::greeting:", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":name:", func() string { return "World" }) | ||
r.RegisterCallback(":greeting:", func() string { return "Hi" }) | ||
r.RegisterCallback(":name::greeting:", func() string { return "Should not match" }) | ||
}, | ||
expected: "Hello WorldHi", | ||
}, | ||
{ | ||
name: "replacement order", | ||
layout: ":a::b::c:", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":c:", func() string { return "3" }) | ||
r.RegisterCallback(":b:", func() string { return "2" }) | ||
r.RegisterCallback(":a:", func() string { return "1" }) | ||
}, | ||
expected: "123", | ||
}, | ||
{ | ||
name: "special characters in placeholders", | ||
layout: "Hello :$name#123:!", | ||
setup: func(r *Replacer) { | ||
r.RegisterCallback(":$name#123:", func() string { return "World" }) | ||
}, | ||
expected: "Hello World!", | ||
}, | ||
{ | ||
name: "multiple occurrences of same placeholder", | ||
layout: ":name: and :name: again", | ||
setup: func(r *Replacer) { | ||
callCount := 0 | ||
r.RegisterCallback(":name:", func() string { | ||
callCount++ | ||
return "World" | ||
}) | ||
}, | ||
expected: "World and World again", | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
r := New() | ||
tt.setup(r) | ||
result := r.Replace(tt.layout) | ||
uassert.Equal(t, tt.expected, result) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/p/moul/dynreplacer |