How can I prevent an HTML p tag from being generated magically?

Foreword

This is a long as hell explanation, and very XY problem-y and I’m quite annoyed at how long I’ve burnt on this, so I’m just going to compile all I’ve written up and dump it here. Feel free to rename the title or answer the underlying problem in another post or however it works around here.

Underlying intent

I want to implement CSS-only, accessible side notes. I am following Making semantic sidenotes without JavaScript | Koos Looijesteijn with the corresponding full demo code at GitHub - kslstn/sidenotes: Semantic sidenotes for the web without JavaScript. I’m running into issues with the responsive version for thin (e.g. mobile) screens—we want to do some shenanigans that you can see demonstrated at the first link after procuring a thin viewport.

What I have

The function that generates side notes

#let sidenote(body) = {
  counter(footnote).step()
  context {
    let n = counter(footnote).get().first()

    html.span(class: "sidenote")[
      #html.input(
        aria-label: "Show sidenote",
        type: "checkbox",
        id: "sidenote__checkbox--" + str(n),
        class: "sidenote__checkbox",
      )
      #html.elem("label", attrs: (
        tabindex: "0",
        // title: str(body), // can't convert content to string
        aria-describedby: "sidenote-" + str(n),
        "for": "sidenote__checkbox--" + str(n),
        class: "sidenote__button",
      ))[
        #super[#n]
      ]
      #html.small(
        id: "sidenote-" + str(n),
        class: "sidenote__content sidenote__content--number-" + str(n),
      )[
        #html.span(class: "sidenote__content-parenthesis")[(sidenote: ]
        #super[#n]#body#html.span(class: "sidenote__content-parenthesis")[)]
      ]
    ]
  }
}

The HTML it generated

<span class="sidenote"
  ><input
    aria-label="Show sidenote"
    type="checkbox"
    id="sidenote__checkbox--1"
    class="sidenote__checkbox"
  /><label
    tabindex="0"
    aria-describedby="sidenote-1"
    for="sidenote__checkbox--1"
    class="sidenote__button"
    ><sup>1</sup></label
  >
  <p>
    <small id="sidenote-1" class="sidenote__content sidenote__content--number-1"
      ><span class="sidenote__content-parenthesis">(sidenote:</span>
      <sup>1</sup>Subframe LSP responses are great for coding. Stuff like bevy
      projects or
      <a href="https://graphite.rs/">https://graphite.rs/</a>
      contributions can get a little absurd, again, relative to what I’m used
      to. Mumble mumble metaprogramming is cool but dangerous.<span
        class="sidenote__content-parenthesis"
        >)</span
      ></small
    >
  </p></span
>

The HTML I want it to generate

Literally just the above but without the <p> and without the </p>.

Current workaround

I just regex it out in post-processing :/ Would love to do it in native Typst, though.

From Discord #quick-questions

Initial message by me

Been trying to implement Making semantic sidenotes without JavaScript | Koos Looijesteijn for the past, uh, hours. But it doesn’t seem possible to put an input, label, and small together on the same level without Typst trying to wrap small in a <p>?

#let sidenote(body) = {
  html.span[
    #html.input()
    #html.label()
    #html.small[aaaaa]
  ]
}

Word.#sidenote[Says who?] Anyways, check this out.

outputs

    <p>
      Word.<span
        ><input /><label></label>
        <p><small>aaaaa</small></p></span
      ><span style="white-space: pre-wrap">&#x20;</span>Anyways, check this out.
    </p>

Which borks the CSS selectors. And if I wrap the input and label into another span, that gets rid of the <p>, but also borks CSS selectors.

I’m losing track of what does what, but the input is a checkbox that toggles the span visible and invisible, and thus needs to select it

i.e. .sidenote__checkbox:checked ~ .sidenote__content and such

Reply by SillyFreak

hmm… the magic seems to be that the .sidenote__content element is next to the .sidenote__checkbox element, not that the content element is a small element. can you just wrap the small inside an explicit html.p and put the class there? Typst is probably adding the <p> since it thinks (rightfully? :thinking:) that there needs to be a block level element, so if you add one explicitly it should wrap it in another.

Re-reply by me

too tired to make it work, so I’ll just leave my post-processing regex hack as the solution for now. But that doesn’t work either because sidenotes, generated via #sidenote[...] are inside a paragraph already anyways. So by doing that, having a p inside a p, apparently HTML renderers resolve this by splitting it up? It’s all confusing and diffucult to find learning resources.

