Skip to content
cccskills
FreePublished on GitHub

Macro Code gen

How compiler macro plugins (html_cbi, css_cbi, js_cbi, universal_cbi, json_cbi, md_cbi) turn Chemical source into generated code — page-buffer emission, SSR server functions, hydration dispatches, styled components, and the ASTBuilder API they use.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31 stars(GitHub 31 + cccskills 0)

Run score Not tested

See how to install

Files included (1)

  • SKILL.md39.2 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

Macros are CBI plugins compiled by TinyCC at build time. They parse their block (#html { ... }, #universal Name(props) { ... }, #styled Name("div") { ... }) into an AST, then generate real Chemical AST nodes (function calls, statements) that reference HtmlPage methods and SSR helpers from lang/libs/page.

For the CBI registration/API mechanics, load cbi_plugin_api; for the AST nodes the plugins build, load compiler_api; for the full SSR/hydration behaviour see universal; for the app-level authoring guide see design_web_app.

Plugin / macro map (verified from lang/libs/*/build.lab)

MacroPluginRegistrationParse hook typeNotes
#htmlhtml_cbihtml_cbi/build.lab:21-92ParseMacroNode, ParseMacroValueJSX/HTML → page.append_html*
#csscss_cbicss_cbi/build.lab:21-89ParseMacroNode, ParseMacroValueCSS → page.append_css*, returns class name; always page-local
#globalcsscss_cbi (extra CBI name)css_cbi/build.lab:91-94ParseMacroNodeApp-wide global CSS; shared sink first, page fallback
#styledcss_cbi (second CBI name)css_cbi/build.lab:96-103ParseMacroTopLevelNodeSSR-only scoped component
#jsjs_cbijs_cbi/build.lab:91-97ParseMacroNode, ParseMacroValueJS → page.append_js*; always page-local
#globaljsjs_cbi (extra CBI name)js_cbi/build.lab:99-103ParseMacroNodeApp-wide global JS; shared sink first, page fallback, once per source location
#universaluniversal_cbiuniversal_cbi/build.lab:23-110ParseMacroTopLevelNodeSSR fn + client JS + hydration; style { } = component-scoped CSS
#json(Type)json_cbijson_cbi/build.lab:18-60ParseMacroTopLevelNodeGenerates std::Serializer/Deserializer impls
#mdmd_cbimd_cbi/build.lab:20-85ParseMacroNode, ParseMacroValueMarkdown → page.append_html*
#universal_test("name")html_cbi (second macro name)html_cbi/build.labParseMacroTopLevelNode, ParseMacroNodeComponent test: SSR fixture + raw <script> steps; see universal_testing

MountStrategy (lang/libs/html_comp/ast.ch:6-13) still declares Preact, React, Solid enum values, but no plugin registers #preact, #react, or #solid — only Universal and Styled are live. React/Preact/Solid bridges were removed (confirmed by grepping lang/libs/*/build.lab for those CBI names — no matches). MountStrategy.Preact/React/Solid are dead enum slots.

How a macro plugin is wired (build.lab → CBI hooks)

Every plugin build.lab exposes build(ctx : *mut BuildContext, user_job : *mut LabJob) : *mut Module and does the same steps (html_cbi/build.lab:13-95, universal_cbi/build.lab:15-113):

  1. Cache check + idempotency: ctx.get_cached/ctx.set_cached, then ctx.contains_cbi/ctx.set_contains_cbi keyed by the CBI name (html_cbi/build.lab:15-25; universal_cbi/build.lab:17-27). Note css_cbi registers two CBI names ("css" and "styled") from one build script (css_cbi/build.lab:21, 92).
  2. Create the job and order it before user code: var cbi = ctx.build_cbi(&name); ctx.put_job_before(cbi, user_job) (html_cbi/build.lab:27-31).
  3. Build dependencies (cstd, std, compiler bindings, shared parsers) and add the module: ctx.add_module(cbi, module) (universal_cbi/build.lab:92-96).
  4. Index each entrypoint with its CBIFunctionType via ctx.index_def_cbi_fn(...) or ctx.index_cbi_fn(cbi, <name>, <entrypoint>, <type>) (html_cbi/build.lab:80-86).

Real registrations:

  • html_cbi: html_initializeLexer, html_parseMacroValue, html_parseMacroNode, html_symResNode, html_symResValue, html_replacementNode, html_replacementValue (html_cbi/build.lab:80-86).
  • css_cbi (css name): same seven (css_cbi/build.lab:83-89); styled name: styled_parseMacroNode → ParseMacroTopLevelNode, styled_symResDeclareNode → SymResDeclareTopLevelNode, styled_symResSigNode → SymResLinkSignatureNode, styled_symResNode, styled_replacementNodeDeclare, styled_replacementNode (css_cbi/build.lab:92-98).
  • js_cbi: seven js_* entries (js_cbi/build.lab:91-97).
  • universal_cbi: universal_parseMacroNode → ParseMacroTopLevelNode, universal_symResDeclareNode, universal_symResSigNode, universal_symResNode, universal_replacementNodeDeclare, universal_replacementNode (universal_cbi/build.lab:98-104).
  • json_cbi: same six top-level hooks (json_cbi/build.lab:53-58).
  • md_cbi: seven md_* entries (md_cbi/build.lab:73-79).

css_cbi, js_cbi, universal_cbi, and json_cbi also create the marker annotation "component" / "serializable" on the annotation controller (css_cbi/build.lab:29-30, js_cbi/build.lab:29-30, universal_cbi/build.lab:30-31, json_cbi/build.lab:24-25) so #html can recognise declarations as components.

The hook lifecycle for a block macro (#html)

  • Lexer — html_initializeLexer allocates an HtmlLexer and installs it via lexer.setUserLexer(ptr, getNextToken ...) (html_cbi/src/main.ch:162-181). getNextToken (:105-160) switches between HTML text/tags and Chemical ${...}/{...} regions (other_mode, chemical_mode, in_paren_expr, expecting_html_block).
  • Parse — html_parseMacroNode consumes {, calls parseHtmlRoot(parser, builder), wraps the result in an EmbeddedNode with builder.make_embedded_node(...), then consumes } (html_cbi/src/main.ch:87-103). The value form uses builder.make_embedded_value(...) (:69-85).
  • SymRes — html_symResNode calls visitor.visitEmbeddedNode(node) then sym_res_root(root, visitor, loc) (html_cbi/src/main.ch:1-8); sym_res_root (html_cbi/src/sym_res/sym_res_root.ch:1-342) resolves every HtmlPage method and SSR helper and emits compile errors when one is missing (e.g. append_html_char, truncate_html, get_html_size).
  • Replacement — html_replacementNode builds a Scope, runs ASTConverter.convertHtmlRoot into it, and returns it (html_cbi/src/main.ch:10-27). The value form returns a block value whose calculated value is the converter's string (:47-67).

#universal / #styled / #json use the top-level hook set instead: declare (..._symResDeclareNode), link signature (..._symResSigNode), link body (..._symResNode), replacement declare (..._replacementNodeDeclare), and replacement (..._replacementNode). The replacement is split in two so the generated FunctionDeclaration is visible to cross-module symbol resolution before its body is built.

#html blocks (html_cbi)

func my_html(page : &mut HtmlPage) {
    #html {
        <span>Hello World</span>
    }
}

generates approximately:

func my_html(page : &mut HtmlPage) {
    page.append_html_view("<span>Hello World</span>")
}

The conceptual append_html_view(name) above is shorthand. The real HtmlPage methods are append_html(value : *char, len : size_t) and append_head(value, len) (lang/libs/page/src/page.ch:92-94, 128-130). There is no page.append_view; the closest helpers are append_head_view and append_css_view (page.ch:132-134, 164-166). The 2c converter buffers static markup and flushes it through emit_append_html_call → page.append_html(stringValue, size) (html_cbi/src/converter/language/main.ch:214-231, 233-241).

Chemical values inside {...} become interleaved append calls:

func my_html(page : &mut HtmlPage, name : &std::string_view) {
    #html { <span>Hello {name}</span> }
}
// generates:
func my_html(page : &mut HtmlPage, name : &std::string_view) {
    page.append_html_view("<span>Hello ")
    name.writeToPageBody(page)   // HtmlPageWriter impl, lang/libs/page/src/PageWriter.ch
    page.append_html_view("</span>")
}

The value path in source: inside #html, {name} is a ChemicalAttributeValue/child; the converter calls put_chemical_value_in → put_by_type → either a primitive append_html_* call or value.writeToPageHtml(page, pageHtml) for structs/aliases (html_cbi/src/converter/language/embedded_value.ch:109-172; put_by_node at :41-68).

Key facts:

  • #html does NOT auto-escape interpolated values — callers escape untrusted data with escape_html / escape_html_view (lang/libs/page/src/page.ch:7-38). Literal HTML text is escaped by the converter via converter.escapeHtml → html_escape_append (html_cbi/src/converter/language/main.ch:208-210; attribute literals at attribute.ch:12-17).
  • An element opened in one #html block must be closed in the same block (no splitting).
  • Inside JSX/HTML mode, @{ ... } escapes back into Chemical for statements (loops, ifs); use nested #html { } blocks inside the escape to emit elements. Increment loop indices before the nested block; each @{} must be self-contained. In #html, {expr} is a Chemical expression; in #universal, {expr} is JS and ${expr} is Chemical.
  • Conditionals/statements inside @{} are lowered to real AST if/else nodes by convertHtmlChild (html_cbi/src/converter/language/main.ch:651-708), which builds builder.make_if_stmt(...) and recurses into each branch's converter.vec.
  • Universal components used inside #html are handled specially (see below), including @{} loop rendering of component lists.
  • Static sibling text/attributes are HTML-entity-decoded before being re-emitted as client vnodes (decode_html_entities, main.ch:75-95) so server text and client vnodes agree.

#css blocks (css_cbi)

#css { ... } parses CSS at compile time, injects the rules into pageCss, and evaluates to the generated class name string (usable as a class value):

func style_button(page : &mut HtmlPage) : *char {
    return #css {
        color : blue;
        padding : 8px;
        .my-global-button { color : red; }
    }
}
  • Parse: css_parseMacroValue / css_parseMacroNode wrap parseCSSOM in an EmbeddedValue/EmbeddedNode (css_cbi/src/main.ch:65-99).
  • Replacement value returns root.className as the block value (css_cbi/src/main.ch:45-63); the node form runs ASTConverter.convertCSSOM (css_cbi/src/main.ch:10-25).
  • Emission: loop-free static CSS is hashed with fnv1a_hash_32(str), guarded by page.require_css_hash(hash), marked with page.set_css_hash(hash), and emitted once via page.append_css(...) (css_cbi/src/converter/string/main.ch:459-476).
  • Non-hashable CSS (dynamic ${} values, media queries, nested rules, keyframes) uses the require_random_css_hash/set_random_css_hash path with a deterministic seed derived from the CSS content or the source location (main.ch:378-441, cssom_stable_hash at :213-222).
  • Class-name format: . + prefix + 6 base64 chars + {rules}; prefix is h for the hashed path (put_class_name, main.ch:182-186; allocate_view_with_classname, :188-196) and r for the non-hashable path (main.ch:413-419). om.className is the 7-char name without the leading dot (main.ch:418-419, 471).

#globalcss blocks (css_cbi)

#globalcss { ... } is the statement-only sibling of #css, registered as a separate macro key (globalcss, css_cbi/build.lab:91-94) that reuses css_initializeLexer + globalcss_parseMacroNode. It parses the same CSSOM but sets root.shared = true before building the css embedded node (css_cbi/src/main.ch):

func GlobalStyles(page : &mut HtmlPage) {
    #globalcss {
        body { margin: 0; }
        .app-header { position: fixed; top: 0; }
    }
}
  • With a shared assets sink attached, append_css* routes to the sink (shared != null && local_css_depth == 0); without a sink it falls back to pageCss. De-duplication uses require_css_hash (repeating it across pages writes it once).
  • #css differs only in shared = false: its emission is wrapped in page.begin_local_css() / end_local_css(), so it always lands on the page (css_cbi/src/main.ch::css_replacementNode / css_replacementValue). The same applies to #js (begin_local_js / end_local_js).

style { } blocks (universal_cbi) — component-scoped CSS

Inside a #universal body, style { ... } is a JS expression parsed by the CSS parser and evaluated to a compile-time class-name string (usable in class={...}):

#universal Badge(props) {
    var badge = style { color: red; &:hover { color: blue; } }
    return <span class={badge}>{props.children}</span>
}
  • Lexing: the hybrid JS lexer detects the style keyword and hands the {...} body to the CSS lexer (universal_cbi/src/main.ch:9-73, universal_style_css_next).
  • Parse: universal_parse_style_block (universal_cbi/src/main.ch:75-101) builds a css embedded value wrapped in a JsChemicalValue; wired as JsParser.style_fn in react/macro.ch.
  • Emission: identical to #css (same CSSOM, hash/dedup paths), but with CSSOM.shared = true so it follows the component into the shared sink (universal_cbi/src/main.ch:88).
  • The returned class is a deterministic constant; multiple blocks per component and repeated instances emit each rule once (lang/tests/compiler_plugins/universal/src/style_block.ch).

#styled components (css_cbi, "styled" CBI)

#styled Card("div") {
    background: #ffffff;
    border: 1px solid #cccccc;
    padding: 8px;
}

#styled declares a reusable, SSR-only styled component whose CSS is scoped to a compiler-generated hash class and injected into the page automatically — no manual #css + class={...} wiring, and no hydration.

Syntax variants (parsed in css_cbi/src/styled.ch:52-166):

  • #styled Name("div") { ... } — tag as a string literal (:69-80).
  • #styled Name(.div) { ... } — shorthand dot-tag form via the CSS lexer's ClassName token (:94-97).
  • #styled Wrap(Inner) { ... } — wrap mode: forwards to the inner component Inner; css_cbi merges its generated class with Inner's onto the rendered element (:81-85, :274-307, :381-383, :390-396). When Inner is a universal component, signature.hydrateFunctionNode points at it and mountStrategy becomes Universal, so html_cbi's hydration dispatch targets the inner component (the styled wrapper is SSR-only).

Usage:

#html {
    <Card class="xl">Hello <Title>World</Title></Card>
}

renders <div class="hAz5DrX xl">Hello ...</div> (base hash class first, user classes merged after — renderHtmlAttrsWithBase in lang/libs/page/src/ssr.ch:568-608) and emits .hAz5DrX{...} into the page CSS once (require_css_hash/set_css_hash).

