Skip to content

Commit 29c809c

Browse files
authored
feat: remember collapsable state between reloads (#65)
1 parent df3ddf5 commit 29c809c

6 files changed

Lines changed: 494 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ This project is a reimplementation of the original Python-based [grip](https://g
4040
- hashtag linking in page (see table of contents)
4141
- math expressions (code, inline, block)
4242
- gh issues and prs #46 and grafana/grafana#22
43+
- toggle state is preserved in [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)
4344

4445
This is an inline $\sqrt{3x-1}+(1+x)^2$ function.
4546

internal/parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55

66
"github.com/chrishrb/go-grip/pkg/alert"
7+
"github.com/chrishrb/go-grip/pkg/details"
78
"github.com/chrishrb/go-grip/pkg/footnote"
89
"github.com/chrishrb/go-grip/pkg/ghissue"
910
"github.com/chrishrb/go-grip/pkg/highlighting"
@@ -43,6 +44,7 @@ func (m Parser) MdToHTML(input []byte) ([]byte, error) {
4344
&mermaid.Extender{RenderMode: mermaid.RenderModeClient},
4445
mathjax.MathJax,
4546
ghissue.New(),
47+
details.New(),
4648
),
4749
goldmark.WithParserOptions(
4850
parser.WithAutoHeadingID(),

pkg/details/details.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Package details provides a Goldmark extension for stateful collapsible details elements.
2+
// It adds unique IDs to <details> elements and includes JavaScript to save/restore their
3+
// state using browser session storage.
4+
package details
5+
6+
import (
7+
"github.com/yuin/goldmark"
8+
"github.com/yuin/goldmark/parser"
9+
"github.com/yuin/goldmark/renderer"
10+
"github.com/yuin/goldmark/renderer/html"
11+
"github.com/yuin/goldmark/util"
12+
)
13+
14+
// Extender implements goldmark.Extender to add stateful details support
15+
type Extender struct {
16+
// IDPrefix is the prefix used for generated IDs. Defaults to "details-"
17+
IDPrefix string
18+
}
19+
20+
// Extend extends the Goldmark parser and renderer with stateful details functionality
21+
func (e *Extender) Extend(m goldmark.Markdown) {
22+
prefix := e.IDPrefix
23+
if prefix == "" {
24+
prefix = "details-"
25+
}
26+
27+
m.Parser().AddOptions(
28+
parser.WithASTTransformers(
29+
util.Prioritized(NewTransformer(prefix), 100),
30+
),
31+
)
32+
33+
// Enable unsafe HTML to allow <details> tags to be rendered
34+
m.Renderer().AddOptions(
35+
html.WithUnsafe(),
36+
renderer.WithNodeRenderers(
37+
util.Prioritized(NewHTMLRenderer(), 100),
38+
),
39+
)
40+
}
41+
42+
// New creates a new details Extender with default settings
43+
func New() *Extender {
44+
return &Extender{
45+
IDPrefix: "details-",
46+
}
47+
}
48+
49+
// NewWithPrefix creates a new details Extender with a custom ID prefix
50+
func NewWithPrefix(prefix string) *Extender {
51+
return &Extender{
52+
IDPrefix: prefix,
53+
}
54+
}

pkg/details/details_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package details
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/yuin/goldmark"
9+
"github.com/yuin/goldmark/parser"
10+
"github.com/yuin/goldmark/renderer/html"
11+
)
12+
13+
func TestDetailsExtension(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
markdown string
17+
contains []string
18+
}{
19+
{
20+
name: "basic details with summary",
21+
markdown: `<details>
22+
23+
<summary><h3>YAML</h3></summary>
24+
25+
**bold text**
26+
27+
</details>`,
28+
contains: []string{
29+
`<details id="details-`,
30+
`<summary><h3>YAML</h3></summary>`,
31+
`<strong>bold text</strong>`,
32+
`</details>`,
33+
`sessionStorage`,
34+
},
35+
},
36+
{
37+
name: "multiple details elements",
38+
markdown: `<details>
39+
<summary>First</summary>
40+
Content 1
41+
</details>
42+
43+
<details>
44+
<summary>Second</summary>
45+
Content 2
46+
</details>`,
47+
contains: []string{
48+
`<details id="details-1-`,
49+
`<details id="details-2-`,
50+
`<summary>First</summary>`,
51+
`<summary>Second</summary>`,
52+
},
53+
},
54+
{
55+
name: "details with markdown content",
56+
markdown: `<details>
57+
<summary>Click to expand</summary>
58+
59+
This is **bold** and this is *italic*.
60+
61+
- List item 1
62+
- List item 2
63+
64+
</details>`,
65+
contains: []string{
66+
`<details id="details-`,
67+
`<summary>Click to expand</summary>`,
68+
`<strong>bold</strong>`,
69+
`<em>italic</em>`,
70+
`<li>List item 1</li>`,
71+
`<li>List item 2</li>`,
72+
},
73+
},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
md := goldmark.New(
79+
goldmark.WithExtensions(
80+
New(),
81+
),
82+
goldmark.WithParserOptions(
83+
parser.WithAutoHeadingID(),
84+
),
85+
goldmark.WithRendererOptions(
86+
html.WithHardWraps(),
87+
html.WithXHTML(),
88+
),
89+
)
90+
91+
var buf bytes.Buffer
92+
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
93+
t.Fatal(err)
94+
}
95+
96+
output := buf.String()
97+
98+
for _, expected := range tt.contains {
99+
if !strings.Contains(output, expected) {
100+
t.Errorf("Expected output to contain %q, but it didn't.\nOutput:\n%s", expected, output)
101+
}
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestDetailsIDGeneration(t *testing.T) {
108+
markdown := `<details>
109+
<summary>Test</summary>
110+
Content
111+
</details>`
112+
113+
md := goldmark.New(
114+
goldmark.WithExtensions(
115+
New(),
116+
),
117+
)
118+
119+
var buf1, buf2 bytes.Buffer
120+
121+
// Render twice
122+
if err := md.Convert([]byte(markdown), &buf1); err != nil {
123+
t.Fatal(err)
124+
}
125+
126+
// Note: We need a new markdown instance because the transformer maintains state
127+
md2 := goldmark.New(
128+
goldmark.WithExtensions(
129+
New(),
130+
),
131+
)
132+
133+
if err := md2.Convert([]byte(markdown), &buf2); err != nil {
134+
t.Fatal(err)
135+
}
136+
137+
output1 := buf1.String()
138+
output2 := buf2.String()
139+
140+
// Both should contain an ID
141+
if !strings.Contains(output1, `id="details-`) {
142+
t.Error("First render should contain an ID")
143+
}
144+
145+
if !strings.Contains(output2, `id="details-`) {
146+
t.Error("Second render should contain an ID")
147+
}
148+
149+
// Script should only be rendered once per document
150+
scriptCount := strings.Count(output1, "<script>")
151+
if scriptCount != 1 {
152+
t.Errorf("Expected exactly 1 script tag, got %d", scriptCount)
153+
}
154+
}
155+
156+
func TestCustomIDPrefix(t *testing.T) {
157+
markdown := `<details>
158+
<summary>Test</summary>
159+
Content
160+
</details>`
161+
162+
md := goldmark.New(
163+
goldmark.WithExtensions(
164+
NewWithPrefix("custom-"),
165+
),
166+
)
167+
168+
var buf bytes.Buffer
169+
if err := md.Convert([]byte(markdown), &buf); err != nil {
170+
t.Fatal(err)
171+
}
172+
173+
output := buf.String()
174+
175+
if !strings.Contains(output, `id="custom-`) {
176+
t.Errorf("Expected custom prefix, got:\n%s", output)
177+
}
178+
}

0 commit comments

Comments
 (0)