· web
A Jekyll + kramdown Bug That Explains How CSS Selectors Actually Work
I wanted the date and title in my archive/category/tag lists to sit on one line, like:
08-13 · Some post title
The Markdown source for each list item looked like this:
- <time datetime="2026-08-13">08-13</time> · [Some post title](/some/url)
The date and the title kept rendering on two separate lines no matter what CSS I threw at the <p> inside the <li>. Chasing that bug down turned into a useful refresher on how kramdown parses raw HTML, and on how CSS selectors are actually evaluated — which is worth writing up on its own.
The wrong DOM assumption
My first fix targeted the paragraph:
.prose li > p {
display: flex;
align-items: baseline;
gap: 0.5em;
}
This assumes the rendered DOM looks like:
<li>
<p><time>08-13</time> · <a href="...">Some post title</a></p>
</li>
It doesn’t. kramdown’s HTML Blocks rule explains why: a line is treated as the start of a raw HTML block if it begins with a tag that is not on kramdown’s span-level HTML tag list. That list is:
a abbr acronym b big bdo br button cite code del dfn em i img input
ins kbd label option q rb rbc rp rt rtc ruby samp select small span
strong sub sup textarea tt var
time is not in it. So a line starting with <time> is parsed as an HTML block, not as inline span content. kramdown’s docs spell out what happens next: “If the HTML/XML tag content should be handled as raw HTML, then only HTML/XML tags are parsed from this point onwards… If there is text after an end tag, it will be parsed as if it appears on a separate line.”
In other words, <time>08-13</time> is consumed as its own raw HTML block, and the remaining · [Some post title](/some/url) on the same source line is parsed as if it started a new line — which wraps it in its own paragraph. The actual DOM is:
<li>
<time datetime="2026-08-13">08-13</time>
<p>· <a href="/some/url">Some post title</a></p>
</li>
<time> and <p> are siblings, not parent/child. A rule scoped to .prose li > p only ever touches the paragraph’s own inline content (the “·” and the link) — it has no way to reach <time>, so the two nodes kept stacking as separate block-level elements.
The fix: style the <li>, not the <p>
Since the only common ancestor of <time> and <p> is the <li> itself, the flex container has to be the <li>:
.prose li:has(> time) {
display: flex;
align-items: baseline;
gap: 0.5em;
margin: 0.3em 0;
}
.prose li:has(> time) > p {
margin: 0;
}
.prose li > time {
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
Three things are doing distinct jobs here:
.prose li:has(> time)uses:has()to select only the<li>elements whose direct child is a<time>— i.e., exactly the archive/category/tag list items, without touching ordinary bullet lists elsewhere in a post. Making the<li>itselfdisplay: flexlines up its two children,<time>and<p>, on one row.> p { margin: 0; }removes the paragraph’s default margin. Browsers apply a block-level top/bottom margin to<p>by default; once the<li>is a flex container, that margin would otherwise push the flex items apart vertically instead of letting them share a baseline.li > timesetsflex: noneso the date keeps its natural width instead of shrinking (the flex default isflex: 0 1 auto, which allows shrinking), plus a muted color andtabular-numsso the day numbers align visually.
:has() reached Baseline “widely available” support in Chrome/Edge 105, Firefox 121, and Safari 15.4, so it’s safe to rely on in a 2026 static site without a fallback.
The selector-parsing rule that actually matters here
The part of this debugging session worth remembering longer than the CSS itself: a CSS selector always filters the rightmost (last) compound selector; everything before it is a condition on that element’s ancestry, not a separate target.
.prose li > pselects<p>elements that are a direct child of an<li>that is a descendant of.prose. It does not select “<li>elements containing a<p>.”.prose li p(all descendant combinators) selects<p>anywhere inside an<li>anywhere inside.prose— the loosest possible ancestry constraint..prose li:has(> time)is the exception that proves the rule::has()is specifically designed to let the left-hand compound (the<li>) be the one that’s selected, based on a condition about its descendants. It’s the only standard way to select an element based on its children rather than its ancestors.
A related trap: comma-separated selectors don’t share context. .prose li p, br is two entirely independent selectors — .prose li p and a bare br that matches every <br> on the page, not just ones inside .prose li. To apply a rule to multiple tags under the same scope, repeat the full ancestor chain for each (.prose li p, .prose li br) or use :is()/:where() to factor it out (.prose li :is(p, br)).
Takeaway
When a CSS rule silently does nothing, don’t assume the selector syntax is wrong before checking the actual generated DOM — with any Markdown processor, an unfamiliar-looking source line can be parsed very differently than it visually appears. kramdown’s span-level HTML tag whitelist is short and easy to overlook; anything not on it, placed at the start of a line, becomes a block-level raw HTML node instead of inline content.
References
- kramdown Syntax — HTML Blocks — defines the span-level HTML tag whitelist and the raw-HTML-block parsing behavior that explains why
<time>and the trailing text end up as sibling nodes instead of nested inside one paragraph. - MDN —
:has()CSS pseudo-class — documents:has()syntax, the sibling/parent-selection use case applied in the fix, and current browser support. - MDN —
:is()CSS pseudo-class — confirms that:is()takes on the specificity of its most specific argument, while:where()always has zero specificity, supporting the comparison used for factoring the comma-selector example.