Key properties:

  • The generated class is a content hash of the CSS — stable/deterministic across renders.
  • Works in #html blocks and is usable cross-module (import the declaring module). The "component" marker annotation makes it resolvable as <Name> in both #html and #universal (styled.ch:157-163).
  • On the SSR side it is just a call to the generated server function with the attribute list and pre-rendered children (same (page, attrs, children) shape as universal servers).
  • Generated body (css_cbi/src/styled.ch:359-415): emit CSS via convertCSSOM, then page.append_html("<"), page.append_html(tag), renderHtmlAttrsWithBase(page, attrs, baseClass), page.append_html(">"), page.append_html(children.data, children.size), page.append_html("</tag>"). Wrap mode instead emits a single call to the inner component with (page, attrs, children).

Style generation / scoping summary

  • Class name = deterministic FNV-1a hash of the serialized CSS, rendered through base64_encode_32bit (css_cbi/src/converter/string/main.ch:176-196).
  • Scoping is by emitting selectors prefixed with the generated class: the root selector is . + className, nested rules are prefixed via generate_css_recurse (main.ch:251-311), and & is substituted with the parent selector.
  • Dedup uses doneClasses (require_css_hash/set_css_hash) and, for dynamic CSS, doneRandomClasses (require_random_css_hash/set_random_css_hash) — both unordered_map fields on HtmlPage (page.ch:63-70, 168-190).
  • Dynamic values inside CSS are emitted through CssEmitter.emit_chemical_value → put_chemical_value_in, which writes an append_css_char_ptr call (css_cbi/src/converter/string/main.ch:25-29, 144-162).

#js blocks (js_cbi)

#js { ... } parses JavaScript (with JSX disabled) at compile time and inlines it into pageJs. ${...} inside the block inserts a Chemical value into the JS stream.

#js {
    const btn = document.getElementById("clickable-btn")
    btn.onclick = () => { console.log("clicked") }
}
  • Lexer: js_initializeLexer installs getNextToken which calls nextJsToken(js, lexer, false) (jsx disabled) (js_cbi/src/main.ch:105-114).
  • Parse: js_parseMacroValue/js_parseMacroNode call parseJsRoot and wrap it (js_cbi/src/main.ch:68-103).
  • Conversion: JsConverter implements JsNodeEmitter (js_cbi/src/converter/converter.ch:244-268); emit_text appends to its string buffer, flush/put_chain_in emits a page.append_js(value, len) call (:139-165), and emit_chemical_value → put_chemical_value_in → put_by_type emits page.append_js_char_ptr / append_js_integer / append_js_uinteger / append_js_float / append_js_double / append_js_char based on type (:86-234).
  • Backtick template strings are emitted back with escaping; escapeJs escapes the backtick and non-ASCII (as \u{...}) (js_cbi/src/converter/converter.ch:29-75).
  • js_cbi also uses the shared statement/expression converter lang/libs/js_parser/src/converter/convert.ch — the canonical JS printer.
  • #js is always page-local: js_replacementNode / js_replacementValue bracket the emission with page.begin_local_js() / page.end_local_js() (js_cbi/src/main.ch), so an attached shared sink is never touched. See #globaljs below for the shared route.

#globaljs blocks (js_cbi)

#globaljs { ... } is the statement-only JS sibling of #globalcss: it reuses the js lexer and the js embedded node, but sets JsRoot.shared = true (js_cbi/src/main.ch::globaljs_parseMacroNode; registered at js_cbi/build.lab:99-103). Its JS prefers the shared assets sink, falling back to the page.

func SetupThemeScript(page : &mut HtmlPage) {
    #globaljs {
        document.documentElement.dataset.theme =
            localStorage.getItem('theme') || 'dark';
    }
}
  • The replacement wraps the block in if(page.require_js_hash(loc)) { page.set_js_hash(loc); <emit> }, where loc is the macro's source location (js_cbi/src/main.ch::js_replacementNode). Because require_js_hash/set_js_hash consult the sink's done_js map when attached (page/src/shared_assets.ch, page/src/page.ch), the block is emitted exactly once across pages sharing a sink.
  • No begin_local_js bracket is opened, so append_js* routes to the sink (shared != null && local_js_depth == 0); without a sink it lands on the page.
  • Dedup keys on the source location, not content: identical blocks written in different places both emit, and a block must be page-independent (no per-page ${} values) since only the first render of a call site is kept.
  • js_replacementValue is unchanged (#globaljs is not a value macro); the value path is #js only.
  • Tests: lang/tests/compiler_plugins/universal/src/css_routing.ch.

#universal components (universal_cbi)

#universal Greeting(props) {
    return <div>Hello {props.name}</div>
}

