How could I automatically generate tree views/collapsible lists from Typst lists?

For example,

// whatever preamble is needed

- Giant planets
  - Gas giants
    - Jupiter
    - Saturn
  - Ice giants
    - Uranus
    - Neptune

would become the tree view as described (HTML-wise) from [1]. Some prior work includes

  • How to generate a nested array from Typst bullet list? [2]
  • How to generate a multilevel list from a data structure? [3]

I’ve realized this might be a bit of work and I’m not looking to do more coding on top of schoolwork right now, so I’ll revisit this myself later.

For context, I have class notes in tree-view form at [4], and I thought it would be nice to be able to collapse arbitrary tree branches.

[1] Tree views in CSS
[2] How to generate a nested array from Typst bullet list?
[3] How to generate a multilevel list from a data structure?
[4] GitHub - saffronner/15351-notes at f701979620797e0e958d87bd59c27f43d06cfbbc

I apologize for circumventing the “new members can’t post many links” safeguard, but I promise I’m not, like, phishing or spamming or anything. I truly believe these links are sufficient and necessary. proper links added by mod :slight_smile:

To expand a bit more, I imagine I need some sort of recursive wrapper around my notes-as-typst-list that somehow “unpacks” the list recursively, and inserts the relevant HTML for every item…

[1] Tree views in CSS
[2] How to generate a nested array from Typst bullet list?
[3] How to generate a multilevel list from a data structure?
[4] GitHub - saffronner/15351-notes at f701979620797e0e958d87bd59c27f43d06cfbbc

1 Like

@vmartel08 I added the links properly to the original post :slight_smile:

Adding classes to HTML <ul> elements

I did some transforming of Typst lists into custom HTML (actually just adding a class to the ul element) for a package of mine, see here: typst-moodular/src/c4l/mod.typ at main · SillyFreak/typst-moodular · GitHub

This gives you a first step in that direction:

#show list: it => {
  show: html.elem.with("ul", attrs: (class: "tree"))

  for elem in it.children {
    html.elem("li", elem.body)
  }
}

- Giant planets
  - Gas giants
    - Jupiter
    - Saturn
  - Ice giants
    - Uranus
    - Neptune
HTML output
<ul class="tree">
  <li>
    <p>Giant planets</p>
    <ul class="tree">
      <li>
        <p>Gas giants</p>
        <ul class="tree">
          <li>Jupiter</li>
          <li>Saturn</li>
        </ul>
      </li>
      <li>
        <p>Ice giants</p>
        <ul class="tree">
          <li>Uranus</li>
          <li>Neptune</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

Selecting specific nesting levels

The class is wrongly applied to all nested lists too; we can prevent that with a state:

#let tree-depth = state("tree-depth", 0)

#show list: it => context {
  show: html.elem.with("ul", attrs: (:
    ..if tree-depth.get() == 0 { (class: "tree") }
  ))

  tree-depth.update(x => x + 1)
  for elem in it.children {
    html.elem("li", elem.body)
  }
  tree-depth.update(x => x - 1)
}

...
HTML output
<ul class="tree">
  <li>
    <p>Giant planets</p>
    <ul>
      <li>
        <p>Gas giants</p>
        <ul>
          <li>Jupiter</li>
          <li>Saturn</li>
        </ul>
      </li>
      <li>
        <p>Ice giants</p>
        <ul>
          <li>Uranus</li>
          <li>Neptune</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

Transforming list items into <details> elements

The next step is a bit more involved; basically, we want to determine if a list element contains a nested list, and if so, split that into summary and details:

/// returns either a tuple (summary, details) or none
#let split-summary(body) = {
  assert.eq(type(body), content)
  let sequence = [].func()

  // this is a list: no summary
  if body.func() in (list, list.item) { return (none, body) }

  // if it's not a sequence it can't contain a list
  // at least not directly – a block containing a list will throw us off!
  if body.func() not in (sequence,) { return none }

  let index = body.children.position(c => c.func() in (list, list.item))
  // if no list or item is found, it doesn't contain a list
  if index == none { return none }

  // split and return the parts
  let summary = body.children.slice(0, index).join()
  let body = body.children.slice(index).join()
  (summary, body)
}

I’m making it a bit easy for myself here; Once I found a list item, everything else is part of the <details>. This will break for list items like these:

- Summary
  - the
  - details
  More content; should not be part of the details

However since I don’t know if the CSS would handle that anyway, I have chosen to ignore this limitation.

Now, we create a <details> element. Setting <details open> is easy since we know the depth of each list anyway.

#show list: it => context {
  let top-level = tree-depth.get() == 0
  show: html.elem.with("ul", attrs: (:
    ..if top-level { (class: "tree") }
  ))

  tree-depth.update(x => x + 1)
  for elem in it.children {
    let result = split-summary(elem.body)
    let body = if result == none {
      // a regular list element if there's no nested list
      elem.body
    } else {
      // a <details> element
      show: html.elem.with("details", attrs: (:
        ..if top-level { (open: "true") }
      ))
      let (summary, body) = result
      if summary != none {
        html.elem("summary", summary)
      }
      body
    }
    html.elem("li", body)
  }
  tree-depth.update(x => x - 1)
}

The result is almost perfectly what’s shown in [1]:

HTML output
<ul class="tree">
  <li>
    <details open="true">
      <summary>Giant planets</summary>
      <ul>
        <li>
          <details>
            <summary>Gas giants</summary>
            <ul>
              <li>Jupiter</li>
              <li>Saturn</li>
            </ul>
          </details>
        </li>
        <li>
          <details>
            <summary>Ice giants</summary>
            <ul>
              <li>Uranus</li>
              <li>Neptune</li>
            </ul>
          </details>
        </li>
      </ul>
    </details>
  </li>
</ul>

The only difference is pretty printing, and that Typst produced <details open="true"> instead of <details open>.

1 Like

Wonderful! I’ve transcribed it to GitHub - saffronner/15351-notes at html-experiments, where I have some ideas for how to make it prettier/simpler, but thanks so much for providing an idea for the main thing. Need to run, but it’s so appreciated. Marking as answer.

1 Like