Skip to content

Commit f4192a6

Browse files
authored
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]>
1 parent 7ca5ed2 commit f4192a6

File tree

3 files changed

+347
-0
lines changed

3 files changed

+347
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Package dynreplacer provides a simple template engine for handling dynamic
2+
// content replacement. It is similar to strings.Replacer but with lazy
3+
// execution of replacements, making it more optimization-friendly in several
4+
// cases. While strings.Replacer requires all replacement values to be computed
5+
// upfront, dynreplacer only executes the callback functions for placeholders
6+
// that actually exist in the template, avoiding unnecessary computations.
7+
//
8+
// The package ensures efficient, non-recursive replacement of placeholders in a
9+
// single pass. This lazy evaluation approach is particularly beneficial when:
10+
// - Some replacement values are expensive to compute
11+
// - Not all placeholders are guaranteed to be present in the template
12+
// - Templates are reused with different content
13+
//
14+
// Example usage:
15+
//
16+
// r := dynreplacer.New(
17+
// dynreplacer.Pair{":name:", func() string { return "World" }},
18+
// dynreplacer.Pair{":greeting:", func() string { return "Hello" }},
19+
// )
20+
// result := r.Replace("Hello :name:!") // Returns "Hello World!"
21+
//
22+
// The replacer caches computed values, so subsequent calls with the same
23+
// placeholder will reuse the cached value instead of executing the callback
24+
// again:
25+
//
26+
// r := dynreplacer.New()
27+
// r.RegisterCallback(":expensive:", func() string { return "computed" })
28+
// r.Replace("Value1: :expensive:") // Computes the value
29+
// r.Replace("Value2: :expensive:") // Uses cached value
30+
// r.ClearCache() // Force re-computation on next use
31+
package dynreplacer
32+
33+
import (
34+
"strings"
35+
)
36+
37+
// Replacer manages dynamic placeholders, their associated functions, and cached
38+
// values.
39+
type Replacer struct {
40+
callbacks map[string]func() string
41+
cachedValues map[string]string
42+
}
43+
44+
// Pair represents a placeholder and its callback function
45+
type Pair struct {
46+
Placeholder string
47+
Callback func() string
48+
}
49+
50+
// New creates a new Replacer instance with optional initial replacements.
51+
// It accepts pairs where each pair consists of a placeholder string and
52+
// its corresponding callback function.
53+
//
54+
// Example:
55+
//
56+
// New(
57+
// Pair{":name:", func() string { return "World" }},
58+
// Pair{":greeting:", func() string { return "Hello" }},
59+
// )
60+
func New(pairs ...Pair) *Replacer {
61+
r := &Replacer{
62+
callbacks: make(map[string]func() string),
63+
cachedValues: make(map[string]string),
64+
}
65+
66+
for _, pair := range pairs {
67+
r.RegisterCallback(pair.Placeholder, pair.Callback)
68+
}
69+
70+
return r
71+
}
72+
73+
// RegisterCallback associates a placeholder with a function to generate its
74+
// content.
75+
func (r *Replacer) RegisterCallback(placeholder string, callback func() string) {
76+
r.callbacks[placeholder] = callback
77+
}
78+
79+
// Replace processes the given layout, replacing placeholders with cached or
80+
// newly computed values.
81+
func (r *Replacer) Replace(layout string) string {
82+
replacements := []string{}
83+
84+
// Check for placeholders and compute/retrieve values
85+
hasReplacements := false
86+
for placeholder, callback := range r.callbacks {
87+
if strings.Contains(layout, placeholder) {
88+
value, exists := r.cachedValues[placeholder]
89+
if !exists {
90+
value = callback()
91+
r.cachedValues[placeholder] = value
92+
}
93+
replacements = append(replacements, placeholder, value)
94+
hasReplacements = true
95+
}
96+
}
97+
98+
// If no replacements were found, return the original layout
99+
if !hasReplacements {
100+
return layout
101+
}
102+
103+
// Create a strings.Replacer with all computed replacements
104+
replacer := strings.NewReplacer(replacements...)
105+
return replacer.Replace(layout)
106+
}
107+
108+
// ClearCache clears all cached values, forcing re-computation on next Replace.
109+
func (r *Replacer) ClearCache() {
110+
r.cachedValues = make(map[string]string)
111+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package dynreplacer
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"gno.land/p/demo/uassert"
8+
)
9+
10+
func TestNew(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
pairs []Pair
14+
}{
15+
{
16+
name: "empty constructor",
17+
pairs: []Pair{},
18+
},
19+
{
20+
name: "single pair",
21+
pairs: []Pair{
22+
{":name:", func() string { return "World" }},
23+
},
24+
},
25+
{
26+
name: "multiple pairs",
27+
pairs: []Pair{
28+
{":greeting:", func() string { return "Hello" }},
29+
{":name:", func() string { return "World" }},
30+
},
31+
},
32+
}
33+
34+
for _, tt := range tests {
35+
t.Run(tt.name, func(t *testing.T) {
36+
r := New(tt.pairs...)
37+
uassert.True(t, r.callbacks != nil, "callbacks map should be initialized")
38+
uassert.True(t, r.cachedValues != nil, "cachedValues map should be initialized")
39+
40+
// Verify all callbacks were registered
41+
for _, pair := range tt.pairs {
42+
_, exists := r.callbacks[pair.Placeholder]
43+
uassert.True(t, exists, "callback should be registered for "+pair.Placeholder)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestReplace(t *testing.T) {
50+
tests := []struct {
51+
name string
52+
layout string
53+
setup func(*Replacer)
54+
expected string
55+
}{
56+
{
57+
name: "empty layout",
58+
layout: "",
59+
setup: func(r *Replacer) {},
60+
expected: "",
61+
},
62+
{
63+
name: "single replacement",
64+
layout: "Hello :name:!",
65+
setup: func(r *Replacer) {
66+
r.RegisterCallback(":name:", func() string { return "World" })
67+
},
68+
expected: "Hello World!",
69+
},
70+
{
71+
name: "multiple replacements",
72+
layout: ":greeting: :name:!",
73+
setup: func(r *Replacer) {
74+
r.RegisterCallback(":greeting:", func() string { return "Hello" })
75+
r.RegisterCallback(":name:", func() string { return "World" })
76+
},
77+
expected: "Hello World!",
78+
},
79+
{
80+
name: "no recursive replacement",
81+
layout: ":outer:",
82+
setup: func(r *Replacer) {
83+
r.RegisterCallback(":outer:", func() string { return ":inner:" })
84+
r.RegisterCallback(":inner:", func() string { return "content" })
85+
},
86+
expected: ":inner:",
87+
},
88+
{
89+
name: "unused callbacks",
90+
layout: "Hello :name:!",
91+
setup: func(r *Replacer) {
92+
r.RegisterCallback(":name:", func() string { return "World" })
93+
r.RegisterCallback(":unused:", func() string { return "Never Called" })
94+
},
95+
expected: "Hello World!",
96+
},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
r := New()
102+
tt.setup(r)
103+
result := r.Replace(tt.layout)
104+
uassert.Equal(t, tt.expected, result)
105+
})
106+
}
107+
}
108+
109+
func TestCaching(t *testing.T) {
110+
r := New()
111+
callCount := 0
112+
r.RegisterCallback(":expensive:", func() string {
113+
callCount++
114+
return "computed"
115+
})
116+
117+
layout := "Value: :expensive:"
118+
119+
// First call should compute
120+
result1 := r.Replace(layout)
121+
uassert.Equal(t, "Value: computed", result1)
122+
uassert.Equal(t, 1, callCount)
123+
124+
// Second call should use cache
125+
result2 := r.Replace(layout)
126+
uassert.Equal(t, "Value: computed", result2)
127+
uassert.Equal(t, 1, callCount)
128+
129+
// After clearing cache, should recompute
130+
r.ClearCache()
131+
result3 := r.Replace(layout)
132+
uassert.Equal(t, "Value: computed", result3)
133+
uassert.Equal(t, 2, callCount)
134+
}
135+
136+
func TestComplexExample(t *testing.T) {
137+
layout := `
138+
# Welcome to gno.land
139+
140+
## Blog
141+
:latest-blogposts:
142+
143+
## Events
144+
:next-events:
145+
146+
## Awesome Gno
147+
:awesome-gno:
148+
`
149+
150+
r := New(
151+
Pair{":latest-blogposts:", func() string { return "Latest blog posts content here" }},
152+
Pair{":next-events:", func() string { return "Upcoming events listed here" }},
153+
Pair{":awesome-gno:", func() string { return ":latest-blogposts: (This should NOT be replaced again)" }},
154+
)
155+
156+
result := r.Replace(layout)
157+
158+
// Check that original placeholders are replaced
159+
uassert.True(t, !strings.Contains(result, ":latest-blogposts:\n"), "':latest-blogposts:' placeholder should be replaced")
160+
uassert.True(t, !strings.Contains(result, ":next-events:\n"), "':next-events:' placeholder should be replaced")
161+
uassert.True(t, !strings.Contains(result, ":awesome-gno:\n"), "':awesome-gno:' placeholder should be replaced")
162+
163+
// Check that the replacement content is present
164+
uassert.True(t, strings.Contains(result, "Latest blog posts content here"), "Blog posts content should be present")
165+
uassert.True(t, strings.Contains(result, "Upcoming events listed here"), "Events content should be present")
166+
uassert.True(t, strings.Contains(result, ":latest-blogposts: (This should NOT be replaced again)"),
167+
"Nested placeholder should not be replaced")
168+
}
169+
170+
func TestEdgeCases(t *testing.T) {
171+
tests := []struct {
172+
name string
173+
layout string
174+
setup func(*Replacer)
175+
expected string
176+
}{
177+
{
178+
name: "empty string placeholder",
179+
layout: "Hello :",
180+
setup: func(r *Replacer) {
181+
r.RegisterCallback("", func() string { return "World" })
182+
},
183+
expected: "WorldHWorldeWorldlWorldlWorldoWorld World:World",
184+
},
185+
{
186+
name: "overlapping placeholders",
187+
layout: "Hello :name::greeting:",
188+
setup: func(r *Replacer) {
189+
r.RegisterCallback(":name:", func() string { return "World" })
190+
r.RegisterCallback(":greeting:", func() string { return "Hi" })
191+
r.RegisterCallback(":name::greeting:", func() string { return "Should not match" })
192+
},
193+
expected: "Hello WorldHi",
194+
},
195+
{
196+
name: "replacement order",
197+
layout: ":a::b::c:",
198+
setup: func(r *Replacer) {
199+
r.RegisterCallback(":c:", func() string { return "3" })
200+
r.RegisterCallback(":b:", func() string { return "2" })
201+
r.RegisterCallback(":a:", func() string { return "1" })
202+
},
203+
expected: "123",
204+
},
205+
{
206+
name: "special characters in placeholders",
207+
layout: "Hello :$name#123:!",
208+
setup: func(r *Replacer) {
209+
r.RegisterCallback(":$name#123:", func() string { return "World" })
210+
},
211+
expected: "Hello World!",
212+
},
213+
{
214+
name: "multiple occurrences of same placeholder",
215+
layout: ":name: and :name: again",
216+
setup: func(r *Replacer) {
217+
callCount := 0
218+
r.RegisterCallback(":name:", func() string {
219+
callCount++
220+
return "World"
221+
})
222+
},
223+
expected: "World and World again",
224+
},
225+
}
226+
227+
for _, tt := range tests {
228+
t.Run(tt.name, func(t *testing.T) {
229+
r := New()
230+
tt.setup(r)
231+
result := r.Replace(tt.layout)
232+
uassert.Equal(t, tt.expected, result)
233+
})
234+
}
235+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module gno.land/p/moul/dynreplacer

0 commit comments

Comments
 (0)