The macro (universal_cbi/src/react/macro.ch:16-124) parses an optional typed/optional param list (#universal Card(props : title, onClick, isWide?)) into a JsComponentDecl embedded node and marks it with the "component" annotation (:107-118). { ... } here is JS/JSX (jsParser.parseBlock, :99-101); ${...} is Chemical.

During symres (universal_cbi/src/sym_res/sym_res.ch:7-73) universal_cbi generates a server function:

func <module>_<Name>(page : &mut HtmlPage, attrs : *SsrAttributeList, children : SsrText) : void

universal_symResNode resolves HtmlPage (:15-18), builds the void function and its three parameters (:24-51), opens a scope, declares page, visits the body, and resolves child components + required props (:59-71; prop/component validation in universal_cbi/src/sym_res/sym_res_root.ch:1-101).

The server function body (universal_cbi/src/react/ast_replace.ch:29-197) does, in order:

  1. if(page.require_component(hash)) { page.set_component_hash(hash); ... } — appends the client component JS function <module>_<Name>(props) { ... } to pageJs once per page (hash = the component function's encoded location; selfHash at :74). The JS block is hoisted above earlier dispatch lines with page.get_js_pos() / page.move_js_range(...) / page.js_hoist_pos (:80-161) so component functions are defined before use. JS emission itself is append_universal_component_js (universal_cbi/src/react/emit_js.ch:1-27). When the page has an attached shared sink (page.attach_shared(shared)), the definition, the runtime, and the registry are routed into SharedAssets instead (deduped across pages); see the universal skill "Shared JS/CSS bundles across pages".
  2. Inside if(page.render_js_only) { ... } else { ... } (:172-188): the else branch emits SSR HTML to pageHtml by converting the component body with target = BufferType.HTML (:177-186). render_js_only prevents a nested component's subtree from being server-rendered twice (page.ch:55-61).
  3. Non-SSRable attributes (event handlers, refs) are skipped in HTML and passed through the hydration props instead.

SSR attribute/child model: the server function receives attrs : *SsrAttributeList and children : SsrText. Attributes are built as SsrAttribute/SsrText structs and rendered by renderHtmlAttrs/renderHtmlAttrsWithBase, which merge class, join style, dedupe other attrs last-wins, skip false booleans and None, and HTML-escape Text/PtrChar (lang/libs/page/src/ssr.ch:478-608). Children are captured at the call site (see below).

When a #universal component is used inside #html, html_cbi renders:

<span id="u{loc}" data-chx-i>...SSR HTML...</span>

plus a hydration trigger in pageJs:

window.$__uni_dispatch('module_ComponentName', document.getElementById('u{loc}'), {props})

data-chx-i spans are display:contents (added by page.defaultUniversalSetup(), page.ch:457-461) so the boundary never affects layout. In table structure elements (table/thead/tbody/tr/…), a comment boundary <!--u{loc}--> is used instead and the dispatch calls window.$__uni_boundary(...) with mode "root" because a <span> would be foster-parented (html_cbi/src/converter/language/component.ch:290-332; main.ch:101-109, 424-547). $__uni_dispatch mounts immediately if the component function exists, otherwise queues it (window.$__uni_hydration_queue) until window.$__universal_flush() at the end of the bundle (page.ch:466-520).

Nested components (inside a #universal body) emit $_uc_c(ComponentFn, props) and hydrate the server-rendered DOM in place — the client component vnode references the function directly (universal_cbi/src/converter/converter_jsx.ch:235-302). The legacy capture_html_delta_to_js / $_uc_h(html, {name, props}) path was removed (see the universal skill); do not emit it from new code.

#json(Type) (json_cbi)

#json(Type) is a top-level macro that auto-generates std::Serializer and std::Deserializer impls for Type by inspecting its fields.

#json(User)

public struct User {
    var name : std::string
    var age : int
}
  • Parse: json_parseMacroTopLevelNode reads #json (, a namespaced type path (a::b::Type), and ); stores it in a SerializableInfo on the AST arena and wraps it in a top-level EmbeddedNode (json_cbi/src/main.ch:3-80).
  • SymRes: resolve_types resolves std::Serializer, std::Deserializer, std::Result, std::Result.Ok/Err, std::Unit, std::SerializationError (json_cbi/src/main.ch:96-120).
  • Replacement: build_serialize_fn generates func serialize(&self, encoder : &JsonEncoder) : Result<Unit, SerializationError> and build_deserialize_fn generates the counterpart (json_cbi/src/main.ch:335-501, 503-…). Field types with no serializer/deserializer are rejected at the #json site with a diagnostic; variant-typed fields are explicitly unsupported (main.ch:241-289).
  • The generated impls plug into the json runtime (JsonEncoder/JsonDecoder, TypeDecoder<T>); see the json_serialization skill for the full pipeline.

#md (md_cbi)

#md { ... } (terminated by #endmd, no closing brace) parses Markdown at compile time and emits HTML into pageHtml.

  • Parse: md_parseMacroNode/md_parseMacroValue call parseMdRoot; the value form consumes the #endmd token (md_cbi/src/main.ch:15-37).
  • Conversion: MdConverter implements MdEmitter; flush emits page.append_html(value, len), and emit_interpolation maps the value's type to append_html_integer/uinteger/float/double/char_ptr (md_cbi/src/converter/converter.ch:11-45, 50-72).

Generated-code model in detail

page.* runtime API emitted by the converters

  • HTML / head: append_html(value : *char, len) and append_head(value, len) — real methods, there is no page.append_view or append_html_view (page.ch:92-94, 128-130). #universal/#styled render SSR markup with the append_html_* family (flushed by emit_append_html_call, main.ch:214-241).
  • CSS: append_css, append_css_view, append_css_char_ptr, append_css_* (page.ch:160-214); guarded by require_css_hash/set_css_hash and require_random_css_hash/set_random_css_hash (page.ch:168-190).
  • JS: append_js, append_js_char_ptr, append_js_escaped, append_js_* (page.ch:216-255).
  • Buffer surgery: get_html_size() / truncate_html(n) (page.ch:120-126) and get_js_pos() / move_js_range(a,b,i) (memmove hoist, page.ch:257-…).
  • Component dedup: require_component(hash) / set_component_hash(hash) (page.ch:176-182) and the render_js_only field (page.ch:55-61).

capture_html_delta_to_js, truncate_html, get_html_size

The current child-capture protocol is get_html_size + truncate_html:

  1. Record startIdx = page.get_html_size().
  2. Render the children (they append to pageHtml).
  3. Copy the new slice out with page.pageHtml.data + startIdx / page.pageHtml.size - startIdx into a std::string via append_with_len, then page.truncate_html(startIdx) to remove it from the page buffer.
  4. Wrap the slice as an SsrText { data, size } and pass it as the 3rd argument to the child server function.

Source: html_cbi/src/converter/language/component.ch:133-235 (build_ssr_children) and the mirrored universal path in universal_cbi/src/converter/converter_jsx.ch:180-232. This is used for #styled/#universal children, which are pre-rendered before the parent's HTML is emitted.

Legacy note (preserved for traceability). Earlier builds used a helper named capture_html_delta_to_js (and move_html_to_js_with_lambda_start) that moved the SSR HTML delta into the JS bundle (paired with $_uc_h(html, name, props)). That mechanism was removed; the live path is get_html_size/truncate_html for SSR children and $_uc_c for nested client vnodes. truncate_html still exists and is tested in lang/tests/compiler_plugins/universal/src/page_buffer_api.ch:297-322.

SSR function generation & hydration dispatch

  • SSR function: one per #universal/#styled component, shaped (page : &mut HtmlPage, attrs : *SsrAttributeList, children : SsrText) : void (sym_res.ch:24-51; styled.ch:195-225).
  • Hydration boundary: #html wraps the SSR call for a universal component in <span id="u{loc}" data-chx-i>…</span> (component.ch:320-332) or a comment boundary in table context (:311-318). The loc comes from element.loc.
  • Dispatch: window.$__uni_dispatch('<scopedName>', <target>, {props} [, "root"]) is built in emit_universal_queue (html_cbi/src/converter/language/main.ch:424-547). target is document.getElementById('u{loc}') or window.$__uni_boundary('u{loc}'); "root" mode is used for the comment boundary.
  • Scoped component names: get_module_scoped_name(functionNode, name, out) prefixes <scope>_<module>_ when the declaration has a module scope (lang/libs/html_comp/ast.ch:33-47). So <Greeting> in module app dispatches 'app_Greeting' (with an extra scope_ prefix for nested scopes). The same helper is used for client vnodes and the function <name>(props) definition, so the two always agree (html_cbi/src/converter/language/main.ch:376, 439; universal_cbi/src/react/emit_js.ch:9-14).
  • Mount modes: default "children" hydrates into the target's children; "root" treats the target element itself as the SSR root (page.ch:475-499).
  • Client vnodes: static children are emitted as $_ur.createElement(tag, props, ...children) or $_uc_c(ComponentFn, props) when the child is itself a component (html_cbi/src/converter/language/main.ch:354-422); dynamic children fall back to a window.$__uni_html("<html>", count) blob (:507-537). The runtime vnode factory is window.$_ur.createElement built by defaultUniversalSetup (page.ch:543-546).

Buffer → output files

HtmlPage fields map to output assets as documented in the universal skill. The finalization entry points are getFinalizedPageJs = pageJs + pageJsEnd (page.ch:2196-2202); htmlPageToString, which references assets via <link>/<script src> (page.ch:2205-2232); and writeToDirectory, which writes .html, .css, _head.js, .js (page.ch:2240-2281).

ASTBuilder API calls the plugins use

Plugins obtain a builder from the hook (parser/builder args, or visitor.getSymbolResolver().getJobBuilder()), then construct nodes. Verified call sites:

Values / identifiers

  • builder.make_identifier(name, linkedNode, isMut, loc) — component.ch:9-11; ast_replace.ch:9; js_embedded_value.ch:7.
  • builder.make_access_chain(std::span<*mut Value>([base, member]), loc) — component.ch:11; ast_replace.ch:11.
  • builder.make_function_call_value(chain, loc) / builder.make_function_call_node(chain, parent, loc) — component.ch:256; js_embedded_value.ch:10.
  • builder.make_string_value(&view, loc), make_ubigint_value, make_char_value, make_bool_value, make_null_value, make_int_value — main.ch:85-87; component.ch:44, 78; js_embedded_value.ch:98.
  • builder.make_expression_value(lhs, rhs, Operation.Addition, type, loc) — ast_replace.ch:128, 137.
  • builder.make_struct_value(linkedNode, loc) + .add_value(name, val) — component.ch:37, 43.
  • builder.make_array_value(elemType, loc) + .get_values() — component.ch:49-50.
  • builder.make_addr_of_value(value, isMut, loc) — component.ch:258.
  • builder.make_dereference_value(value, type, loc) — styled.ch:399.

Statements / declarations

  • builder.make_varinit_stmt(isConst, isUnsafe, &name, type, initializer, spec, parent, loc) — ast_replace.ch:93.
  • builder.make_assignment_stmt(lhs, rhs, Operation.Assignment, parent, loc) — ast_replace.ch:161.
  • builder.make_if_stmt(cond, parent, loc) + .get_body() / .add_else_body() / .add_else_if(cond) — ast_replace.ch:102; styled.ch:407.
  • builder.make_return_stmt(value, parent, loc), make_scope(parent, loc) + .getNodes(), make_function(name, retType, isVariadic, parent, loc) + .add_body() + .get_params() — main.ch:14-25; sym_res.ch:25-53.
  • builder.make_function_param(name, type, index, defaultVal, isSelf, func, loc) — sym_res.ch:34, 45, 49.
  • builder.make_value_wrapper(value, parent), make_block_value(parent, loc) + setCalculatedValue(v) — main.ch:51-66.
  • builder.make_top_level_embedded_node(spec, key, dataPtr, knownTypeFn, childResFn, crossModProxyFn, nodes, values, parent, loc) — universal_cbi/src/react/macro.ch:105; css_cbi/src/styled.ch:144; json_cbi/src/main.ch:66.
  • builder.make_embedded_node(...) / make_embedded_value(...) — html_cbi/src/main.ch:76, 94.

Types / allocations

  • builder.make_linked_type(name, linkedNode, loc), make_ptr_type(child, isMut, loc), make_reference_type(child, isMut, loc), make_void_type(loc), builder.get_int_type(), builder.get_u64_type(), builder.get_bool_type() — sym_res.ch:24-49; styled.ch:196-220; ast_replace.ch:93.
  • builder.allocate<T>() + placement new(ptr) T{...} — macro.ch:74-92; styled.ch:114-141.
  • builder.allocate_view(&string) / builder.allocate_view(view), builder.allocate_str_size(n), builder.allocate_str(data, len) — macro.ch:24; css_cbi/src/converter/string/main.ch:188-196.

The full API is documented in compiler_api; the hook typedefs and registration are in cbi_plugin_api.

Prop serialization and escaping rules

There are three distinct escaping contexts; each has one canonical helper — extend those, do not hand-escape at call sites:

  1. HTML text / attributes — HTML-escaped & < > " ' by appendHtmlEscaped and escape_html/escape_html_view (lang/libs/page/src/page.ch:7-38; ssr.ch:361-363). renderHtmlAttrs HTML-escapes Text/PtrChar values.
  2. JS string literals — appendJsEscaped quotes ", escapes \, \n\r\t, control chars, and rewrites </ to \u003C/ so inline <script> cannot be broken out of (page.ch:224-235; ssr.ch:362-364). The html converter's append_js_string_literal implements the same rules (html_cbi/src/converter/language/main.ch:337-352), and append_escaped_single_quoted escapes \ ' \n \r \t for single-quoted contexts (main.ch:119-131).
  3. Single-quoted JS props — when a value is embedded between single quotes, escape \ and ' (and control chars). Generated dispatches today embed string props in double quotes (emit_universal_queue, main.ch:464-478, and the #html script path at component.ch:369-382), so they go through put_js_value_in → append_js_escaped_char_ptr (js_embedded_value.ch:139-149). The documented js_string_escape() workaround (double the backslash, ' → \u0027) still applies to any consumer that wraps props in single quotes (e.g. hand-written serializers in app code; it is not a repo function).

User structs as props: define writeToPageHtml for HTML and writeToPageJs for the JS bundle (html_cbi/src/converter/language/embedded_value.ch:41-68; js_embedded_value.ch:86-114). Without them the converter now reports a compile diagnostic at the macro location (unsupported_value/js_unsupported, embedded_value.ch:27-39; js_embedded_value.ch:65-79) instead of silently writing an error string. On the TCC (2c) backend the diagnostic is printed but non-fatal; the LLVM backend fails.

Gotchas

  • Cannot split an element across #html blocks. An element opened in one block must be closed in that block; use @{} + nested #html {} for dynamic structure (design_web_app §"Cannot split #html").
  • @{} ordering. Inside @{}, increment the loop index before the nested #html {} so the captured value matches the iteration; each @{} must be self-contained.
  • Vector .get_ptr(i) not .get(i). For vector<T> where T has a destructor, .get(i) copies internals and double-frees; always use .get_ptr(i) (AGENTS.md §"Vector Access").
  • Prop types. Never pass a C++ struct with vector<> fields as a component prop; pre- serialize to JSON and post-process with the js_string_escape pattern. bool props that are false and None are skipped by accumulateAttrs (ssr.ch:488-508).
  • Escaping is explicit for interpolated HTML. #html does not auto-escape {value} (page.ch:2-6); call escape_html/escape_html_view.
  • Table contexts. A <span data-chx-i> inside <tr>/<table> is foster-parented; the converter switches to a comment boundary + "root" hydration mode (component.ch:290-332; main.ch:101-109).
  • Plugin recompilation. After editing a plugin's .ch, rebuild with plugins enabled; --cached-plugins (or --cache) may otherwise reuse the stale JIT module. Use --frecompile-plugins when iterating on a macro implementation.
  • Enum sync. Adding a value to a CBI-exposed enum without mirroring it in the lang/libs/compiler/src/*.ch binding shifts every later value and crashes all plugins — see cbi_plugin_api §"Enum Sync Rule".
  • render_js_only. When emitting child client JS, the converter sets page.render_js_only = true around the child server call and restores it afterwards; getting this wrong double-renders SSR subtrees (converter_jsx.ch:255-283; page.ch:55-61).

Shared plumbing you will touch when adding a macro

  • Buffer emission: everything ends up in HtmlPage (lang/libs/page/src/page.ch): pageHead, pageHtml, pageCss, pageHeadJs, pageJs (+ pageJsEnd). Buffer-targeted helper families exist: append_html*, append_css*, append_head_js*, append_js* (the last one targets pageJs), including escaped variants (append_js_escaped_char_ptr / append_js_escaped) that quote/backslash/</-escape for embedding inside JS string literals.
  • SSR value model (lang/libs/page/src/ssr.ch): build SsrAttributeList / SsrAttributeValue values and let renderHtmlAttrs / renderJsAttrs render them — they handle class/style merging, dedup, escaping, and skipping unresolvable values. User structs can plug in with getSsrAttributeValue(&mut self, page) : SsrAttributeValue.
  • SymResSupport: plugins resolve every HtmlPage method / SSR helper they emit calls to (see universal_cbi/src/sym_res/support_fix.ch for the pattern) and emit compile errors when a required helper is missing — do this instead of emitting unresolved names.
  • Generated SSR function bodies bypass symres type determination: call plain helper functions (ssrMakeTextValue, ssrMultipleGet, ...) instead of raw index/variant operations, or LLVM codegen crashes on unresolved types.

Known generated-code pitfalls (deep review in the universal skill): emitted JS is never re-parsed before shipping; unsupported prop types were silently UInteger (now a diagnostic); layout effects were registered but never executed (now fixed); per-page runtime duplication (~32 KB inline per page); legacy capture_html_delta_to_js did not escape </script> (removed / fixed). Contract tests pin these in lang/tests/compiler_plugins/universal/src/runtime_contracts.ch.

For developing new compiler plugins or the plugin API itself, load the cbi_plugin_api skill. For compile-time intrinsics/reflection used by macros, load intrinsics_compiler_reflection. For the full universal SSR/hydration model, load universal. For the AST node hierarchy used by ASTBuilder, load compiler_api. For authoring apps with these macros, load design_web_app.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Comprehensive guide to the Chemical compiler's annotation system (`@name`, `@name(args)`) — how annotations are lexed, parsed, registered in the `AnnotationController`, stored and queried centrally, consumed by the compiler and CBI plugins, and how to add a new one. Load when working on annotations, `AnnotationController`, compiler plugins that define/read annotations, or node attribute flags such as `@extern`, `@test`, `@make`, `@deprecated`, `@no_mangle`, `@inline`, and `@volatile`.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

The C++ AST node framework of the Chemical compiler — the ASTAny/ASTNode/Value/BaseType hierarchy, node/value/type kind enums and range predicates, the visitor dispatch framework, the arena allocator, TypeBuilder canonicalization, member containers, and cross-reference resolution. Load when adding or modifying AST nodes, writing compiler passes/codegen visitors, debugging kind mismatches or arena/lifetime issues, or extending the compiler API bindings.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

The Chemical compiler benchmark & release analytics system — how the static GitHub Pages dashboard works, the JSON data model (manifest/daily/release records), the bash collection scripts (bench-*.sh), the GitHub Actions workflows (benchmark-daily/release), and the critical pitfalls (hidden upload dirs, worktree submodules, gh-pages-only data). Load when working on the dashboard, benchmark collection, or analytics workflows.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

Comprehensive deep-dive into the Chemical build pipeline — how chemical.mod is converted to build.lab, how build scripts are JIT-compiled via TinyCC, how jobs are created and executed, and how ASTProcessor orchestrates the parallel compilation passes.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

All documentation related to building and running Project or Tests

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

Comprehensive guide to the C translation backend (2c) — how Chemical AST is translated to C code, key patterns, gotchas, and optimization strategies.

The English summary is being prepared. Showing the original description.

chemicallang/chemical31Updated Oct 26, 2026

See all skills by chemicallang

Report a problem with this skill