How to Use MDX Expressions in DESIGN.md Files with the `spec` Command
MDX expressions in spec.mdx files are evaluated by the npx @google/design.md spec command using a sandboxed compiler that replaces JavaScript expressions with their computed markdown output, generating the final docs/spec.md specification.
The google-labs-code/design.md repository provides a CLI tool that transforms MDX source files into human-readable specifications. When you run the spec command, it processes embedded MDX expressions in your spec.mdx file to dynamically generate content based on your configuration and reusable renderer functions.
Understanding the MDX Compilation Pipeline
The spec command triggers a three-stage pipeline that converts MDX syntax into static markdown. Understanding these stages helps you write effective expressions and debug compilation issues.
Entry Point and Scope Generation
In packages/cli/src/linter/spec-gen/generate.ts, the generation process begins by reading the spec.mdx source file and constructing a scope object. This scope binds two critical resources: the SPEC_CONFIG configuration data and a set of pure-function renderers imported from renderers.ts.
The generate.ts module orchestrates the workflow:
- Reads
spec.mdx(referenced asMDX_PATH) - Builds a scope containing
SPEC_CONFIGand renderer functions - Calls
compileMdx(source, scope)to transform the MDX into plain markdown - Writes the generated output to
docs/spec.md
AST Parsing and Expression Evaluation
The packages/cli/src/linter/spec-gen/compiler.ts module handles the actual MDX processing. It uses the unified ecosystem with remark-parse and remark-remark-mdx to parse the source into an AST.
When the compiler encounters expression nodes, it evaluates them using a sandboxed Function constructor:
- Inline expressions (
mdxTextExpressionnodes like{sectionOrderList()}) execute within the scope and return strings that replace the node directly - Block expressions (
mdxFlowExpressionnodes like{colorsExample()}) execute similarly but wrap results in HTML nodes to preserve markdown block formatting
Renderer Functions
The packages/cli/src/linter/spec-gen/renderers.ts file contains pure functions that generate reusable markdown fragments. Each renderer receives the fully typed SpecConfig object and returns a markdown string. These functions are exposed to the MDX scope, allowing you to call them directly within curly braces.
How MDX Expressions Are Evaluated
The compiler distinguishes between two expression types based on their position in the document:
| Expression Type | Node Type | Output Handling | Use Case |
|---|---|---|---|
| Inline | mdxTextExpression |
Replaced with plain text node | Dynamic values inside paragraphs |
| Block | mdxFlowExpression |
Wrapped in HTML node preserving markdown | Tables, code blocks, lists |
Because the evaluator uses new Function(...scope), expressions can only reference variables explicitly provided in the scope. You cannot import arbitrary modules or access Node.js globals outside the defined scope.
Practical Examples
Inline Expressions for Dynamic Values
Use curly braces within text to inject configuration values or simple renderer output:
# DESIGN.md Format
The current spec version is **{SPEC_VERSION}**.
{sectionOrderList()}
When processed, {SPEC_VERSION} pulls from SPEC_CONFIG, while {sectionOrderList()} calls the renderer to generate a numbered list:
# DESIGN.md Format
The current spec version is **alpha**.
1. Overview
2. Colors
3. Typography
4. Layout
5. Elevation & Depth
6. Shapes
7. Components
8. Do's and Don'ts
Block-Level Expressions for Complex Content
For tables, code blocks, or multi-line content, use block expressions that occupy their own line:
## Colors
{colorsExample()}
The colorsExample() renderer returns a fenced code block with YAML front matter. The compiler preserves the markdown formatting, producing:
## Colors
```yaml
---
version: alpha
name: Daylight Prestige
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: 600
lineHeight: 1.1
letterSpacing: -0.02em
---
### Creating Custom Renderers
To add domain-specific content, extend the rendering system in three steps.
First, define the function in [`packages/cli/src/linter/spec-gen/renderers.ts`](https://github.com/google-labs-code/design.md/blob/main/packages/cli/src/linter/spec-gen/renderers.ts):
```typescript
export function myCustomBanner(cfg: SpecConfig): string {
return `> **NOTE:** This spec was generated on ${new Date().toDateString()} using version ${cfg.SPEC_VERSION}.`;
}
Second, expose it in packages/cli/src/linter/spec-gen/generate.ts by adding to the scope:
myCustomBanner: () => renderers.myCustomBanner(cfg),
Third, invoke it in spec.mdx:
{myCustomBanner()}
The compiled output will contain the dynamic banner with the current date and version string.
Running the spec Command
Execute the generation process from your project root:
npx @google/design.md spec
This command reads the default spec.mdx path, compiles the MDX expressions using the pipeline described above, and writes the final specification to docs/spec.md. The command also supports outputting to stdout or custom file paths as documented in the repository README under the spec heading.
Summary
- MDX expressions in
spec.mdxenable dynamic content generation by executing JavaScript within a sandboxed scope during thespeccommand execution. - The compilation pipeline in
packages/cli/src/linter/spec-gen/consists ofgenerate.tsfor orchestration,compiler.tsfor AST processing, andrenderers.tsfor markdown generation functions. - Two expression types exist: inline expressions (
mdxTextExpression) for text replacement and block expressions (mdxFlowExpression) for complex markdown structures. - Custom renderers extend functionality by adding pure functions to
renderers.tsand exposing them in the scope withingenerate.ts. - The
npx @google/design.md speccommand processes these expressions to produce the final human-readabledocs/spec.mdspecification.
Frequently Asked Questions
What is the difference between inline and block MDX expressions?
Inline expressions use mdxTextExpression nodes and work within paragraphs or headings, returning text that replaces the curly braces directly. Block expressions use mdxFlowExpression nodes and stand alone on their own lines, returning markdown that the compiler wraps in HTML nodes to preserve formatting. According to the source code in compiler.ts, both execute within the same sandboxed scope but differ in how the AST transformer handles their output.
Can I import external modules in MDX expressions?
No. The compiler in packages/cli/src/linter/spec-gen/compiler.ts uses a new Function(...scope) constructor that limits expression evaluation to the provided scope variables. You cannot use import, require, or reference Node.js globals. All data must come from SPEC_CONFIG or renderer functions exposed in the scope by generate.ts.
How do I add custom rendering logic?
Create a pure function in packages/cli/src/linter/spec-gen/renderers.ts that accepts the SpecConfig type and returns a markdown string. Then expose that function in the scope object within packages/cli/src/linter/spec-gen/generate.ts. Finally, call the function using curly braces in spec.mdx. The function will execute during compilation and its return value will appear in the generated specification.
Where does the spec command write its output?
By default, the spec command writes the compiled markdown to docs/spec.md in your project directory. This behavior is controlled by the generate.ts entry point, which reads from spec.mdx (referenced as MDX_PATH) and outputs the processed content. You can verify the output path and override options in the README documentation under the spec command section.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →