🙈

Astro+MDX+Web Componentでハマったと思ったらHTMLの知識不足だった

2024/11/01に公開

Astroのバグかと思ったらHTMLの仕様でした。

発生したバグ

AstroのMDXインテグレーションには、指定したHTML要素をカスタムコンポーネントに差し替える機能があります。
MarkdownとMDX | Docs
具体的にはこんなことができます。めちゃくちゃ便利です。

index.astro
---
import { getEntry } from "astro:content";
import H1Component from "../components/H1Component.astro";

const post = await getEntry('post', 'test-post');
const { Content } = await post.render();
---
<Content components={{h1: HogeComponent}} />
HogeComponent.astro
---
---
<h1 class="hoge"><slot /></h1>
test-post.mdx
# This is h1!
index.html(出力)
<!DOCTYPE html><h1 class="hoge">This is h1!</h1>

この機能を使ってimgやemタグをWeb Componentに置き換えようとしたとき、見事にハマりました。
以下のようにWeb Componentの中にdivなど要素をいれると、それらがWeb Componentの外側に飛び出てしまいます。なんで???

TestComponent.astro
---
---
<test-component>
    <div>From TestComponent</div>
    <slot />
</test-component>

<script>
    class TestComponent extends HTMLElement {}
    customElements.define('test-component', TestComponent)
</script>

飛び出しバグのスクリーンショット

いろいろ実験してみて、以下の条件で飛び出しが発生することがわかりました。

  • img, em, strongなどの置き換えで発生するが、pタグの置き換えでは発生しない
  • Webコンポーネントの中でdiv, blockquote要素などを使っているときに発生する

原因

ここで実際にastro buildで出力されたHTMLファイルを見てみます。

index.html
<!doctype html>
<p>
  <script type="module">
    class e extends HTMLElement {}
    customElements.define("test-component", e);
  </script>
  <test-component>
    <div>From TestComponent</div>
    hoge
  </test-component>
</p>

え??出力されたファイルはカスタムコンポーネントの記述に沿っています…あ!!!

Astroが原因ではなく、Phrasing content以外の要素をpタグの中で使っていることが問題でした。pタグのPermitted ContentはPhrasing contentのため、それ以外を渡していたことによって正しくないHTML構文になり、ブラウザがHTMLのパース時に修正してくれた結果飛び出しが起こっていたみたいです。

このような間違った構文がパーサーに渡されたときの具体的な挙動が気になったので、HTML Standardを見てみました。以下はin bodyモードでのparsing条件からの引用です。
HTML Standard #parsing-main-inbody

A start tag whose tag name is one of: "address", "article", "aside", "blockquote", "center", "details", "dialog", "dir", "div", "dl", "fieldset", "figcaption", "figure", "footer", "header", "hgroup", "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul"

If the stack of open elements has a p element in button scope, then close a p element.

Insert an HTML element for the token.

一部抜粋にとどめますが、他いくつかの開きタグに対しても"If the stack of open elements has a p element in button scope, then close a p element."の記述があります。
正直全部把握しきれてないですが、ざっくり「Phrasing contentでない要素がp要素が閉じられる前に現れた場合、p要素を閉じる」と理解しました。

そしてp閉じタグの挙動は以下のとおりです。

An end tag whose tag name is "p"

If the stack of open does not have a p element in button scope, then this is a parse error; insert an HTML element for a "p" start tag token with no attributes.

Close a p element.

「p要素が開いていない状態でp要素の閉じタグが記述されているとき、p要素の開きタグを属性なしで挿入する」ということかと思います。確かに上のスクリーンショットをよく見ると、飛び出た中身の下に<p></p>が続いています。

結論

AstroとMDXの話に戻ります。

MDX及びMarkdownからHTMLへのコンパイル時、上の条件の1つ目に挙げたimg, em, strong要素などは、変換後は共通してp要素の中に入ります。そして条件2つ目でp要素が持てないdivやblockquote要素を子孫に挿入することになるので、正しくないHTML構文となり、ブラウザのパース後の出力では一見Web Componentから子要素が飛び出したように見えてみたようです。

おわりに

結局AstroやMDXに関連した挙動というより、間接的に許可されていないHTML構文を作ってしまっていたことが主な原因でした。Markdown構文をパッと見たときにp要素の中に対象要素が入ると分かりづらく、またカスタムコンポーネントで挿入するためコーディング時にはHTML構文に誤りがあることに気づきづらい点が厄介でした。

HTML仕様書は今回始めて読んだのですが、"button scope"の意味をちゃんと理解しきれていないので、もしかしたらトンチンカンなことを書いてしまっているかもしれません。もしそうならコメントでぜひ教えていただけると嬉しいです!

参考

Custom MDX components get tangled · Issue #5868 · withastro/astro · GitHub
Content categories - HTML: HyperText Markup Language | MDN
<p>: The Paragraph element - HTML: HyperText Markup Language | MDN
HTML Standard #parsing-main-inbody
The HTML parser - Idiosyncrasies of the HTML parser

Discussion