Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Details Shortcode for collapsable sections within markdown content #13090

Open
racehd opened this issue Nov 27, 2024 · 5 comments · May be fixed by #13100
Open

Add Details Shortcode for collapsable sections within markdown content #13090

racehd opened this issue Nov 27, 2024 · 5 comments · May be fixed by #13100

Comments

@racehd
Copy link

racehd commented Nov 27, 2024

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 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 which lets me show you what I'm talking about directly within this proposal:

Details

You can add a header

You can add text within a collapsed section.

You can add an image or a code block, too.

   puts "Hello World"

To implement the details element, I have developed the following code:

{{ $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, 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 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, .

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!

@bep bep added this to the Unscheduled milestone Nov 27, 2024
@bep bep removed the NeedsTriage label Nov 27, 2024
@bep
Copy link
Member

bep commented Nov 27, 2024

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.

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

@racehd
Copy link
Author

racehd commented Nov 27, 2024

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:

{{ $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:

/* 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
If a user wanted to have a details heading they would need to use CSS to force the inline behavior.

details > summary > * {
    display: inline;
}

Visual comparison:
image

@jmooring
Copy link
Member

jmooring commented Nov 27, 2024

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. 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 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:

layouts/shortcodes/details.html
{{- /*
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>

Notes:

  • If site/theme authors need to style the content (but not the summary) they can do:

    details :not(:first-child) {
      color: red;
    }
  • Use the RenderString method instead of the markdownify function to avoid these problems 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

@racehd
Copy link
Author

racehd commented Nov 28, 2024

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.html

{{- /*
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>

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.

@jmooring
Copy link
Member

jmooring commented Nov 29, 2024

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
layouts/shortcodes/details.html
{{- /*
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>

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

@racehd racehd linked a pull request Dec 1, 2024 that will close this issue
racehd added a commit to racehd/hugo that referenced this issue Dec 1, 2024
- Add new shortcode to render details HTML element.
- Implement integration tests to check: default state, custom summary, open state, safeHTML sanitization, allowed attributes, and localization of default summary text.
- Update docs to include details shortcode.

Closes gohugoio#13090
@bep bep added Enhancement and removed Proposal labels Dec 3, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants