Astro Syntax Deep Dive
Expression: { } หย่อนเข้าไปใน JavaScript
หัวข้อที่มีชื่อว่า “Expression: { } หย่อนเข้าไปใน JavaScript”ที่ไหนก็ได้ใน template { } จะฝัง JavaScript expression แล้ว render ค่าที่ได้ออกมา ตัวแปรจาก frontmatter ใช้ได้เลย
---const name = "Ada";const count = 3;---<h1>Hello, {name}</h1><p>You have {count} messages.</p><p>Total: {count * 2}</p>มีแค่ expression เท่านั้นที่ใส่ใน { } ได้ — สิ่งที่ evaluate ออกมาเป็นค่า statement (บล็อก if, loop for) ต้องอยู่ใน frontmatter ใน template คุณเขียน control flow เป็น expression แบบที่แสดงข้างล่างนี้
Conditional
หัวข้อที่มีชื่อว่า “Conditional”ใช้ idiom logical-AND เพื่อ render บางอย่างเฉพาะตอนเงื่อนไขเป็นจริง และ ternary เพื่อเลือกระหว่างสองทาง:
---const isLoggedIn = true;const user = { name: "Ada", role: "admin" };---{isLoggedIn && <p>Welcome back, {user.name}</p>}
{user.role === "admin" ? <a href="/admin">Admin panel</a> : <a href="/account">Your account</a>}{cond && <div/>} render element เมื่อ cond เป็น truthy และไม่ render อะไรเลยถ้าไม่ใช่ ส่วน ternary จะ render กิ่งใดกิ่งหนึ่งเสมอ
ไม่มี loop directive พิเศษ — คุณ map array ให้เป็น markup เหมือน JSX เป๊ะ:
---const posts = [ { id: 1, title: "First" }, { id: 2, title: "Second" },];---<ul> {posts.map((post) => <li>{post.title}</li>)}</ul>Astro ไม่ต้องการ prop key แบบ React — นี่คือ server rendering ไม่มี reconciliation Map แล้ว return element จบ
Fragment: จับกลุ่มโดยไม่มี wrapper
หัวข้อที่มีชื่อว่า “Fragment: จับกลุ่มโดยไม่มี wrapper”เมื่อต้องการ return หลาย element โดยไม่เพิ่ม DOM node ห่อ ใช้ Fragment Astro รองรับทั้ง <Fragment> แบบมีชื่อ และ shorthand <>:
---const items = ["a", "b"];---<Fragment> <dt>Term</dt> <dd>Definition</dd></Fragment>
{items.map((x) => ( <> <span>{x}</span> <br /> </>))}Fragment ยังเป็นที่อยู่ของ directive set:* เมื่อไม่มี element ให้ยึด (ถัดไป)
Render raw HTML: set:html กับ set:text พิสูจน์จริง
หัวข้อที่มีชื่อว่า “Render raw HTML: set:html กับ set:text พิสูจน์จริง”ค่า default แล้ว expression ที่ได้ string จะถูก escape — Astro render เป็น text ดังนั้น <b>hi</b> จะโชว์ tag ตรง ๆ ตามตัวอักษร ปลอดภัย เมื่อมี HTML string ที่เชื่อถือได้จริง ๆ ที่อยากให้ render เป็น markup (เช่น HTML จาก CMS ที่คุณควบคุมเอง) ใช้ set:html:
---const untrusted = "<img src=x onerror=alert('xss')>";---<div id="raw" set:html={untrusted} /><div id="safe" set:text={untrusted} />set:html ข้าม escaping — รวมถึง content ที่คุณไม่ได้เขียนเอง Render ผ่าน Container API กับ [email protected] ที่ติดตั้งจริง ด้วย string รูปแบบ XSS ที่ field จาก attacker จริง ๆ (comment, username, search query) อาจมี:
<!-- id="raw" (set:html) --><div id="raw"><img src=x onerror=alert('xss')></div><!-- id="safe" (set:text) --><div id="safe"><img src=x onerror=alert('xss')></div>div id="raw" render tag <img onerror=...> ตรงตัวเป๊ะ — XSS payload จริง ทำงานทันทีที่ browser parse ส่วน set:text (div ข้างล่าง input เดียวกัน) escape ทุกตัวอักษร ดังนั้น tag โผล่มาเป็น text เฉย ๆ นี่ไม่ใช่สมมติฐาน set:html คือ จุด injection ถ้า argument ของมันมี content ที่ attacker แทรกได้ — คิดเหมือนกับ dangerouslySetInnerHTML ใน React หรือ v-html ใน Vue ใช้มันกับ content ที่คุณควบคุมเองหรือผ่าน sanitizer จริงแล้วเท่านั้น
type ของ set:html คือ any และ behavior ตอน runtime ยอมรับมากกว่า “แค่ string” — พิสูจน์ตรงกับ compiler ที่ติดตั้งจริง:
---const promiseVal = Promise.resolve('<b>from a promise</b>');const responseVal = new Response('<b>from a response</b>');---<div id="promise" set:html={promiseVal} /><div id="response" set:html={responseVal} /><div id="promise"><b>from a promise</b></div><div id="response"><b>from a response</b></div>Promise<string> ถูก await ก่อนถูกใส่เข้าไป และ Response ถูกอ่าน body เป็น text — ทั้งคู่กลายเป็น markup จริง ไม่ escape สิ่งที่มันไม่รับคือ element JSX/Astro ที่ส่งเป็นค่าตรง ๆ — ตัวนี้ fail ตอน compile ไม่ใช่ runtime:
// @expect-error set:html cannot take a JSX expression directly — only a string, Promise<string>, or Response------<div set:html={<Fragment><b>from jsx</b></Fragment>} />พิสูจน์กับ compiler ที่ติดตั้งจริง — parse failure จริง ไม่ใช่แค่ type warning:
Parse failure: Parse failed with 1 error:Unexpected JSX expression ...${$$unescapeHTML(<Fragment>... ^set:text บังคับให้ค่าถูกปฏิบัติเป็น plain text (ค่า default ที่ปลอดภัย เขียนออกมาชัด ๆ) — ใช้เมื่อต้องการรับประกันว่าไม่มี HTML ถูก render เลย แม้ string จะเริ่มมีตัวอักษรหน้าตาเหมือน markup ก็ตาม
Spread attribute
หัวข้อที่มีชื่อว่า “Spread attribute”เมื่อมี object ของ attribute อยากจะ spread เข้า element ด้วย {...obj} — สะดวกสำหรับส่งต่อ prop:
---const attrs = { id: "cta", class: "btn", "data-track": "signup" };---<a href="/join" {...attrs}>Join</a>Dynamic tag name
หัวข้อที่มีชื่อว่า “Dynamic tag name”คุณคำนวณได้ว่า tag ไหน จะ render กำหนดตัวแปรที่ขึ้นต้นด้วยตัวใหญ่ให้เป็นชื่อ tag แล้วใช้มัน:
---const level = 2;const Heading = `h${level}`; // "h2"---<Heading>Dynamic heading</Heading> <!-- renders <h2>Dynamic heading</h2> -->ตัวแปรต้องขึ้นต้นด้วยตัวใหญ่ เพื่อให้ Astro ปฏิบัติมันเป็น component/tag reference แทนที่จะเป็น HTML tag ตรงตัว
flowchart LR expr["expression in curly braces"] --> eval["evaluated on the server"] eval --> esc["string values are escaped by default"] esc --> html["safe HTML output"] eval --> raw["set:html bypasses escaping for trusted HTML"] raw --> html
Compiler ของ Astro 7 พิสูจน์ผิดสี่แบบ
หัวข้อที่มีชื่อว่า “Compiler ของ Astro 7 พิสูจน์ผิดสี่แบบ”Astro 7 เปลี่ยน compiler เก่าที่เขียนด้วย Go มาเป็น Rust compiler และตอนนี้เป็น compiler เดียวที่ใช้ มันเร็วกว่าและเข้มงวดกว่า: compiler เก่า “แก้ให้” HTML ที่พังแบบเงียบ ๆ ตัวใหม่รายงานเป็น build error สี่ความล้มเหลวจริง รันผ่าน astro check กับ [email protected] ที่ติดตั้งจริง — ไม่ใช่จำมา แต่ทำซ้ำจริง:
1. tag ที่ไม่ปิด:
// @expect-error unclosed <p> — Astro 6 silently closed it for you, Astro 7 fails the build------<p>Hello world<p>Hello world</p>src/pages/unclosed.astro:3:2 - error ts(17008): HTML element 'p' has no corresponding closing tag.<p>Hello world<p>Hello world</p> ~src/pages/unclosed.astro:4:1 - error astro: Expected a closing tag but instead found the end of the file.2. Element ที่ปิดผิดลำดับ (<span> ถูกปิดหลัง parent <div> ของมันแทนที่จะปิดก่อน):
// @expect-error mis-nested closing tags------<div><span>oops</div></span>src/pages/wrongorder.astro:3:24 - error ts(17002): Expected corresponding JSX closing tag for 'Fragment'.<div><span>oops</div></span> ~~~~src/pages/wrongorder.astro:3:7 - error ts(17008): HTML element 'span' has no corresponding closing tag.<div><span>oops</div></span> ~~~~src/pages/wrongorder.astro:3:16 - error astro: Expected a matching closing tag but instead found '</div>'.<div><span>oops</div></span> ~~~~~~3. { เดี่ยว ๆ ในเนื้อความ (ไม่ได้ตั้งใจให้เป็น expression — ประโยคที่บังเอิญใช้ปีกกา):
// @expect-error a `{` outside a fence is parsed as the start of an expression, not literal text------<p>Set theory uses { and } to denote a set.</p>src/pages/curlytext.astro:3:22 - error ts(2304): Cannot find name 'and'.<p>Set theory uses { and } to denote a set.</p> ~~~4. Expression ที่ “return object” — อันนี้ไม่ fail build เลย มันเป็น JavaScript และ Astro ที่ valid render ออกมาเหมือนกับที่ String(obj) จะทำ:
---const obj = { a: 1, b: 2 };---<p>{obj}</p><p>[object Object]</p>นี่เป็น footgun จริง แค่ไม่ใช่ตอน compile-time: object ที่หย่อนเข้า { } จะ stringify กลายเป็น text ไร้ประโยชน์ [object Object] แทนที่จะ error ดังนั้น bug นี้จะโผล่มาแค่ตอนมองหน้าเว็บที่ render ออกมา (หรือ snapshot test) ถ้าตั้งใจจะ render field ให้เขียน {obj.a} ถ้าตั้งใจจะ debug ด้วย JSON ให้เขียน {JSON.stringify(obj)}
การเปลี่ยนที่สองของ Astro 7 คือ whitespace option compressHTML ตอนนี้ default เป็น 'jsx' ดังนั้น whitespace ระหว่าง element เป็นไปตามกฎ JSX — newline ระหว่าง element แบบ inline สอง element ไม่ทำให้เกิดช่องว่างที่เห็นได้อีกต่อไป พิสูจน์ด้วย Container API ตัด attribute debug data-astro-source-* ออกเพื่อให้อ่านง่าย:
<span>hello</span><em>world</em><span>hello</span><em>world</em>นั่น render เป็น “helloworld” ไม่มีช่องว่าง — ยืนยันกับ output จริง ไม่ใช่สมมติเอา วิธีแก้คือเขียนชัด ๆ เหมือนกับที่จะทำใน React:
<span>hello</span>{" "}<em>world</em><span>hello</span> <em>world</em>ถ้า template เก่าขนาดใหญ่พึ่ง behavior เดิม ตั้ง compressHTML: true ใน astro.config.mjs เพื่อคงมันไว้ระหว่าง migrate โปรเจกต์ใหม่ควรเขียน {" "} ตรงที่ต้องการช่องว่างเลย
ลองเลย: compile โค้ดที่ผิดจริงในเบราว์เซอร์ ความผิดพลาดสี่แบบข้างบนพิสูจน์ด้วย Rust compiler จริงผ่าน astro check — แต่ compiler ตัวนั้นรันในเบราว์เซอร์ไม่ได้ (WASM build ตัวเดียวของมัน link ไม่ผ่านเมื่อโหลดจาก CDN: LinkError: ... "napi_create_async_work": function import requires a callable) playground นี้เลยโหลด @astrojs/compiler รุ่นคลาสสิกที่ทำมาให้รันในเบราว์เซอร์ได้แทน — เป็น Go compiler รุ่นก่อน 7.0 ที่ยังเผยแพร่ไว้เพื่อจุดประสงค์นี้โดยเฉพาะ มันปล่อยผ่านง่ายกว่ามาก (แท็กที่ไม่ปิดหรือปิดผิดลำดับข้างบน compile ผ่านโดยไม่มี diagnostic เลย) แต่มันยังจับความผิดพลาดจริงของ Astro ที่ไม่ขึ้นกับเวอร์ชันได้อยู่ — set:html บน element ที่ยังมี child node เหลืออยู่:
set:html ที่ยังมี children เหลืออยู่
------<div set:html={"<b>Overwritten</b>"}> This text is ignored -- set:html replaces all children.</div>กด Compile: แผง diagnostics จะโชว์ WARNING_SET_WITH_CHILDREN จริง — “set:html directive will overwrite child nodes” — และแผง output จะโชว์ JS ที่ compile แล้ว ให้เห็นเองว่าข้อความไม่ได้ไปโผล่ใน output ที่ render ออกมาเลย (พิสูจน์ตอนสร้าง playground นี้: diagnostic ตัวนี้ขึ้นเฉพาะตอน child เป็น text node แบบข้างบนเท่านั้น — ถ้าเปลี่ยนเป็น element ซ้อนอย่าง <p>...</p> ใน compiler build นี้ warning จะหายไปเงียบ ๆ เป็นช่องโหว่จริงที่ค่อนข้างเซอร์ไพรส์ — ไม่ใช่ทุกกรณี “ยังมี children” จะถูกจับได้หมด)
ชื่อ attribute แบบ HTML ไม่ใช่ชื่อแบบ JSX
หัวข้อที่มีชื่อว่า “ชื่อ attribute แบบ HTML ไม่ใช่ชื่อแบบ JSX”ความต่างที่สำคัญแต่ subtle จาก React: template ของ Astro ใช้ชื่อ attribute แบบ HTML มาตรฐาน เป็น class ไม่ใช่ className, for ไม่ใช่ htmlFor คุณกำลังเขียน superset ของ HTML ดังนั้นชื่อแบบ HTML คือของจริง
Directive: is:raw, define:vars, class:list
หัวข้อที่มีชื่อว่า “Directive: is:raw, define:vars, class:list”is:raw บอก compiler ให้หยุด parse ลูกของ element เป็น syntax ของ Astro เลย — { } ข้างในมันเป็น text ตรงตัว ไม่ใช่ expression:
<div is:raw> { this is not an expression, is:raw stops the compiler from parsing it } <p>literal { braces } stay literal</p></div>output ที่พิสูจน์แล้ว — ปีกกาผ่านมาโดยไม่ถูกแตะ และ <p> ข้างในไม่ถูก parse เป็น element เลยด้วยซ้ำ:
<div> { this is not an expression, is:raw stops the compiler from parsing it } <p>literal { braces } stay literal</p></div>ใช้เมื่อกำลัง render ตัวอย่างโค้ดหรือ client-side templating snippet (Handlebars, Vue template) ที่เต็มไปด้วย { } ที่ไม่อยากให้ Astro แตะ
define:vars (ที่แนะนำไปก่อนหน้าสำหรับ <style>) ก็ใช้กับ element อื่นได้เพื่อฉีดค่าจาก frontmatter เป็น CSS custom property แบบ inline — พูดถึงลึก ๆ สำหรับ <style> ในบทถัดไป
class:list รวมแหล่งที่มาของ class name หลายแบบ — array, object ({ className: boolean }), conditional string, และ plain string — เข้าเป็น class attribute เดียว ใช้เพิ่มเติมจาก class แบบ static:
---const isActive = true;const size = 'lg';---<div class="base" class:list={['btn', { active: isActive, disabled: false }, size && `size-${size}`]}>x</div>output ที่รวมแล้วพิสูจน์จริง — disabled ถูกตัดออก (ค่าเป็น false) และ template string ที่มี guard falsy ชนะเพราะ size เป็น truthy และ class="base" แบบ static ถูกเก็บไว้และรวมเข้าด้วยกัน:
<div class="base btn active size-lg">x</div>Astro.self: component แบบ recursive
หัวข้อที่มีชื่อว่า “Astro.self: component แบบ recursive”component render ตัวเองได้ด้วย <Astro.self> ส่ง prop ใหม่ทุกครั้ง — นี่คือวิธีสร้าง tree (comment ซ้อนกัน, nav แบบ recursive, file browser) จาก component definition เดียว:
---interface Props { depth: number }const { depth } = Astro.props;---{depth > 0 ? ( <div class="node"> depth {depth} <Astro.self depth={depth - 1} /> </div>) : <div class="leaf">leaf</div>}Render ด้วย depth={3} ผ่าน Container API — output ซ้อนกันจริง ระดับละหนึ่ง recursive call จบที่ branch leaf:
<div class="node">depth 3<div class="node">depth 2<div class="node">depth 1<div class="leaf">leaf</div></div></div></div>ไม่มี recursion limit built-in นอกเหนือจาก stack ของตัวเอง — guard เงื่อนไขจบ (depth > 0 ข้างบน) แบบเดียวกับที่ทำใน recursive function ปกติ ไม่งั้น prop ที่แย่ (เช่น depth เริ่มต้นติดลบที่ไม่ถึง base case เลย) จะ recurse จนกว่า process จะพัง
Astro.slots.has กับ Astro.slots.render
หัวข้อที่มีชื่อว่า “Astro.slots.has กับ Astro.slots.render”นอกจาก <slot /> ใน template แล้ว Astro.slots เป็น API ใน frontmatter: has(name) บอกว่า slot ได้รับ content หรือเปล่า และ render(name, args?) return content ของ slot เป็น HTML string (await เพราะ render เป็น async) — มีประโยชน์เมื่อต้องการ markup เป็นค่าแทนที่จะหย่อนตรง ๆ ใน template ด้วย <slot /> slots-and-composition (บทถัดไป) ใช้ parameter args ของ render เพื่อสร้าง function-as-slot (“scoped slot”) ที่นี่พิสูจน์แค่กลไกของมันเอง:
---const hasDefault = Astro.slots.has('default');const rendered = await Astro.slots.render('default', ['scoped-arg']);---<p>has:{String(hasDefault)}</p><div id="rendered" set:html={rendered} />อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”snippet ที่ generate มาด้วย template expression ดูถูกต้องได้และยังผิดในแบบที่โผล่มาแค่ตอน runtime หรือ input เฉพาะ เช็คสิ่งเหล่านี้:
- มีค่าไหนถูกส่งตรงเข้า
set:htmlโดยไม่ผ่านการ sanitize บ้างไหม — comment body, username, อะไรก็ตามที่มาจาก request? พิสูจน์แล้วข้างบน:set:htmlrender argument ของมันตรงตัวเป๊ะ รวม XSS payload - Expression render object หรือ array ตรง ๆ ไหม (
{user}แทนที่จะเป็น{user.name})? มันไม่ error — render เป็น[object Object]เงียบ ๆ พิสูจน์แล้วข้างบน - มี
{โผล่ในเนื้อความธรรมดาไหม นอก code fence และไม่ได้ตั้งใจให้เป็น expression? มันจะถูก parse เป็นจุดเริ่ม expression (พิสูจน์แล้วข้างบนด้วย errorCannot find nameจริง) ไม่ใช่โชว์ตรงตัว classถูกเขียนเป็นclassNameหรือforเป็นhtmlForไหม — copy มาจากตัวอย่าง React โดยไม่แปลงเป็นชื่อ attribute แบบ HTML-native ของ Astro?- snippet สมมติไหมว่า
<span>/<em>(หรือ inline element คล้าย ๆ กัน) ที่อยู่คนละบรรทัดจะ render มีช่องว่างระหว่างกัน? ตั้งแต่ Astro 7compressHTML: 'jsx'แปลว่าจะไม่มี — พิสูจน์แล้วข้างบน — เว้นแต่จะใส่{" "}ชัด ๆ
🐛 หา bug ในโค้ดนี้
---const comment = Astro.props.comment; // arbitrary user-submitted text---<div class="comment-body" set:html={comment} />เฉลย
โค้ดนี้ดูเหมือนโค้ด “render field จาก CMS” ธรรมดา แต่ comment ในที่นี้ถูกอธิบายว่าเป็น text ที่ user ส่งมา — input ประเภทเดียวกับที่พิสูจน์แล้วข้างบนว่าผ่าน set:html โดยไม่ถูก escape เลย ถ้า comment มี <img src=x onerror=...> (หรือ <script> tag, หรือ attribute onclick) มันจะทำงานใน browser ของทุกคนที่เข้าดู ตรงตามที่พิสูจน์ไว้ก่อนหน้านี้ ทางแก้คือใช้ set:text={comment} (ถ้าไม่ควรมี markup เลย) หรือส่ง comment ผ่าน HTML sanitizer จริง ๆ (เช่น sanitize-html หรือ DOMPurify ฝั่ง server) ก่อนที่มันจะไปถึง set:html — อย่าเชื่อ string ดิบ ๆ ที่บอกว่า “user-submitted” ในจุดนี้เด็ดขาด
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Layer template ของ Astro คือกฎ expression ของ JSX ที่ยึดเข้ากับ HTML จริง: { } คือ “หย่อนเข้าไปใน JavaScript” ที่เหลือคือ HTML ตามที่ browser parse อยู่แล้ว นั่นแปลว่า compiler บังคับใช้ความถูกต้องของ HTML เอง (พิสูจน์แล้วข้างบน: tag ที่ไม่ปิดและซ้อนผิดตอนนี้เป็น build error ไม่ใช่ถูกแก้เงียบ ๆ) และแปลว่า whitespace ระหว่าง element เป็นไปตามกฎ collapse ของ JSX ไม่ใช่ raw-HTML set:html คือทางออกเดียวที่ตั้งใจหนีจาก “ทุกอย่าง escape เป็น default” — ปฏิบัติกับทุกค่าที่ไปถึงมันเหมือนกับว่ามันจะถูก execute โดย browser เพราะมันจะเป็นแบบนั้นจริง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Escape (default) | set:html |
|
|---|---|---|
| ความปลอดภัย | ปลอดภัย default — tag render เป็น text | ปลอดภัยเท่าที่ input ปลอดภัย — พิสูจน์แล้วข้างบนด้วย XSS payload จริง |
| ใช้กับ | ค่าไหนก็ตามที่คุณไม่ได้เขียน/ตรวจสอบเองว่าเป็น HTML | HTML ที่เชื่อถือได้ (field จาก CMS ที่ sanitize แล้ว, markup ที่คุณ generate เอง) |
| รับ | ค่าที่ stringify ได้ทุกแบบ | string, Promise<string> (await ให้อัตโนมัติ), หรือ Response (อ่าน body เป็น text) — ไม่ใช่ JSX element ตรง ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “object ที่หย่อนเข้า
{ }จะโชว์ error ให้เห็นความผิดพลาด” พิสูจน์แล้วข้างบน: มัน render เป็น[object Object]ตอน runtime ไม่มี build error เลย - “
set:htmlรับแค่ plain string” พิสูจน์แล้วข้างบน: มันรับPromise<string>และResponseด้วย แต่ไม่รับ JSX/Astro element ที่ส่งตรง ๆ (เป็น compile error จริงอีกแบบ) - “Newline ระหว่าง inline element ใน HTML render เป็นช่องว่างเสมอ เหมือนที่ browser ทำปกติ”
compressHTML: 'jsx'ที่เป็น default ของ Astro 7 collapse whitespace นั้น — พิสูจน์แล้วข้างบน<span>hello</span>\n<em>world</em>render “helloworld” ไม่ใช่ “hello world” - “
{โดด ๆ ในประโยคแค่ print ตรงตัว เหมือนที่ทำใน HTML ธรรมดา” พิสูจน์แล้วข้างบน: มันถูก parse เป็นจุดเริ่ม expression และ fail build เว้นแต่ content นั้นจะเป็น expression จริง ๆ (หรือ element ใช้is:raw)
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import SetHtmlDemo from './SetHtmlDemo.astro';
describe('set:html vs set:text', () => { it('set:html emits raw markup verbatim; set:text escapes the same input', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(SetHtmlDemo); expect(html).toContain("<img src=x onerror=alert('xss')>"); expect(html).toContain('<img src=x onerror=alert('xss')>'); });});