Repository: gohugoio/hugo (language: go)

Issue:
Add Details Shortcode for collapsable sections within markdown content
Hello, 

I would like to propose adding a new shortcode: 
- tpl/tplimpl/embedded/templates/shortcodes/details.html

The Details shortcode would allow users to use the [Details html element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) within their markdown files. I realize this could already be done by using a rawhtml shortcode but I believe this is a common enough element to warrant inclusion in the project. So common that [github implemented it in their markdown](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections) which lets me show you what I'm talking about directly within this proposal: 

<details>

<summary>Details</summary>

### You can add a header

You can add text within a collapsed section. 

You can add an image or a code block, too.

```ruby
   puts "Hello World"
```

</details>

To implement the details element, I have developed the following code:
```js
{{ $summary := .Get "summary" | default (.Get 0) | default "Details:" | markdownify }}
{{ $openParam := .Get "open" | default (.Get 1) | default false }}
{{ $isOpen := not (or (eq $openParam false) (and (eq (printf "%T" $openParam) "string") (eq (lower $openParam) "false"))) }}
{{ $altSummary := .Get "altSummary" | default (.Get 2) | default $summary | markdownify }}
{{ $name := .Get "name" | default (.Get 3) | default "" }}
<details {{ if $isOpen }}open{{ end }}{{ with $name }} name="{{ . }}"{{ end }}>
    <summary onclick="this.innerHTML = this.parentNode.open ? '{{ $summary }}' : '{{ $altSummary }}';">
        {{ if $isOpen }}
            {{ $altSummary }}
        {{ else }}
            {{ $summary }}
        {{ end }}
    </summary>
    {{ .Inner | markdownify }}
</details>
```

The code has 4 arguments that can be passed:  
1. `summary` (str). Default = "Details:". This is the text that appears for users to click on.
2. `open` (bool). Default = `false`. If this argument is specified, and a value other than `false` (bool) or "false" (str) is provided, then the element will be expanded upon page load.  
3. `altSummary` (str). Default = `summary`. This is the text that appears when the details tag is open. If not provided it will be the same as the summary text.
4. `name` (str). Default = "" (empty string). This attribute allows you to connect multiple details elements so that only one can be open at a time. If a user opens a named element then all other same-named elements will be closed. 

Notes:
- The arguments are named and positional. 
- The details shortcode generates a standard HTML details element that works for basic  expand/collapse  functionality without additional CSS or JS. The shortcode does include a javascript `onclick` handler for the summary/altSummary text switching feature but the code is self-contained to the shortcode's .html file. 
- The `open` behavior matches [html's implementation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details#open), with the exception it also accepts "false" as a str to keep the element closed on page load. 
- the `name` argument is an html argument and the shortcode does not deviate from the expected behavior. 
- The `altSummary` is an added feature over existing html behavior. I added this since I wanted to express when the details elements were expanded/collapsed, such as toggling between "10 cells collapsed" and "10 cells expanded". 

Discourse [29546](https://discourse.gohugo.io/t/detail-html-tag/29546) has had 2.2k views in a few years demonstrating it is a popular query, and my proposal has additional features to align with html's implementation. 

I cannot fully demonstrate my shortcode within github discussion. If you would like to see examples of its usage, You can see the usage of the details shortcode within hugo in my [blog post here, ](https://racedorsey.com/posts/2024/hugo-details-shortcode/). 

This is would be my first contribution to the project. I have signed the CLA and am prepared to update documentation if my proposal is approved. Thanks for your time! 


Discussion / hints:
Thanks for this. I would agree that this would be very useful. The challenge is to implement a shortcode that would make "most Hugo users" happy. For one, we would want to be JS framework agnostic.

><summary onclick="this.innerHTML = this.parentNode.open ? '{{ $summary }}' : '{{ $altSummary }}';">

I would probably not mess with `innerHTML`; a better approach would probably to toggle the `display` style. But even then I'm not sure we would want to maintain it as part of Hugo's internal templates.

On a side note, I recently tested this CSS only `details` shortcode for TailwindCSS v4:

https://github.com/bep/hugojsbatchdemo/blob/main/layouts/shortcodes/details.html

But that one would certainly be too special cased for inclusion in Hugo.

So, I'm not sure.

/cc @jmooring 
Thank you. 

>  For one, we would want to be JS framework agnostic 
> [...]
> I would probably not mess with innerHTML; a better approach would probably to toggle the display style. But even then I'm not sure we would want to maintain it as part of Hugo's internal templates.

The JS and `altSummary` could easily be removed which would make it more closely match the html element's usage. This would also alleviate the innerHTML concern so the code is more framework agnostic. To your point I went with the JS route to keep this self-contained rather than rely on CSS to toggle the display state which would then need to be maintained separately.

I think the more neutral approach would be:
- remove the `altSummary` to make it more framework agnostic code
- (Optional) Add a `class` argument. It would not be automatically utilized but could allow users to modify detail classes via CSS, which would give them freedom to style the details elements as they see fit. 

This approach would look like this: 
```html
{{ $summary := .Get "summary" | default (.Get 0) | default "Details:" | markdownify }}
{{ $openParam := .Get "open" | default (.Get 1) | default false }}
{{ $isOpen := not (or (eq $openParam false) (and (eq (printf "%T" $openParam) "string") (eq (lower $openParam) "false"))) }}
{{ $class := .Get "class" | default (.Get 2) | default "" }}
{{ $name := .Get "name" | default (.Get 3) | default "" }}

<details {{ if $isOpen }}open{{ end }}{{ with $name }} name="{{ . }}"{{ end }}{{ with $class }} class="{{ . }}"{{ end }}>
    <summary>{{ $summary }}</summary>
    <div{{ with $class }} class="{{ . }}"{{ end }}>
    {{ .Inner | markdownify }}
    </div>
</details>
```

Note I added `<div>` to allow the inner content to be styled via CSS. Users could use the following CSS if they wanted to modify elements: 
```CSS
/* Target details element */
details.custom-class { }

/* Target summary element */
details.custom-class > summary > * { }

/* Target inner content */
details.custom-class > *:not(summary) { }
```

I should note that with this approach, using a `summary` with a block element (heading/list) will follow normal block element behavior and appear on a new line. 
![image](https://github.com/user-attachments/assets/e8f8004f-0c6e-4a4c-a6b1-4f5f6e735d83)
If a user wanted to have a details heading they would need to use CSS to force the inline behavior. 

```css
details > summary > * {
    display: inline;
}
```

Visual comparison:
![image](https://github.com/user-attachments/assets/093f3c25-7836-4fad-8c05-12abcfb8ef08)
GitHub didn't implement anything. They simply allow HTML `details` and `summary`  elements in Markdown. Although not a great idea for every site, you can do the same thing with Hugo by modifying your [site configuration](https://gohugo.io/getting-started/configuration-markup/#rendererunsafe). With this approach the Markdown is portable (i.e., behaves the same with Hugo, GitHub, GitLab, VS Code, Obsidian, etc.).

Although the [referenced forum topic](https://discourse.gohugo.io/t/detail-html-tag/29546) has thousands of views, this is the first time that I have seen a request (either here or in the forum) to create an embedded `details` shortcode. Typically an opinionated implementation is created by theme authors as needed.

Having said that, provided that it is not opinionated and that it will actually be used by someone, I don't have a problem with adding a `details` shortcode to our embedded templates. I'd probably do something like this:


<details>
<summary>layouts/shortcodes/details.html</summary>

```text
{{- /*
Renders an HTML details element.

@param {string} [class] The value of the element's class attribute.
@param {string} [name] The value of the element's name attribute.
@param {bool} [open=false] Whether to initially display the contents of the details element.
@param {string} [summary] The content of the child summary element.

@examples

    {{< details >}}
    This is an _emphasized_ word.
    {{< /details >}}

    {{< details summary="This is a **bold** word" >}}
    This is an _emphasized_ word.
    {{< /details >}}

    {{< details summary="This is a **bold** word" open=true >}}
    This is an _emphasized_ word.
    {{< /details >}}

    {{< details summary="This is a **bold** word" class="my-class" >}}
    This is an _emphasized_ word.
    {{< /details >}}

    {{< details summary="This is a **bold** word" name="my-name" >}}
    This is an _emphasized_ word.
    {{< /details >}}

*/}}

{{- /* Get arguments. */}}
{{- $class := or (.Get "class") "" }}
{{- $name := or (.Get "name") "" }}
{{- $summary := or (.Get "summary") (T "details") "Details" }}
{{- $open := false }}
{{- if in (slice "false" false 0) (.Get "open") }}
  {{- $open = false }}
{{- else if in (slice "true" true 1) (.Get "open")}}
  {{- $open = true }}
{{- end }}

{{- /* Render. */}}
<details
  {{- with $class }} class="{{ . }}" {{- end }}
  {{- with $name }} name="{{ . }}" {{- end }}
  {{- if $open }} open {{- end -}}
>
  <summary>{{ $summary | .Page.RenderString }}</summary>
  {{ .Inner | .Page.RenderString (dict "display" "block") }}
</details>

```
</details>

Notes:

- If site/theme authors need to style the content (but not the summary) they can do:

    ```css
    details :not(:first-child) {
      color: red;
    }
    ```
- Use the `RenderString` method instead of the `markdownify` function to avoid [these problems](https://github.com/gohugoio/hugo/issues/9692) and to prevent removal of wrapping `p` tags
- Allow localization of the default summary value
- Don't worry about positional arguments; when there's more than one I can never remember the order anyway





Thank you for the feedback and detailed notes! 

Focusing on the "not opinionated" aspect of this, both of our handling of `open` deviated from the details element. I rethought the approach and arrived at the following:
- Only explicitly specify `summary` and `open` parameters since  they require special handling. Any other arguments are passed directly to the details element. This allows the use of `name`, `class`, and other global attributes to be used. 
- `open` now only accepts false (bool) which is how HTML handles it. Meaning open="false" is treated as true. If we wanted to be slightly opinionated we could accept false/"false"/0 as false, otherwise true if `open` is specified. For now I have removed that and stuck strictly with how HTML handles it. 

I also added your suggestions:
- The use of `RenderString` is nice, Since it doesn't remove the `<p>` tags from inner content (like markdownify) then CSS like `details.my-custom-class > :not(summary) { }` can select the inner content. 
- allow default value of `summary` to be localized
- scrapped positional arguments. 

My revised code looks like this: 

<details><summary>details.html</summary>

```

{{- /*
Renders an HTML details element.

@param {string} [summary] The content of the child summary element.
@param {bool} [open=false] Whether to initially display the contents of the details element.
@param {object} [...params] Additional HTML attributes passed directly to the details element.

@reference https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details

@examples
    {{< details >}}
    A basic collapsible section.
    {{< /details >}}
    
    {{< details summary="Custom Summary Text" >}}
    Showing custom `summary` text. 
    {{< /details >}}
    
    {{< details summary="Open Details" open=true >}}
    Contents displayed initially by using `open`. 
    {{< /details >}}
    
    {{< details summary="Styled Content" class="my-custom-class">}}
    Content can be styled with CSS by specifying a `class`. 
    
    Target details element
    ```css
    details.my-custom-class { }
    ```
    
    Target summary element
    ```css
    details.my-custom-class > summary > * { }
    ```
    
    Target inner content
    ```css
    details.my-custom-class > :not(summary) { }
    ```
    {{< /details >}}
    
    {{< details summary="Grouped Details" name="my-details">}}
    Specifying a `name` to group detail elements so that only one within the group can be open at a time. 
    {{< /details >}}
    
*/}}
    
{{- /* Get arguments. */}}
{{- $summary := or (.Get "summary") (T "details") "Details" }}
{{- $open := false }}
{{- with .Get "open" }}
    {{- if not (eq . false) }}
        {{- $open = true }}
    {{- end }}
{{- end }}
{{- $attributes := dict }}
{{- range $key, $value := .Params }}
    {{- if not (in (slice "summary" "open") $key) }}
        {{- $attributes = merge $attributes (dict (string $key) $value) }}
    {{- end }}
{{- end }}

{{- /* Render. */}}
<details
    {{- if $open }} open {{- end -}}
    {{- range $key, $value := $attributes }} {{ $key }}="{{ $value }}"{{- end }}
>
   <summary>{{ $summary | .Page.RenderString }}</summary>
   {{ .Inner | .Page.RenderString (dict "display" "block") }}
</details>
```

</details>

Would this be acceptable? I believe this approach is not opinionated and versatile enough that it should satisfy many users looking to utilize the details element. Thanks for the time. 
These changes are mostly nits...

1. Minor changes to comment formatting
2. Revert to previous logic for setting `$open` to allow `open="false"` (a string) in the shortcode call
3. Iterate over .Params once instead of twice
4. Allow setting the `style` attribute (requires passing the attribute+value through the `safeHTMLAttr` function)
5. Disallow event handler attributes (e.g., `onclick`, etc.)
6. Adjust white space removal in action delimiters
7. With the exception of the comments, indent by 2 spaces to match (most of) the other embedded templates

<details>
<summary>layouts/shortcodes/details.html</summary>

```text
{{- /*
Renders an HTML details element.

@param {string} [summary] The content of the child summary element.
@param {bool} [open=false] Whether to initially display the contents of the details element.
@param {object} [...params] Additional HTML attributes passed directly to the details element.

@reference https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details

@examples

    {{< details >}}
    A basic collapsible section.
    {{< /details >}}

    {{< details summary="Custom Summary Text" >}}
    Showing custom `summary` text.
    {{< /details >}}

    {{< details summary="Open Details" open=true >}}
    Contents displayed initially by using `open`.
    {{< /details >}}

    {{< details summary="Styled Content" class="my-custom-class" >}}
    Content can be styled with CSS by specifying a `class`.

    Target details element:

    ```css
    details.my-custom-class { }
    ```

    Target summary element:

    ```css
    details.my-custom-class > summary > * { }
    ```

    Target inner content:

    ```css
    details.my-custom-class > :not(summary) { }
    ```
    {{< /details >}}

    {{< details summary="Grouped Details" name="my-details" >}}
    Specifying a `name` to group detail elements so that only one within the group can be open at a time.
    {{< /details >}}

*/}}

{{- /* Get arguments. */}}
{{- $summary := or (.Get "summary") (T "details") "Details" }}
{{- $open := false }}
{{- if in (slice "false" false 0) (.Get "open") }}
  {{- $open = false }}
{{- else if in (slice "true" true 1) (.Get "open")}}
  {{- $open = true }}
{{- end }}

{{- /* Render. */}}
<details
  {{- if $open }} open{{ end }}
    {{- range $k, $v := .Params }}
      {{- if not (or (in (slice "open" "summary") $k) (strings.HasPrefix $k "on")) }}
        {{- printf " %s=%q" $k $v | safeHTMLAttr }}
      {{- end }}
    {{- end -}}
>
  <summary>{{ $summary | .Page.RenderString }}</summary>
  {{ .Inner | .Page.RenderString (dict "display" "block") -}}
</details>
```
</details>
<br>

Unless you have any other recommendations, please submit a PR:

- Code goes in /tpl/tplimpl/embedded/templates/shortcodes
- Add an integration test to /tpl/tplimpl/tplimpl_integration_test.go
  -  Use TestCommentShortcode as an example
  - Add several shortcode calls to content/_index.md to test for open/close, `onclick` attribute removal, `style` attribute, i18n of "details" key, with/without summary

Fix patch:
diff --git a/docs/content/en/content-management/shortcodes.md b/docs/content/en/content-management/shortcodes.md
index 8e345f2fb36..7a589a34008 100644
--- a/docs/content/en/content-management/shortcodes.md
+++ b/docs/content/en/content-management/shortcodes.md
@@ -94,6 +94,40 @@ Example usage:
 
 Although you can call this shortcode using the `{{</* */>}}` notation, computationally it is more efficient to call it using the `{{%/* */%}}` notation as shown above.
 
+### details
+
+{{< new-in 0.140.0 >}}
+
+{{% note %}}
+To override Hugo's embedded `details` shortcode, copy the [source code] to a file with the same name in the layouts/shortcodes directory.
+
+This may be useful if you are wanting access to more global HTML attributes. 
+
+[source code]: {{% eturl details %}}
+{{% /note %}}
+
+Use the `details` shortcode to generate a collapsible details HTML element. For example:
+
+```text
+{{</* details summary="Custom Summary Text" */>}}
+Showing custom `summary` text.
+{{</* /details */>}}
+```
+
+Additional examples can be found in the source code. The `details` shortcode can use the following named arguments:
+
+summary
+: (`string`) Optional. Specifies the content of the child summary element. Default is "Details"
+
+open
+: (`bool`) Optional. Whether to initially display the contents of the details element. Default is `false`.
+
+name
+: (`string`) Optional. The value of the element's name attribute.
+
+class
+: (`string`) Optional. The value of the element's class attribute.
+
 ### figure
 
 {{% note %}}
diff --git a/docs/data/embedded_template_urls.toml b/docs/data/embedded_template_urls.toml
index 38b437fe15d..b7247f2727f 100644
--- a/docs/data/embedded_template_urls.toml
+++ b/docs/data/embedded_template_urls.toml
@@ -25,6 +25,7 @@
 
 # Shortcodes
 'comment' = 'shortcodes/comment.html'
+'details' = 'shortcodes/details.html'
 'figure' = 'shortcodes/figure.html'
 'gist' = 'shortcodes/gist.html'
 'highlight' = 'shortcodes/highlight.html'
diff --git a/tpl/tplimpl/embedded/templates/shortcodes/details.html b/tpl/tplimpl/embedded/templates/shortcodes/details.html
new file mode 100644
index 00000000000..932289517a6
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/shortcodes/details.html
@@ -0,0 +1,68 @@
+{{- /*
+Renders an HTML details element.
+
+@param {string} [summary] The content of the child summary element.
+@param {bool} [open=false] Whether to initially display the contents of the details element.
+@param {string} [class] The value of the element's class attribute.
+@param {string} [name] The value of the element's name attribute.
+
+@reference https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
+
+@examples
+
+    {{< details >}}
+    A basic collapsible section.
+    {{< /details >}}
+
+    {{< details summary="Custom Summary Text" >}}
+    Showing custom `summary` text.
+    {{< /details >}}
+
+    {{< details summary="Open Details" open=true >}}
+    Contents displayed initially by using `open`.
+    {{< /details >}}
+
+    {{< details summary="Styled Content" class="my-custom-class" >}}
+    Content can be styled with CSS by specifying a `class`.
+
+    Target details element:
+
+    ```css
+    details.my-custom-class { }
+    ```
+
+    Target summary element:
+
+    ```css
+    details.my-custom-class > summary > * { }
+    ```
+
+    Target inner content:
+
+    ```css
+    details.my-custom-class > :not(summary) { }
+    ```
+    {{< /details >}}
+
+    {{< details summary="Grouped Details" name="my-details" >}}
+    Specifying a `name` allows elements to be connected, with only one able to be open at a time.
+    {{< /details >}}
+
+*/}}
+
+{{- /* Get arguments. */}}
+{{- $summary := or (.Get "summary") (T "shortcodes.details") "Details" }}
+{{- $class := or (.Get "class") "" }}
+{{- $name := or (.Get "name") "" }}
+{{- $open := false }}
+{{- if in (slice "false" false 0) (.Get "open") }}
+    {{- $open = false }}
+{{- else if in (slice "true" true 1) (.Get "open")}}
+    {{- $open = true }}
+{{- end }}
+
+{{- /* Render. */}}
+<details{{- if $open }} open{{ end }}{{- if $name }} name="{{ $name }}"{{- end }}{{- if $class }} class="{{ $class }}"{{- end }}>
+    <summary>{{ $summary | .Page.RenderString }}</summary>
+    {{ .Inner | .Page.RenderString (dict "display" "block") -}}
+</details>
\ No newline at end of file


Test patch:
diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go
index c7e118e8259..36355598df7 100644
--- a/tpl/tplimpl/tplimpl_integration_test.go
+++ b/tpl/tplimpl/tplimpl_integration_test.go
@@ -600,3 +600,118 @@ a{{< comment >}}b{{< /comment >}}c
 	b := hugolib.Test(t, files)
 	b.AssertFileContent("public/index.html", "<p>ac</p>")
 }
+
+func TestDetailsShortcode(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ['rss','section','sitemap','taxonomy','term']
+defaultContentLanguage = "en"
+[languages]
+  [languages.en]
+    weight = 1
+  [languages.es]
+    weight = 2
+-- i18n/en.toml --
+[shortcodes.details]
+other = "Details"
+-- i18n/es.toml --
+[shortcodes.details]
+other = "Detalles"
+-- layouts/_default/single.html --
+{{ .Content }}
+-- content/d1.md --
+---
+title: Default State Test
+---
+{{< details >}}
+Basic example without summary
+{{< /details >}}
+-- content/d2.md --
+---
+title: Custom Summary Test
+---
+{{< details summary="Custom Summary" >}}
+Example with custom summary text
+{{< /details >}}
+-- content/d3.md --
+---
+title: Open State Test
+---
+{{< details summary="Test Open State" open="true" >}}
+Example with open state
+{{< /details >}}
+-- content/d4.md --
+---
+title: Attributes Test
+---
+{{< details summary="Test Attribute sanitization" style="color: red; font-weight: bold; background-color: #eee" onclick="alert('test')" >}}
+Example testing attribute sanitization
+{{< /details >}}
+-- content/d5.md --
+---
+title: Class Test
+---
+{{< details class="custom-class" >}}
+Example with allowed class attribute
+{{< /details >}}
+-- content/d6.md --
+---
+title: Name Test
+---
+{{< details name="custom-name" >}}
+Example with allowed name attribute
+{{< /details >}}
+-- content/d7.es.md --
+---
+title: Localization Test
+---
+{{< details >}}
+Localization example without summary
+{{< /details >}}
+`
+	b := hugolib.Test(t, files)
+
+	// Test1: default state (closed by default)
+	b.AssertFileContentEquals("public/d1/index.html",
+		"\n<details>\n    <summary>Details</summary>\n    <p>Basic example without summary</p>\n</details>\n",
+	)
+	content1 := b.FileContent("public/d1/index.html")
+	c := qt.New(t)
+	c.Assert(content1, qt.Not(qt.Contains), "open")
+
+	// Test2: custom summary
+	b.AssertFileContentEquals("public/d2/index.html",
+		"\n<details>\n    <summary>Custom Summary</summary>\n    <p>Example with custom summary text</p>\n</details>\n",
+	)
+
+	// Test3: open state
+	b.AssertFileContentEquals("public/d3/index.html",
+		"\n<details open>\n    <summary>Test Open State</summary>\n    <p>Example with open state</p>\n</details>\n",
+	)
+
+	// Test4: Test sanitization
+	b.AssertFileContentEquals("public/d4/index.html",
+		"\n<details>\n    <summary>Test Attribute sanitization</summary>\n    <p>Example testing attribute sanitization</p>\n</details>\n",
+	)
+	content4 := b.FileContent("public/d4/index.html")
+	c.Assert(content4, qt.Not(qt.Contains), "style")
+	c.Assert(content4, qt.Not(qt.Contains), "onclick")
+	c.Assert(content4, qt.Not(qt.Contains), "alert")
+
+	// Test5: class attribute
+	b.AssertFileContentEquals("public/d5/index.html",
+		"\n<details class=\"custom-class\">\n    <summary>Details</summary>\n    <p>Example with allowed class attribute</p>\n</details>\n",
+	)
+
+	// Test6: name attribute
+	b.AssertFileContentEquals("public/d6/index.html",
+		"\n<details name=\"custom-name\">\n    <summary>Details</summary>\n    <p>Example with allowed name attribute</p>\n</details>\n",
+	)
+
+	// Test7: localization
+	b.AssertFileContentEquals("public/es/d7/index.html",
+		"\n<details>\n    <summary>Detalles</summary>\n    <p>Localization example without summary</p>\n</details>\n",
+	)
+}