basically, if you put

<p>outer<p>inner</p>other side</p>

in a file and open it in the browser, you get

<p>outer</p><p>inner</p>other side<p></p>

even though by looking at view-source:localhost:8000 or whatever, nothing changed.

Also,

<p>outer<div>inner</div>other side</p>

turns into

<p>outer</p><div>inner</div>other side<p></p>

I think it’s a block v inline thing. And I think it happens render-time before css is applied? Wait I should check that

<style>div {display:inline}</style><p>one<div>two</div>three</p>

turns to

<p>one</p><div>two</div>three<p></p>

So, still bad.

Closing thoughts

Still confused what mechanism Typst uses to decide when to insert a paragraph tag. Possibly something to do with block level v inline level tags. Not sure, and I’ve ran out of energy to experiment.

:pensive:

2 Likes

I have also been tormented by this problem. :heart:
In my case, I used xml to read an existing html and found the exported html is different. (#5890)

The cause is that Typst wraps inline-level content in a paragraph:

The rules for when Typst wraps inline-level content in a paragraph are as follows:

  • All text at the root of a document is wrapped in paragraphs.
  • Text in a container (like a block) is only wrapped in a paragraph if the container holds any block-level content. If all of the contents are inline-level, no paragraph is created.

The criterion of inline-level is in typst/crates/typst-html/src/tag.rs at a4be7778e5bbc443f54c963711187470de2decc0 · typst/typst · GitHub.

/// Whether the element is inline-level as opposed to being block-level.
///
/// Not sure whether this distinction really makes sense. But we somehow
/// need to decide what to put into automatic paragraphs. A `<strong>`
/// should merged into a paragraph created by realization, but a `<div>`
/// shouldn't.
///
/// <https://www.w3.org/TR/html401/struct/global.html#block-inline>
/// <https://developer.mozilla.org/en-US/docs/Glossary/Inline-level_content>
/// <https://github.com/orgs/mdn/discussions/353>
pub fn is_inline_by_default(tag: HtmlTag) -> bool {
    matches!(
        tag,
        self::abbr | self::a | self::bdi | self::b | self::br | self::bdo
            | self::code | self::cite | self::dfn | self::data | self::i
            | self::em | self::mark | self::kbd | self::rp | self::q
            | self::ruby | self::rt | self::samp | self::s | self::span
            | self::small // 👈
            | self::sub | self::strong | self::time | self::sup | self::var | self::u
    )
}
  • It does not take CSS into account
  • <input> and <label> are considered block-level
  • <small> is considered inline-level

As a result, <small> is wrapped with a <p>.

Workaround

Put block-level tags in <span>.

#{
  html.span(html.label(
    html.input(),
  ))
  html.small[aaaaa]
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <!-- Spaces are added by me. -->
    <p>
      <span>
        <label><input></label>
      </span>
      <small>aaaaa</small>
    </p>
  </body>
</html>
1 Like

See Create a dedicated section on dangers of using square brackets carelessly (multi-line) · Issue #6844 · typst/typst · GitHub. Your code will generate additional spacing because of block-level markup parts.

#let sidenote(body) = {
  counter(footnote).step()
  context {
    let n = counter(footnote).get().first()

    [
      #[
        #super[#n]
      ]
      #[
        #[(sidenote: ]
        #super[#n]#body#[)]
      ]
    ]
  }
}

#sidenote[Subframe LSP responses are great for coding. Stuff like bevy projects
  or https://graphite.rs/ contributions can get a little
  absurd, again, relative to what I’m used to. Mumble mumble metaprogramming is
  cool but dangerous.]

#let sidenote(body) = {
  counter(footnote).step()
  context {
    let n = counter(footnote).get().first()

    {
      {
        super[#n]
      }
      {
        [(sidenote: ]
        super[#n]
        body
        [)]
      }
    }
  }
}

#sidenote[Subframe LSP responses are great for coding. Stuff like bevy projects
  or https://graphite.rs/ contributions can get a little
  absurd, again, relative to what I’m used to. Mumble mumble metaprogramming is
  cool but dangerous.]

1 Like

This workaround technically answers the question I chose for the title, but inserting such a wrapper prevents the input (a checkbox) from being able to select the content below it, because CSS can’t select outside of a node’s siblings, or such. :pensive:

Relevant: CSS selectors - CSS | MDN