Compiler Output
The compiler’s job, in one sentence
Section titled “The compiler’s job, in one sentence”Astro’s compiler turns a .astro file into a plain JavaScript module that calls functions exported from astro/runtime/server/index.js. There is no framework runtime “interpreting” your component at render time — the interpretation already happened, once, in the compiler. The package that actually does this in Astro 7 is @astrojs/compiler-rs, a native Rust NAPI addon (transform()/parse()) — not the older, purely-WASM @astrojs/compiler package some tooling (and this course’s own in-browser playground below) still uses. Every claim in this lesson was produced by running transform() from the exact package this repo has installed (@astrojs/[email protected], resolved from tools/probe/node_modules) — this is the same compiler astro check and astro build actually run against your code.
A component with props, a scoped style, an expression, and a slot
Section titled “A component with props, a scoped style, an expression, and a slot”---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>Running the installed compiler’s transform() directly against that exact source (filename: 'src/components/Card.astro'):
import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>`;
test('transform(): props + scoped style + expression + slot', () => { const result = transform(source, { filename: 'src/components/Card.astro' });
expect(result.code).toContain('$$createComponent'); expect(result.code).toContain('$$maybeRenderHead'); expect(result.code).toContain('$$renderSlot'); expect(result.css[0]).toMatch(/\.card:where\(\.astro-[a-z0-9]+\)/); expect(result.code).toMatch(/class="card astro-[a-z0-9]+"/);});✓ transform(): props + scoped style + expression + slot
Test Files 1 passed (1) Tests 1 passed (1)The full, real compiled output for that source:
import { render as $$render, createAstro as $$createAstro, createComponent as $$createComponent, maybeRenderHead as $$maybeRenderHead, renderSlot as $$renderSlot } from "astro/runtime/server/index.js";import "src/components/Card.astro?astro&type=style&index=0&lang.css";const $$Astro = $$createAstro("https://astro.build");const Astro = $$Astro;const $$Card = $$createComponent(($$result, $$props, $$slots) => { const Astro = $$result.createAstro($$props, $$slots); Astro.self = $$Card; const { name } = Astro.props; return $$render`${$$maybeRenderHead($$result)}<div class="card astro-3dcq3mxs"> <h2 class="astro-3dcq3mxs">Hello ${name}</h2> ${$$renderSlot($$result, $$slots["default"])}</div>`;}, "src/components/Card.astro", undefined);export default $$Card;Every piece does real, specific work:
$$createComponent(renderFn, filename, moduleId)— wraps your render function into the actual component Astro’s runtime calls.$$Astro.selflets the component reference itself (used for recursive components).$$createAstro(site)— builds the shared$$Astroglobal your frontmatter’s bareAstroidentifier is bound to, before your ownconst { name } = Astro.propsline runs.$$maybeRenderHead($$result)— emits<head>content lazily, the first time any element renders — proven by its position: it’s inlined right before the first real markup, not at the top of the function.$$render/ a tagged template — the whole body is one tagged template literal; every${...}interpolation is either a plain value (escaped automatically by the runtime) or another renderer call ($$renderSlot,$$renderComponentfor child components).- The scoped-style hash (
astro-3dcq3mxs) — appended as a real CSS class on every element the<style>block’s selectors reach, and the same hash appears in the emitted CSS as a:where(.astro-3dcq3mxs)wrapper (:wherekeeps scoping from adding to the selector’s specificity — proven above:.card:where(.astro-3dcq3mxs) { color: red; }). $$renderSlot($$result, $$slots["default"])— a<slot />becomes a call that looks up the named slot ("default"here) on$$slots, passed in by whatever renders<Card>.
The scoped-style hash is derived from the filename — proof by renaming
Section titled “The scoped-style hash is derived from the filename — proof by renaming”Compiling the exact same source string under two different filenames produces two different hashes:
import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>`;
test('renaming the file changes the scoped-style hash, for identical source', () => { const original = transform(source, { filename: 'src/components/Card.astro' }); const renamed = transform(source, { filename: 'src/components/RenamedCard.astro' });
const hashOf = (code: string) => code.match(/astro-([a-z0-9]+)/)?.[1];
expect(hashOf(original.code)).toBe('3dcq3mxs'); expect(hashOf(renamed.code)).toBe('izcegd4j'); expect(hashOf(original.code)).not.toBe(hashOf(renamed.code));});✓ renaming the file changes the scoped-style hash, for identical source
Test Files 1 passed (1) Tests 1 passed (1)Real, captured values from this exact run: src/components/Card.astro → astro-3dcq3mxs; the identical source saved as RenamedCard.astro → astro-izcegd4j. This is real, useful information when reading a diff: a scoped-style hash changing in a PR that only renames a file (no content change) is expected, not a sign of a stray edit — and copy-pasting a component’s compiled output into a different file without recompiling would carry the wrong hash.
Dynamic attributes: $$addAttribute, and set:html vs. a plain expression
Section titled “Dynamic attributes: $$addAttribute, and set:html vs. a plain expression”---const { name, htmlContent } = Astro.props;const id = `card-${name}`;---<div id={id} class="card"> <p set:html={htmlContent} /> <p>{htmlContent}</p></div>import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---const { name, htmlContent } = Astro.props;const id = \`card-\${name}\`;---<div id={id} class="card"> <p set:html={htmlContent} /> <p>{htmlContent}</p></div>`;
test('transform(): a dynamic attribute becomes $$addAttribute; set:html becomes $$unescapeHTML, a plain expression does not', () => { const result = transform(source, { filename: 'src/components/AttrCard.astro' });
expect(result.code).toContain('$$addAttribute(id, "id")');
// set:html's paragraph goes through $$unescapeHTML exactly once... const unescapedCalls = result.code.match(/\$\$unescapeHTML\(htmlContent\)/g) ?? []; expect(unescapedCalls).toHaveLength(1);
// ...while the plain {htmlContent} expression on the next line stays a bare, // auto-escaped interpolation — no $$unescapeHTML wrapping it at all. expect(result.code).toContain('<p>${htmlContent}</p>');});✓ transform(): a dynamic attribute becomes $$addAttribute; set:html becomes $$unescapeHTML, a plain expression does not
Test Files 1 passed (1) Tests 1 passed (1)The real, full compiled output:
import { render as $$render, createAstro as $$createAstro, createComponent as $$createComponent, maybeRenderHead as $$maybeRenderHead, unescapeHTML as $$unescapeHTML, addAttribute as $$addAttribute } from "astro/runtime/server/index.js";const $$Astro = $$createAstro("https://astro.build");const Astro = $$Astro;const $$AttrCard = $$createComponent(($$result, $$props, $$slots) => { const Astro = $$result.createAstro($$props, $$slots); Astro.self = $$AttrCard; const { name, htmlContent } = Astro.props; const id = `card-${name}`; return $$render`${$$maybeRenderHead($$result)}<div${$$addAttribute(id, "id")} class="card"> <p>${$$unescapeHTML(htmlContent)}</p> <p>${htmlContent}</p></div>`;}, "src/components/AttrCard.astro", undefined);export default $$AttrCard;Two facts this proves directly:
id={id}— any attribute whose value is an expression (not a string literal) compiles to$$addAttribute(value, "name")— a runtime call, not string interpolation into the tag, because it also has to handle booleans (disabled={false}must omit the attribute, not renderdisabled="false") andclass:list-style objects.set:html={htmlContent}compiles to$$unescapeHTML(htmlContent)— the string is inserted as raw HTML, unescaped. The very next line, a plain{htmlContent}expression with no directive, interpolates the same value with no$$unescapeHTMLcall at all — the runtime’s tagged-template renderer HTML-escapes plain interpolations automatically. This is the entire difference between “renders as HTML” and “renders as text” in Astro: one directive, one specific compiled call.
Hydration directive metadata: client:load vs. client:only
Section titled “Hydration directive metadata: client:load vs. client:only”import { useState } from 'preact/hooks';
export default function Counter({ count }: { count: number }) { const [n, setN] = useState(count); return <button onClick={() => setN(n + 1)}>Count: {n}</button>;}---import Counter from '../components/Counter.tsx';---<Counter client:load count={5} />import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
test('transform(): client:load renders the component AND emits hydration metadata', () => { const source = `---import Counter from '../components/InternalsCounter.jsx';---<Counter client:load count={5} />`; const result = transform(source, { filename: 'src/pages/island-demo.astro', resolvePath: (s) => s });
expect(result.code).toContain('"client:load": true'); expect(result.code).toContain('"client:component-hydration": "load"'); expect(result.code).toContain('"client:component-path": "../components/InternalsCounter.jsx"'); expect(result.code).toContain('"client:component-export": "default"'); // The component itself IS passed to $$renderComponent (server-rendered, then hydrated): expect(result.code).toMatch(/\$\$renderComponent\(\$\$result, "Counter", Counter, \{/); expect(result.hydratedComponents).toHaveLength(1); expect(result.hydratedComponents[0].specifier).toBe('../components/InternalsCounter.jsx');});
test('transform(): client:only passes null instead of the component — no server render at all', () => { const source = `---import Counter from '../components/InternalsCounter.jsx';---<Counter client:only="preact" count={5} />`; const result = transform(source, { filename: 'src/pages/island-only-demo.astro', resolvePath: (s) => s });
expect(result.code).toContain('"client:only": "preact"'); expect(result.code).toContain('"client:component-hydration": "only"'); // The THIRD argument to $$renderComponent (the component reference) is null: expect(result.code).toMatch(/\$\$renderComponent\(\$\$result, "Counter", null, \{/); expect(result.clientOnlyComponents).toHaveLength(1);});✓ transform(): client:load renders the component AND emits hydration metadata✓ transform(): client:only passes null instead of the component — no server render at all
Test Files 1 passed (1) Tests 2 passed (2)The real compiled output for client:load:
import { render as $$render, createComponent as $$createComponent, renderComponent as $$renderComponent } from "astro/runtime/server/index.js";import Counter from "../components/InternalsCounter.jsx";const $$IslandDemo = $$createComponent(($$result, $$props, $$slots) => { return $$render`${$$renderComponent($$result, "Counter", Counter, { "client:load": true, "count": 5, "client:component-hydration": "load", "client:component-path": "../components/InternalsCounter.jsx", "client:component-export": "default" })}`;}, "src/pages/island-demo.astro", undefined);export default $$IslandDemo;…and for client:only="preact" — note the third argument to $$renderComponent is null, not the imported Counter:
$$render`${$$renderComponent($$result, "Counter", null, { "client:only": "preact", "count": 5, "client:component-hydration": "only", "client:component-path": "../components/InternalsCounter.jsx", "client:component-export": "default"})}`;That null is the compiled proof behind “client:only skips server rendering entirely” (see Client Directives): the runtime’s renderComponent function never has an actual component reference to call on the server for a client:only island — only client:component-path, which the browser will import() later. Island Hydration picks up from here, at the exact moment this metadata becomes the real <astro-island> element’s attributes in the rendered HTML.
Try it yourself
Section titled “Try it yourself”---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>This playground compiles the same source shown above — but through @astrojs/compiler (the classic, browser-loadable WASM package), not @astrojs/compiler-rs (the native Rust compiler this lesson’s transform() tests actually ran against). The two agree in shape — both produce a $$createComponent/$$render body with a scoped astro-* class — but are genuinely different packages, and the exact runtime import path and internal helper names can differ between them. Trust the tested transform() output above as what astro check/astro build really run; use this playground to explore, not as a second source of truth.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does an explanation claim Astro “interprets”
.astrofiles at request time, the way a template engine does? There is no interpretation step at runtime —transform()already produced plain JavaScript calls, proven throughout this lesson; a request just calls the resulting function. - Does a diff show a scoped-style hash changing with no visible reason? Proven above: the hash is a function of the file’s path, not (only) its content — a rename, a move, or copy-pasting a component into a new file location all change it, and that’s expected, not a sign of drift.
- Is
set:htmlused on a value that isn’t already trusted, sanitized HTML? Proven above: it compiles to$$unescapeHTML, which skips the runtime’s automatic escaping entirely — the exact same mechanism a plain{expression}uses to stay safe. - Does a comment assume a
client:onlycomponent is “rendered on the server, then hydrated”? Proven above: the compiled call passesnullwhere the component reference would be — there is no server render to speak of for aclient:onlyisland.
🐛 Spot the bug
---const { userComment } = Astro.props;---<!-- "using set:html so line breaks in the user's comment render properly" --><p set:html={userComment} />Show the answer
The comment’s intent (preserve line breaks) doesn’t require set:html at all, and using it here is a real, serious bug: set:html compiles to $$unescapeHTML(userComment), proven above to skip the runtime’s automatic HTML escaping entirely. If userComment is untrusted user input, this is a stored XSS vulnerability — any <script> or onerror handler a visitor types into that field is inserted into the page verbatim and executes for every other viewer. The fix is to keep the plain expression (<p>{userComment}</p>, which the compiled output above proves stays escaped) and handle line breaks with CSS (white-space: pre-wrap) or by rendering each line inside its own element — never by turning off escaping for untrusted text.
Mental Model
Section titled “Mental Model”The compiler is a one-time translator, not a runtime interpreter: every prop read, every scoped style, every {expression}, every directive (set:html, client:load, client:only) becomes a specific, plain JavaScript call against astro/runtime/server — decided once, by walking the file, never re-decided at render time. Reading compiled output is reading the actual program that runs; there is no second, hidden layer underneath it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Native Rust compiler (@astrojs/compiler-rs) instead of the WASM one |
Faster astro check/astro build; no WASM instantiation overhead |
No browser build exists — a genuinely in-browser Astro playground (this lesson’s <AstroPlayground> included) has to fall back to the older WASM package, which can disagree in small ways |
| Filename-derived scoped-style hash | Deterministic without a build-wide counter — two builds of the same file always agree | Renaming or moving a file changes every one of its scoped classes, which shows up as a large-looking diff in generated HTML/CSS for what is otherwise a no-op change |
Automatic escaping of plain {expression} interpolations |
Safe by default — untrusted text can’t inject markup unless a directive explicitly opts out | The opt-out (set:html) looks like a small, one-word difference from the safe default, which is exactly what makes it easy to reach for by habit |
client:only passing null for the component reference |
No wasted server-side render work for a component that can’t run there anyway | The page’s initial HTML has no fallback content for that slot at all — a slow network means a real empty gap until the client-only bundle loads |
Common Misconceptions
Section titled “Common Misconceptions”- “
.astrofiles are interpreted by a runtime, like a template engine reads.ejs/.hbsfiles.” Proven false throughout this lesson:transform()produces a complete, plain JavaScript module ahead of time. Astro’s “runtime” (astro/runtime/server) is just the small set of helper functions ($$createComponent,$$render,$$addAttribute, …) that compiled output calls into — there is no template re-parsing per request. - “A scoped style hash changing between two versions of a file means something about the CSS changed.” Proven false above: renaming a file with byte-identical content and CSS produces a different hash. The hash is path-derived, not content-derived.
- “
{expression}andset:html={expression}are the same thing, just different syntax.” Proven false above: one compiles to a plain, auto-escaped interpolation; the other compiles to$$unescapeHTML, a specific call that skips escaping entirely. Using the wrong one on untrusted input is a real XSS vector, not a style preference.