Repository: alloy-framework/alloy
Pull request #150 (2025-05-04 23:42:22)

Fully resolve refkeys for first MemberExpression.Part
I believe it should always be fine to fully resolve refkeys when they are the first part of a member expression. If this isn't the case, we can instead rename `refkey` and `symbol` to `nameRefkey` and `nameSymbol` to call out that these are only used for the name and do not create a reference.

Additionally, this:

* deletes CallChainExpression
* formats call chains in member expressions properly
* throws when passing invalid options to a member expression part
* allows a part of explicitly provide its children
* allows a part to expllicitly ask for quoted keys
* exports new utils from vue reactive


github-actions[bot]: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@alloy-js/core`
 - :white_check_mark: `@alloy-js/typescript`

<details>
<summary>Show changes</summary>

### `@alloy-js/core` - _feature_ [✏️](https://github.com/bterlson/alloy/edit/member-expr-reform/.chronus/changes/member-expr-reform-2025-4-4-16-38-45.md?pr=/alloy-framework/alloy/pull/150)
> Export `isRef` and `isReactive` utility functions.
### `@alloy-js/core` - _feature_ [✏️](https://github.com/bterlson/alloy/edit/member-expr-reform/.chronus/changes/member-expr-reform-2025-4-4-16-39-17.md?pr=/alloy-framework/alloy/pull/150)
> Fully resolve refkeys given to the first part of a MemberExpression.
### `@alloy-js/typescript` - _feature_ [✏️](https://github.com/bterlson/alloy/edit/member-expr-reform/.chronus/changes/member-expr-reform-2025-4-4-16-39-33.md?pr=/alloy-framework/alloy/pull/150)
> Allow providing explicit children to a MemberExpression.Part.
</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/1e68abfb-da93-49d7-97fd-fb384a8d18f9)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/cli</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/cli@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/create</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/create@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/markdown</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/markdown@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/rollup-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/rollup-plugin@150
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@150
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/41619911351"><code>6aa277b</code></a>_


Patch:
diff --git a/.chronus/changes/md-code-2025-4-4-20-23-20.md b/.chronus/changes/md-code-2025-4-4-20-23-20.md
new file mode 100644
index 0000000..dfb07e2
--- /dev/null
+++ b/.chronus/changes/md-code-2025-4-4-20-23-20.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/markdown"
+---
+
+Add `<Code />` component
\ No newline at end of file
diff --git a/.chronus/changes/md-frontmatter-2025-4-3-22-52-33.md b/.chronus/changes/md-frontmatter-2025-4-3-22-52-33.md
new file mode 100644
index 0000000..7c73c25
--- /dev/null
+++ b/.chronus/changes/md-frontmatter-2025-4-3-22-52-33.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: feature
+packages:
+  - "@alloy-js/markdown"
+---
+
+Add `<FrontMatter />` component
diff --git a/.chronus/changes/member-expr-reform-2025-4-4-16-38-45.md b/.chronus/changes/member-expr-reform-2025-4-4-16-38-45.md
new file mode 100644
index 0000000..ae0e854
--- /dev/null
+++ b/.chronus/changes/member-expr-reform-2025-4-4-16-38-45.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Export `isRef` and `isReactive` utility functions.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-reform-2025-4-4-16-39-17.md b/.chronus/changes/member-expr-reform-2025-4-4-16-39-17.md
new file mode 100644
index 0000000..f75a3e8
--- /dev/null
+++ b/.chronus/changes/member-expr-reform-2025-4-4-16-39-17.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Fully resolve refkeys given to the first part of a MemberExpression.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-reform-2025-4-4-16-39-33.md b/.chronus/changes/member-expr-reform-2025-4-4-16-39-33.md
new file mode 100644
index 0000000..841f692
--- /dev/null
+++ b/.chronus/changes/member-expr-reform-2025-4-4-16-39-33.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Allow providing explicit children to a MemberExpression.Part.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-reform-2025-4-6-12-44-56.md b/.chronus/changes/member-expr-reform-2025-4-6-12-44-56.md
new file mode 100644
index 0000000..b589e73
--- /dev/null
+++ b/.chronus/changes/member-expr-reform-2025-4-6-12-44-56.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/typescript"
+---
+
+Remove `MemberChainExpression` component. Use `MemberExpression` component instead, which accomplishes the same goals but formats better, handles optional chaining, and more.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-reform-2025-4-6-12-46-38.md b/.chronus/changes/member-expr-reform-2025-4-6-12-46-38.md
new file mode 100644
index 0000000..2c6794d
--- /dev/null
+++ b/.chronus/changes/member-expr-reform-2025-4-6-12-46-38.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Format member expressions with multiple calls in a much better way.
\ No newline at end of file
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8d7ac5a..67fb8c0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,8 @@
 export {
   computed,
   isProxy,
+  isReactive,
+  isRef,
   reactive,
   ref,
   shallowReactive,
@@ -9,6 +11,8 @@ export {
   toRef,
   toRefs,
   type Ref,
+  type ToRef,
+  type ToRefs,
 } from "@vue/reactivity";
 export * from "./binder.js";
 export * from "./code.js";
diff --git a/packages/markdown/package.json b/packages/markdown/package.json
index 8b5ffe7..189278b 100644
--- a/packages/markdown/package.json
+++ b/packages/markdown/package.json
@@ -24,12 +24,13 @@
   "author": "brian.terlson@microsoft.com",
   "license": "MIT",
   "dependencies": {
-    "@alloy-js/core": "workspace:~"
+    "@alloy-js/core": "workspace:~",
+    "yaml": "^2.7.1"
   },
   "devDependencies": {
-    "@microsoft/api-extractor": "catalog:",
-    "@alloy-js/rollup-plugin": "workspace:~",
     "@alloy-js/cli": "workspace:~",
+    "@alloy-js/rollup-plugin": "workspace:~",
+    "@microsoft/api-extractor": "catalog:",
     "typescript": "catalog:",
     "vitest": "catalog:"
   },
diff --git a/packages/markdown/src/components/Code.tsx b/packages/markdown/src/components/Code.tsx
new file mode 100644
index 0000000..d84c309
--- /dev/null
+++ b/packages/markdown/src/components/Code.tsx
@@ -0,0 +1,24 @@
+import { List } from "@alloy-js/core";
+import { Children } from "@alloy-js/core/jsx-runtime";
+
+export interface CodeProps {
+  /** Language of the code block */
+  lang?: string;
+
+  /** Body of the code block */
+  children?: Children;
+}
+
+/**
+ * Render a Markdown code block
+ */
+export function Code(props: CodeProps) {
+  const tripleBackticks = "```";
+  return (
+    <List>
+      {tripleBackticks + props.lang}
+      {props.children}
+      {tripleBackticks}
+    </List>
+  );
+}
diff --git a/packages/markdown/src/components/Frontmatter.tsx b/packages/markdown/src/components/Frontmatter.tsx
new file mode 100644
index 0000000..2c8ec74
--- /dev/null
+++ b/packages/markdown/src/components/Frontmatter.tsx
@@ -0,0 +1,32 @@
+import { List } from "@alloy-js/core";
+import { Children } from "@alloy-js/core/jsx-runtime";
+import { stringify } from "yaml";
+
+export interface FrontmatterProps {
+  /** Js value to automatically serialize as yaml */
+  jsValue?: Record<string, unknown>;
+  children?: Children;
+}
+
+/**
+ * Create a frontmatter block for markdown files.
+ *
+ * @example
+ *
+ * ```markdown
+ * ---
+ * title: "My Title"
+ * ---
+ * ```
+ */
+export function Frontmatter(props: FrontmatterProps) {
+  return (
+    <List>
+      {"---"}
+      {props.jsValue ?
+        stringify(props.jsValue).trim()
+      : <List children={props.children} />}
+      {"---"}
+    </List>
+  );
+}
diff --git a/packages/markdown/src/components/index.ts b/packages/markdown/src/components/index.ts
index 8fbe55a..a0d3b37 100644
--- a/packages/markdown/src/components/index.ts
+++ b/packages/markdown/src/components/index.ts
@@ -1,3 +1,5 @@
+export * from "./Code.jsx";
+export * from "./Frontmatter.jsx";
 export * from "./Heading.jsx";
 export * from "./Link.jsx";
 export * from "./List.jsx";
diff --git a/packages/typescript/src/components/MemberChainExpression.tsx b/packages/typescript/src/components/MemberChainExpression.tsx
deleted file mode 100644
index d69bfd0..0000000
--- a/packages/typescript/src/components/MemberChainExpression.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { childrenArray, computed, For, List, Show } from "@alloy-js/core";
-import { Children, isComponentCreator } from "@alloy-js/core/jsx-runtime";
-import { FunctionCallExpression } from "./FunctionCallExpression.jsx";
-
-export interface MemberChainExpressionProps {
-  children: Children;
-}
-
-/**
- * Create a member chain expression, which is a member expression comprised of
- * either children, {@link FunctionCallExpression} components, or nested member
- * chain expression components. Other component types are ignored.
- */
-export function MemberChainExpression(props: MemberChainExpressionProps) {
-  // chunks are constructed by consuming as many non-call expressions as
-  // possible, then placing an indent and soft line break before and after the
-  // any subsequent call expressions
-
-  const chunks = computed(() => {
-    const children = flattenCallChains(childrenArray(() => props.children));
-    const chunks: Children[][] = [];
-
-    let currentChunk: Children[] = [];
-
-    for (const child of children) {
-      if (
-        isComponentCreator(child) &&
-        child.component === FunctionCallExpression
-      ) {
-        if (currentChunk.length > 0) {
-          chunks.push(currentChunk);
-          currentChunk = [];
-        }
-        chunks.push([child]);
-      } else {
-        currentChunk.push(child);
-      }
-    }
-
-    if (currentChunk.length > 0) {
-      chunks.push(currentChunk);
-    }
-
-    return [chunks[0], chunks.slice(1)];
-  });
-  const groupId = Symbol();
-
-  return (
-    <group>
-      <group id={groupId}>
-        <List joiner="." children={chunks.value[0]} />
-      </group>
-      <Show when={chunks.value[1].length === 1}>
-        <For each={chunks.value[1]} softline>
-          {(chunk) => (
-            <>
-              .<List joiner="." children={chunk} />
-            </>
-          )}
-        </For>
-      </Show>
-      <Show when={chunks.value[1].length > 1}>
-        <indent>
-          <For each={chunks.value[1]} softline>
-            {(chunk) => (
-              <>
-                .<List joiner="." children={chunk} />
-              </>
-            )}
-          </For>
-        </indent>
-      </Show>
-    </group>
-  );
-}
-
-function flattenCallChains(children: Children[]): Children[] {
-  const flatChildren = [];
-  for (const child of children) {
-    if (
-      isComponentCreator(child) &&
-      child.component === MemberChainExpression
-    ) {
-      flatChildren.push(...flattenCallChains(child.props.children));
-    } else {
-      flatChildren.push(child);
-    }
-  }
-
-  return flatChildren;
-}
diff --git a/packages/typescript/src/components/MemberExpression.tsx b/packages/typescript/src/components/MemberExpression.tsx
index 041e277..c7a7856 100644
--- a/packages/typescript/src/components/MemberExpression.tsx
+++ b/packages/typescript/src/components/MemberExpression.tsx
@@ -5,12 +5,14 @@ import {
   For,
   isComponentCreator,
   OutputSymbol,
-  Ref,
+  reactive,
   ref,
   Refkey,
-  toRef,
+  Show,
+  ToRefs,
   useBinder,
 } from "@alloy-js/core";
+
 import { TSOutputSymbol, TSSymbolFlags } from "../symbols/ts-output-symbol.js";
 import { isValidJSIdentifier } from "../utils.js";
 
@@ -19,10 +21,11 @@ export interface MemberExpressionProps {
 }
 
 interface PartDescriptor {
-  base: string | Ref<string>;
-  accessStyle: Ref<"dot" | "bracket">;
-  nullish: Ref<boolean>;
-  args?: Children[] | boolean;
+  id: Children;
+  accessStyle: "dot" | "bracket";
+  quoteId: boolean;
+  nullish: boolean;
+  args?: Children[];
 }
 
 /**
@@ -30,12 +33,18 @@ interface PartDescriptor {
  * the following:
  *
  * * **id**: The identifier for the member expression part
- * * **refkey**: A refkey for a symbol whose name becomes the identifier
+ * * **refkey**: a refkey for a symbol whose name becomes the identifier
  * * **symbol**: a symbol whose name becomes the identifier part
  * * **args**: create a method call with the given args
+ * * **children**: arbitrary contents for the identifier part.
+ *
+ * Each part can have a nullish prop, which indicates that the part may be null
+ * or undefined.
  *
- * Additionally, a part can be `nullish`, which means subsequent parts
- * will use the appropriate conditional access operator e.g. `?.`.
+ * Each part can also have a quoteId prop, which indicates that the identifier
+ * of the part should be quoted (i.e. `["foo"]` instead of `.foo`). This is only
+ * necessary when providing the children prop, otherwise it is determined
+ * automatically.
  *
  * @example
  *
@@ -45,6 +54,7 @@ interface PartDescriptor {
  *   <MemberExpression.Part refkey={rk} nullish />
  *   <MemberExpression.Part symbol={sym} />
  *   <MemberExpression.Part args={["hello", "world"]} />
+ *   <MemberExpression.Part>SomeValue</MemberExpression.Part>
  * </MemberExpression>
  * ```
  *
@@ -52,11 +62,41 @@ interface PartDescriptor {
  * with a name of "prop2", this will render:
  *
  * ```ts
- * base.prop1?.prop2("hello", "world")
+ * base.prop1?.prop2("hello", "world").SomeValue
  * ```
  */
 export function MemberExpression(props: MemberExpressionProps): Children {
   const children = flattenMemberExpression(childrenArray(() => props.children));
+  const parts = childrenToPartDescriptors(children);
+
+  if (parts.length === 0) {
+    return <></>;
+  }
+
+  const isCallChain = computed(() => {
+    let callCount = 0;
+    for (const part of parts) {
+      if (part.args !== undefined) callCount++;
+    }
+
+    return callCount > 1;
+  });
+
+  // construct a member expression from the parts. When a part is nullish,
+  // and there is a subsequent part, we use `?.` instead of `.`. accessStyle determines
+  // whether we use dot or bracket notation.
+
+  return computed(() => {
+    return isCallChain.value ?
+        formatCallChain(parts)
+      : formatNonCallChain(parts);
+  });
+}
+
+/**
+ * Build part descriptors from the children of a MemberExpression.
+ */
+function childrenToPartDescriptors(children: Children[]): PartDescriptor[] {
   const parts: PartDescriptor[] = [];
   for (const child of children) {
     if (!isComponentCreator(child, MemberExpression.Part)) {
@@ -64,121 +104,320 @@ export function MemberExpression(props: MemberExpressionProps): Children {
       continue;
     }
 
-    const partProps = child.props;
-    const part: PartDescriptor = {
-      base: "",
-      accessStyle: ref("dot"),
-      nullish: ref(!!partProps.nullish),
-    };
-
-    if (partProps.id) {
-      part.base = partProps.id;
-      part.accessStyle.value = accessStyleForMemberName(partProps.id);
-    } else if (partProps.refkey) {
-      const binder = useBinder();
-      const symbolRef = binder.getSymbolForRefkey(partProps.refkey);
-      part.base = computed(() => {
-        if (symbolRef.value) {
-          part.nullish.value = !!(
-            (symbolRef.value as TSOutputSymbol).tsFlags & TSSymbolFlags.Nullish
-          );
-          part.accessStyle.value = accessStyleForMemberName(
-            symbolRef.value.name,
-          );
-          return symbolRef.value.name;
-        } else {
-          return "<unresolved symbol>";
-        }
-      });
+    parts.push(
+      createPartDescriptorFromProps(child.props, child === children[0]),
+    );
+  }
+
+  return parts;
+}
+
+const exclusiveParts: (keyof MemberExpressionPartProps)[] = [
+  "children",
+  "args",
+  "refkey",
+  "symbol",
+  "id",
+];
+/**
+ * Creates a reactive part descriptor from the given part props.
+ *
+ * @param partProps The props for the part.
+ * @param first Whether this is the first part in the expression. Refkeys are
+ * handled specially for the first part.
+ */
+function createPartDescriptorFromProps(
+  partProps: MemberExpressionPartProps,
+  first: boolean,
+) {
+  const foundProps = exclusiveParts.filter((key) => {
+    return key in partProps;
+  });
+
+  if (foundProps.length > 1) {
+    throw new Error(
+      `Only one of ${foundProps.join(", ")} can be used for a MemberExpression part at a time`,
+    );
+  }
+
+  const symbolSource = computed(() => {
+    if (partProps.refkey) {
+      return getSymbolForRefkey(partProps.refkey).value;
     } else if (partProps.symbol) {
-      part.nullish.value = !!(
-        (partProps.symbol as TSOutputSymbol).tsFlags & TSSymbolFlags.Nullish
-      );
-      part.accessStyle = computed(() => {
-        return accessStyleForMemberName(partProps.symbol!.name);
-      });
-      part.base = toRef(partProps.symbol, "name");
-    } else if (partProps.args) {
-      part.args = partProps.args;
+      return partProps.symbol;
+    } else {
+      return undefined;
     }
+  });
 
-    parts.push(part);
-  }
+  const part: ToRefs<PartDescriptor> = {
+    id: computed(() => {
+      if (partProps.args) {
+        return undefined;
+      } else if ("children" in partProps && partProps.children !== undefined) {
+        return partProps.children;
+      } else if (first && partProps.refkey) {
+        return partProps.refkey;
+      } else if (partProps.id) {
+        return escapeId(partProps.id);
+      } else if (symbolSource.value) {
+        return escapeId(symbolSource.value.name);
+      } else {
+        return "<unresolved symbol>";
+      }
+    }),
+    accessStyle: computed(() => {
+      if (partProps.args) {
+        // not used
+        return "dot";
+      } else if (partProps.quoteId) {
+        return "bracket";
+      } else if (partProps.id) {
+        return accessStyleForMemberName(partProps.id);
+      } else if (symbolSource.value) {
+        return accessStyleForMemberName(symbolSource.value.name);
+      } else {
+        return "dot";
+      }
+    }),
+    quoteId: computed(() => {
+      if (partProps.quoteId) {
+        return partProps.quoteId;
+      } else if (partProps.id) {
+        return !isValidJSIdentifier(partProps.id);
+      } else if (symbolSource.value) {
+        return !isValidJSIdentifier(symbolSource.value.name);
+      } else {
+        return false;
+      }
+    }),
+    nullish: computed(() => {
+      if (partProps.nullish) {
+        return partProps.nullish;
+      }
 
-  if (parts.length === 0) {
-    return <></>;
-  }
+      if (symbolSource.value) {
+        return isNullish(symbolSource.value);
+      }
 
-  // construct a member expression from the parts. When a part is nullish,
-  // and there is a subsequent part, we use `?.` instead of `.`. accessStyle determines
-  // whether we use dot or bracket notation.
+      return false;
+    }),
+    args: ref<any>(partProps.args === true ? [] : partProps.args),
+  };
 
-  const result = computed(() => {
+  return reactive(part);
+}
+
+/**
+ * replaces quotes with escaped quotes
+ */
+function escapeId(id: string) {
+  return id.replace(/"/g, '\\"');
+}
+
+/**
+ * Convert a refkey to a symbol ref using the current binder.
+ */
+function getSymbolForRefkey(refkey: Refkey) {
+  const binder = useBinder();
+  return binder.getSymbolForRefkey(refkey);
+}
+
+/**
+ * Check if the given symbol is nullish.
+ */
+function isNullish(symbol: OutputSymbol): boolean {
+  return !!((symbol as TSOutputSymbol).tsFlags & TSSymbolFlags.Nullish);
+}
+
+/**
+ * Formatting of call chains (i.e. member expressions which have more than one
+ * call in them). The general approach is that line breaks occur after each
+ * call, and there is only one call per line. When there are non-call elements,
+ * they occur prior to the call part. The first part of the member expression
+ * contains all but the last non-call part.
+ *
+ * The following is an example of proper formatting:
+ *
+ * ```ts
+ * z.dummy             // all but the last non-call part for the first element
+ *   .object({         // the first call part with line break after
+ *      a: 1,
+ *   })
+ *   .dummy.partial()  // the next call part with non-call parts before it
+ * ```
+ */
+function formatCallChain(parts: PartDescriptor[]): Children {
+  return computed(() => {
+    const expression: Children[] = [];
+
+    // break the expression into parts.
+    const chunks: PartDescriptor[][] = [];
+
+    // the first part is all the non-call parts
+    let partIndex = 0;
+
+    function pushPart() {
+      const part = parts[partIndex];
+      if (!part) throw new Error("No part to push");
+      chunks.at(-1)!.push(part);
+      partIndex++;
+    }
+
+    function pushChunk() {
+      chunks.push([]);
+    }
+
+    // For the first chunk, take all the non-call parts except the last one
+    // and put them in a chunk.
+    pushChunk();
+    while (
+      partIndex < parts.length &&
+      (partIndex === parts.length - 1 ||
+        chunks.at(-1)!.length === 0 ||
+        parts[partIndex + 1].args === undefined)
+    ) {
+      pushPart();
+      if (chunks.at(-1)!.at(-1)!.args !== undefined) {
+        // the first segment always ends after we see a call
+        // if we happen to take one
+        break;
+      }
+    }
+
+    // then for all remaining parts, collect all the non-call parts and end with
+    // a call chunk
+    while (partIndex < parts.length) {
+      pushChunk();
+      while (partIndex < parts.length && !parts[partIndex].args) {
+        pushPart();
+      }
+      while (partIndex < parts.length && parts[partIndex].args) {
+        pushPart();
+      }
+    }
+
+    for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
+      const chunk = chunks[chunkIndex];
+      const chunkExpression = [];
+      for (let partIndex = 0; partIndex < chunk.length; partIndex++) {
+        if (chunkIndex === 0 && partIndex === 0) {
+          // first part is just gonna be the id
+          chunkExpression.push(chunk[0].id);
+          continue;
+        }
+        const part = chunk[partIndex];
+        const prevPart =
+          partIndex === 0 ?
+            chunks[chunkIndex - 1].at(-1)!
+          : chunk[partIndex - 1];
+
+        if (part.args !== undefined) {
+          // For parts with only args (no name), append function call directly with appropriate nullish operator
+          chunkExpression.push(formatCallExpr(prevPart, part));
+        } else if (part.accessStyle === "dot") {
+          chunkExpression.push(formatDotAccess(prevPart, part));
+        } else {
+          // bracket notation - don't include the dot
+          chunkExpression.push(formatArrayAccess(prevPart, part));
+        }
+      }
+
+      expression.push(
+        chunkIndex === 0 ? chunkExpression : (
+          <>
+            <sbr />
+            {chunkExpression}
+          </>
+        ),
+      );
+    }
+
+    return (
+      <group>
+        <indent>{expression}</indent>
+      </group>
+    );
+  });
+}
+
+function formatNonCallChain(parts: PartDescriptor[]): Children {
+  return computed(() => {
     const expression: Children[] = [];
 
     for (let i = 0; i < parts.length; i++) {
       const part = parts[i];
-      const base = typeof part.base === "string" ? part.base : part.base.value;
-      const hasName = base !== "";
-
+      const base = part.id;
       if (i === 0) {
         expression.push(base);
       } else {
         // Determine if we should use nullish operator from previous part
         const prevPart = parts[i - 1];
-        const isNullish = prevPart.nullish.value;
-
-        // Handle parts with name vs. parts with only args differently
-        if (hasName) {
-          // Apply dot or bracket notation based on accessStyle for named parts
-          if (part.accessStyle.value === "dot") {
-            expression.push(
-              <group>
-                <indent>
-                  <sbr />
-                  {isNullish ? "?." : "."}
-                  {base}
-                </indent>
-              </group>,
-            );
-          } else {
-            // bracket notation - don't include the dot
-            expression.push(
-              <group>
-                {isNullish ? "?." : ""}[
-                <indent>
-                  <sbr />
-                  {JSON.stringify(base)}
-                </indent>
-                <sbr />]
-              </group>,
-            );
-          }
-        } else if (part.args !== undefined) {
+
+        if (part.args !== undefined) {
           // For parts with only args (no name), append function call directly with appropriate nullish operator
-          expression.push(
-            <group>
-              {isNullish ? "?." : ""}
-              <group>
-                (
-                <For
-                  each={typeof part.args === "boolean" ? [] : (part.args ?? [])}
-                  comma
-                  line
-                >
-                  {(arg) => arg}
-                </For>
-                )
-              </group>
-            </group>,
-          );
+          expression.push(formatCallExpr(prevPart, part));
+        } else if (part.accessStyle === "dot") {
+          expression.push(formatDotAccess(prevPart, part));
+        } else {
+          // bracket notation - don't include the dot
+          expression.push(formatArrayAccess(prevPart, part));
         }
       }
     }
 
     return expression;
   });
-  return <>{result.value}</>;
+}
+
+function formatArrayAccess(prevPart: PartDescriptor, part: PartDescriptor) {
+  return (
+    <group>
+      {prevPart.nullish ? "?." : ""}[
+      <indent>
+        <sbr />
+        {part.quoteId && '"'}
+        {part.id}
+        {part.quoteId && '"'}
+      </indent>
+      <sbr />]
+    </group>
+  );
+}
+
+function formatDotAccess(prevPart: PartDescriptor, part: PartDescriptor) {
+  return (
+    <group>
+      <indent>
+        <sbr />
+        {prevPart.nullish ? "?." : "."}
+        {part.id}
+      </indent>
+    </group>
+  );
+}
+
+function formatCallExpr(prevPart: PartDescriptor, part: PartDescriptor) {
+  const args = computed(() => {
+    return typeof part.args === "boolean" ? [] : (part.args ?? []);
+  });
+
+  return (
+    <group>
+      {prevPart.nullish ? "?." : ""}(
+      <Show when={args.value.length <= 1}>{args.value[0]}</Show>
+      <Show when={args.value.length > 1}>
+        <indent>
+          <sbr />
+          <For each={args} comma line>
+            {(arg) => arg}
+          </For>
+        </indent>
+        <sbr />
+      </Show>
+      )
+    </group>
+  );
 }
 
 function accessStyleForMemberName(name: string) {
@@ -207,6 +446,13 @@ export interface MemberExpressionPartProps {
    * The identifier for this part of the member expression.
    */
   id?: string;
+
+  /**
+   * Whether the identifier should be quoted or not. Only needed when
+   * providing the `children` prop, otherwise it is determined automatically.
+   */
+  quoteId?: boolean;
+
   /**
    * A refkey for a symbol whose name becomes the identifier.
    */
@@ -224,6 +470,12 @@ export interface MemberExpressionPartProps {
    * This part is nullish. Subsequent parts use conditional access operators.
    */
   nullish?: boolean;
+
+  /**
+   * The contents of this part. When passed, overrides the other props except
+   * nullish.
+   */
+  children?: Children;
 }
 /**
  * A part of a member expression. Each part can provide one of the following
@@ -233,6 +485,15 @@ export interface MemberExpressionPartProps {
  * * **refkey**: A refkey for a symbol whose name becomes the identifier
  * * **symbol**: a symbol whose name becomes the identifier part
  * * **args**: create a method call with the given args
+ * * **children**: arbitrary contents for the identifier part.
+ *
+ * Each part can have a nullish prop, which indicates that the part may be null
+ * or undefined.
+ *
+ * Each part can also have a quoteId prop, which indicates that the identifier
+ * of the part should be quoted (i.e. `["foo"]` instead of `.foo`). This is only
+ * necessary when providing the children prop, otherwise it is determined
+ * automatically.
  */
 MemberExpression.Part = function (props: MemberExpressionPartProps) {
   /**
diff --git a/packages/typescript/src/components/index.ts b/packages/typescript/src/components/index.ts
index f6e9194..f85b005 100644
--- a/packages/typescript/src/components/index.ts
+++ b/packages/typescript/src/components/index.ts
@@ -26,7 +26,6 @@ export * from "./InterfaceMethod.jsx";
 export * from "./JSDoc.jsx";
 export * from "./JSDocComment.jsx";
 export * from "./JSDocExample.jsx";
-export * from "./MemberChainExpression.jsx";
 export * from "./MemberExpression.jsx";
 export * from "./ObjectExpression.js";
 export * from "./PackageDirectory.js";
diff --git a/packages/typescript/src/components/stc/index.ts b/packages/typescript/src/components/stc/index.ts
index c9c91cf..0fba406 100644
--- a/packages/typescript/src/components/stc/index.ts
+++ b/packages/typescript/src/components/stc/index.ts
@@ -30,7 +30,6 @@ export const JSDoc = stc(base.JSDoc);
 export const JSDocExample = stc(base.JSDocExample);
 export const JSDocComment = stc(base.JSDocComment);
 export const MemberExpression = stc(base.MemberExpression);
-export const MemberChainExpression = stc(base.MemberChainExpression);
 export const ObjectExpression = stc(base.ObjectExpression);
 export const ObjectProperty = stc(base.ObjectProperty);
 export const PackageDirectory = stc(base.PackageDirectory);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d0294b..52193b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -520,6 +520,9 @@ importers:
       '@alloy-js/core':
         specifier: workspace:~
         version: link:../core
+      yaml:
+        specifier: ^2.7.1
+        version: 2.7.1
     devDependencies:
       '@alloy-js/cli':
         specifier: workspace:~


Test patch:
diff --git a/packages/markdown/test/Code.test.tsx b/packages/markdown/test/Code.test.tsx
new file mode 100644
index 0000000..765df04
--- /dev/null
+++ b/packages/markdown/test/Code.test.tsx
@@ -0,0 +1,37 @@
+import { List } from "@alloy-js/core";
+import "@alloy-js/core/testing";
+import { d } from "@alloy-js/core/testing";
+import { expect, it } from "vitest";
+import { Code } from "../src/components/Code.jsx";
+import { mdTest } from "./utils.jsx";
+
+it("renders with js value", () => {
+  const template = mdTest(
+    <Code lang="ts" children={"const foo = 123;\nconsole.log(foo);"} />,
+  );
+
+  expect(template).toRenderTo(d`
+    \`\`\`ts
+    const foo = 123;
+    console.log(foo);
+    \`\`\`
+  `);
+});
+
+it("renders with children", () => {
+  const template = mdTest(
+    <Code lang="ts">
+      <List>
+        <>const foo = 123;</>
+        <>console.log(foo);</>
+      </List>
+    </Code>,
+  );
+
+  expect(template).toRenderTo(d`
+    \`\`\`ts
+    const foo = 123;
+    console.log(foo);
+    \`\`\`
+  `);
+});
diff --git a/packages/markdown/test/Frontmatter.test.tsx b/packages/markdown/test/Frontmatter.test.tsx
new file mode 100644
index 0000000..b5d52bb
--- /dev/null
+++ b/packages/markdown/test/Frontmatter.test.tsx
@@ -0,0 +1,40 @@
+import "@alloy-js/core/testing";
+import { d } from "@alloy-js/core/testing";
+import { expect, it } from "vitest";
+import { Frontmatter } from "../src/components/Frontmatter.jsx";
+import { mdTest } from "./utils.jsx";
+
+it("renders with js value", () => {
+  const template = mdTest(
+    <Frontmatter jsValue={{ title: "My Title", tags: ["tag1", "tag2"] }} />,
+  );
+
+  expect(template).toRenderTo(d`
+    ---
+    title: My Title
+    tags:
+      - tag1
+      - tag2
+    ---
+  `);
+});
+
+it("renders with children", () => {
+  const template = mdTest(
+    <Frontmatter>
+      <>{`title: My Title`}</>
+      <>{`tags:`}</>
+      <>{`  - tag1`}</>
+      <>{`  - tag2`}</>
+    </Frontmatter>,
+  );
+
+  expect(template).toRenderTo(d`
+    ---
+    title: My Title
+    tags:
+      - tag1
+      - tag2
+    ---
+  `);
+});
diff --git a/packages/typescript/test/member-chain.test.tsx b/packages/typescript/test/member-chain.test.tsx
deleted file mode 100644
index 0de0b0c..0000000
--- a/packages/typescript/test/member-chain.test.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { d } from "@alloy-js/core/testing";
-import { expect, it } from "vitest";
-import { MemberChainExpression } from "../src/components/MemberChainExpression.jsx";
-import { FunctionCallExpression, ValueExpression } from "../src/index.js";
-import { toSourceText } from "./utils.js";
-
-it("works", () => {
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <FunctionCallExpression target="string" />
-        <FunctionCallExpression target="array" />
-        <FunctionCallExpression target="min" args={[5]} />
-        <FunctionCallExpression target="max" args={[10]} />
-      </MemberChainExpression>,
-      { printWidth: 10 },
-    ),
-  ).toBe(d`
-    z.string()
-      .array()
-      .min(5)
-      .max(10)
-  `);
-});
-
-it("works with args which break", () => {
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <FunctionCallExpression
-          target="object"
-          args={[<ValueExpression jsValue={{ a: 1, b: 2 }} />]}
-        />
-        <FunctionCallExpression target="array" />
-      </MemberChainExpression>,
-      { printWidth: 10 },
-    ),
-  ).toBe(d`
-    z.object({
-        a: 1,
-        b: 2,
-      })
-      .array()
-  `);
-});
-
-it("works with args when it doesn't break", () => {
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <FunctionCallExpression
-          target="object"
-          args={[<ValueExpression jsValue={{ a: 1, b: 2 }} />]}
-        />
-      </MemberChainExpression>,
-    ),
-  ).toBe(d`
-    z.object({
-      a: 1,
-      b: 2,
-    })
-  `);
-
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <>z2</>
-        <>z3</>
-        <FunctionCallExpression
-          target="object"
-          args={[<ValueExpression jsValue={{ a: 1, b: 2 }} />]}
-        />
-      </MemberChainExpression>,
-    ),
-  ).toBe(d`
-    z.z2.z3.object({
-      a: 1,
-      b: 2,
-    })
-  `);
-});
-
-it("works with nested call member chains", () => {
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <FunctionCallExpression target="string" />
-        <FunctionCallExpression target="array" />
-        <MemberChainExpression>
-          <FunctionCallExpression target="min" args={[5]} />
-          <FunctionCallExpression target="max" args={[10]} />
-        </MemberChainExpression>
-        <FunctionCallExpression target="array" />
-      </MemberChainExpression>,
-      { printWidth: 10 },
-    ),
-  ).toBe(d`
-    z.string()
-      .array()
-      .min(5)
-      .max(10)
-      .array()
-  `);
-});
-
-it("works without function call expressions", () => {
-  expect(
-    toSourceText(
-      <MemberChainExpression>
-        <>z</>
-        <>string</>
-        <>min</>
-      </MemberChainExpression>,
-      { printWidth: 5 },
-    ),
-  ).toBe(d`
-    z.string.min
-  `);
-});
diff --git a/packages/typescript/test/member-expression.test.tsx b/packages/typescript/test/member-expression.test.tsx
index ef6c9eb..d987d83 100644
--- a/packages/typescript/test/member-expression.test.tsx
+++ b/packages/typescript/test/member-expression.test.tsx
@@ -1,20 +1,26 @@
-import { code, List, refkey, StatementList } from "@alloy-js/core";
+import {
+  code,
+  List,
+  Output,
+  refkey,
+  render,
+  StatementList,
+} from "@alloy-js/core";
 import { d } from "@alloy-js/core/testing";
 import { describe, expect, it } from "vitest";
+import { InterfaceMember, ObjectExpression } from "../src/components/index.js";
 import { MemberExpression } from "../src/components/MemberExpression.jsx";
-import {
-  InterfaceMember,
-  ObjectExpression,
-} from "../src/components/stc/index.js";
 import { VarDeclaration } from "../src/components/VarDeclaration.jsx";
 import {
   ClassDeclaration,
   ClassField,
   FunctionDeclaration,
   InterfaceDeclaration,
+  ObjectProperty,
   ParameterDescriptor,
+  SourceFile,
 } from "../src/index.js";
-import { toSourceText } from "./utils.js";
+import { assertFileContents, toSourceText } from "./utils.js";
 
 it("renders basic member expression with dot notation", () => {
   expect(
@@ -42,6 +48,19 @@ it("renders member expression with bracket notation for invalid identifiers", ()
   `);
 });
 
+it("renders member expressions with quotes", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id={`property-"name"`} />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj["property-\\"name\\""]
+  `);
+});
+
 it("handles nullish chaining", () => {
   expect(
     toSourceText(
@@ -83,6 +102,19 @@ it("ignores non-part children", () => {
   `);
 });
 
+it("ignores allows parts to define children", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part>well</MemberExpression.Part>
+        <MemberExpression.Part>hello</MemberExpression.Part>
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    well.hello
+  `);
+});
+
 it("flattens nested member expressions", () => {
   expect(
     toSourceText(
@@ -131,6 +163,46 @@ it("handles nullish chaining at multiple levels", () => {
   `);
 });
 
+it("throws an error when providing conflicting part props", () => {
+  expect(() =>
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id="property" nullish={true} args={[1, 2]} />
+      </MemberExpression>,
+    ),
+  ).toThrowError(
+    `Only one of args, id can be used for a MemberExpression part at a time`,
+  );
+});
+
+it("takes children for the id part", () => {
+  expect(
+    toSourceText(
+      <List>
+        <MemberExpression>
+          <MemberExpression.Part>child1</MemberExpression.Part>
+          <MemberExpression.Part quoteId>child2</MemberExpression.Part>
+        </MemberExpression>
+        <MemberExpression>
+          <MemberExpression.Part>child1</MemberExpression.Part>
+          <MemberExpression.Part quoteId>child2</MemberExpression.Part>
+          <MemberExpression.Part args />
+          <MemberExpression.Part quoteId nullish>
+            child3
+          </MemberExpression.Part>
+          <MemberExpression.Part args />
+          <MemberExpression.Part>["foo" + 1]</MemberExpression.Part>
+          <MemberExpression.Part args />
+        </MemberExpression>
+      </List>,
+    ),
+  ).toBe(d`
+    child1["child2"]
+    child1["child2"]()["child3"]?.().["foo" + 1]()
+  `);
+});
+
 describe("with refkeys", () => {
   it("handles symbols correctly", () => {
     const rk1 = refkey();
@@ -332,6 +404,42 @@ describe("with refkeys", () => {
       const test1_2 = 2;
     `);
   });
+
+  it("creates a full reference to the first refkey", () => {
+    const rk1 = refkey();
+    const res = render(
+      <Output>
+        <SourceFile path="source.ts">
+          <VarDeclaration name="importMe">
+            <ObjectExpression>
+              <ObjectProperty name="prop" refkey={rk1} />
+            </ObjectExpression>
+          </VarDeclaration>
+        </SourceFile>
+        <SourceFile path="index.ts">
+          <StatementList>
+            <MemberExpression>
+              <MemberExpression.Part refkey={rk1} />
+              <MemberExpression.Part id="foo" />
+            </MemberExpression>
+            <MemberExpression>
+              <MemberExpression.Part>{rk1}</MemberExpression.Part>
+              <MemberExpression.Part id="foo" />
+            </MemberExpression>
+          </StatementList>
+        </SourceFile>
+      </Output>,
+    );
+
+    assertFileContents(res, {
+      "index.ts": d`
+        import { importMe } from "./source.js";
+
+        importMe.prop.foo;
+        importMe.prop.foo;
+      `,
+    });
+  });
 });
 
 describe("with function calls", () => {
@@ -378,6 +486,20 @@ describe("with function calls", () => {
       myFunction(1, 2)?.prop
     `);
   });
+
+  it("handles function calls returning nullish correctly", () => {
+    expect(
+      toSourceText(
+        <MemberExpression>
+          <MemberExpression.Part id="myFunction" />
+          <MemberExpression.Part args={[1, 2]} nullish />
+          <MemberExpression.Part id="prop" />
+        </MemberExpression>,
+      ),
+    ).toBe(d`
+      myFunction(1, 2)?.prop
+    `);
+  });
 });
 
 describe("formatting", () => {
@@ -439,8 +561,33 @@ describe("formatting", () => {
         })
       `);
     });
-
-    it.skip("handles multiple calls", () => {
+    it("handles single calls with multiple parameters", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part
+              args={[
+                <ObjectExpression jsValue={{ x: 1 }} />,
+                <ObjectExpression jsValue={{ y: 2 }} />,
+              ]}
+            />
+          </MemberExpression>,
+          { printWidth: 12 },
+        ),
+      ).toBe(d`
+        z.object(
+          {
+            x: 1,
+          },
+          {
+            y: 2,
+          }
+        )
+      `);
+    });
+    it("handles multiple calls", () => {
       expect(
         toSourceText(
           <MemberExpression>
@@ -462,5 +609,71 @@ describe("formatting", () => {
           .partial()
       `);
     });
+
+    it("renders multiple calls on the same line when there are no breaks and they fit", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part args />
+            <MemberExpression.Part id="partial" />
+            <MemberExpression.Part args />
+            <MemberExpression.Part id="optional" />
+            <MemberExpression.Part args />
+          </MemberExpression>,
+        ),
+      ).toBe(d`
+        z.object().partial().optional()
+      `);
+    });
+    it("handles multiple calls with id parts", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part id="z1" nullish />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part
+              args={[<ObjectExpression jsValue={{ x: 1 }} />]}
+            />
+            <MemberExpression.Part id="foo" />
+            <MemberExpression.Part id="partial" />
+            <MemberExpression.Part args={[]} />
+          </MemberExpression>,
+        ),
+      ).toBe(d`
+        z.z1
+          ?.object({
+            x: 1,
+          })
+          .foo.partial()
+      `);
+    });
+
+    it("handles the first part being a call", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part args />
+            <MemberExpression.Part id="z1" nullish />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part
+              args={[<ObjectExpression jsValue={{ x: 1 }} />]}
+            />
+            <MemberExpression.Part id="foo" />
+            <MemberExpression.Part id="partial" />
+            <MemberExpression.Part args={[]} />
+          </MemberExpression>,
+        ),
+      ).toBe(d`
+        z()
+          .z1?.object({
+            x: 1,
+          })
+          .foo.partial()
+      `);
+    });
   });
 });