Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions __tests__/transformers/evaluate-expressions.test.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using mix instead of mdxish since mix returns a string already (mix is basically stringified mdxish)

Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { mdxish } from '../../lib/mdxish';
import { mix } from '../../lib';

describe('evaluateExpressions', () => {
it('should evaluate numeric operations', () => {
const content = '{1 + 2 - 1} {4 * 2 / 2}';
const html = mix(content);
expect(html).toContain('2 4');
expect(html).not.toContain('{1 + 2 - 1}');
expect(html).not.toContain('{4 * 2 / 2}');
});

it('should evaluate inline MDX expressions and replace with results', () => {
const context = {
count: 5,
Expand All @@ -9,14 +17,13 @@ describe('evaluateExpressions', () => {
};

const content = 'Total: {count * price} items for {name}';
const hast = mdxish(content, { jsxContext: context });
const html = mix(content, { jsxContext: context });

// The expressions should be evaluated and converted to text nodes
const textContent = JSON.stringify(hast);
expect(textContent).toContain('50'); // count * price = 50
expect(textContent).toContain('Test'); // name = 'Test'
expect(textContent).not.toContain('{count * price}');
expect(textContent).not.toContain('{name}');
expect(html).toContain('50'); // count * price = 50
expect(html).toContain('Test'); // name = 'Test'
expect(html).not.toContain('{count * price}');
expect(html).not.toContain('{name}');
});

it('should handle null and undefined expressions', () => {
Expand All @@ -26,15 +33,14 @@ describe('evaluateExpressions', () => {
};

const content = 'Before {nullValue} middle {undefinedValue} after';
const hast = mdxish(content, { jsxContext: context });
const html = mix(content, { jsxContext: context });

// Null/undefined should be removed (empty string)
const textContent = JSON.stringify(hast);
expect(textContent).toContain('Before');
expect(textContent).toContain('middle');
expect(textContent).toContain('after');
expect(textContent).not.toContain('nullValue');
expect(textContent).not.toContain('undefinedValue');
expect(html).toContain('Before');
expect(html).toContain('middle');
expect(html).toContain('after');
expect(html).not.toContain('nullValue');
expect(html).not.toContain('undefinedValue');
});

it('should handle object expressions', () => {
Expand All @@ -43,11 +49,18 @@ describe('evaluateExpressions', () => {
};

const content = 'Object: {obj}';
const hast = mdxish(content, { jsxContext: context });
const html = mix(content, { jsxContext: context });

// Objects should be JSON stringified (account for JSON escaping in stringified output)
const textContent = JSON.stringify(hast);
expect(textContent).toContain('{\\"a\\":1,\\"b\\":2}');
expect(html).toContain('{"a":1,"b":2}');
});

it('should evaluate the string operations', () => {
const content = 'Hello {"world".toUpperCase()} {"world".length}';
const html = mix(content);
expect(html).toContain('Hello WORLD 5');
expect(html).not.toContain('{"world".toUpperCase()}');
expect(html).not.toContain('{"world".length}');
});

it('should preserve expressions in code blocks', () => {
Expand All @@ -56,12 +69,18 @@ describe('evaluateExpressions', () => {
};

const content = '```\nconst x = {count};\n```';
const hast = mdxish(content, { jsxContext: context });
const html = mix(content, { jsxContext: context });

// Expressions in code blocks should be preserved
const textContent = JSON.stringify(hast);
expect(textContent).toContain('{count}');
expect(textContent).not.toContain('5');
expect(html).toContain('{count}');
expect(html).not.toContain('5');
});
});

it('should not evaluate operations when not in braces', () => {
const content = '1 + 2 "world".toUpperCase()';
const html = mix(content);
expect(html).toContain(content);
expect(html).not.toContain('WORLD');
expect(html).not.toContain('3');
});
});
10 changes: 9 additions & 1 deletion __tests__/transformers/preprocess-jsx-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { preprocessJSXExpressions } from '../../processor/transform/mdxish/prepr

describe('preprocessJSXExpressions', () => {
describe('Step 3: Evaluate attribute expressions', () => {
it('should evaluate JSX attribute expressions and convert them to string attributes', () => {
it('should evaluate expressions in the attributes', () => {
const content = '<div style={{ height: 1+1 + "px" }}>Link</div>';
const result = preprocessJSXExpressions(content);

expect(result).toContain('style="height: 2px"');
expect(result).not.toContain('style={{ height: 1+1 + "px" }}');
});

it('should replace variables with their values', () => {
const context = {
baseUrl: 'https://example.com',
userId: '123',
Expand Down
41 changes: 8 additions & 33 deletions processor/transform/mdxish/evaluate-expressions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { JSXContext } from './preprocess-jsx-expressions';
import type { Root } from 'mdast';
import type { MdxFlowExpression, MdxTextExpression } from 'mdast-util-mdx-expression';
import type { Plugin } from 'unified';

import { visit } from 'unist-util-visit';

import { evaluateExpression, type JSXContext } from './preprocess-jsx-expressions';

/**
* AST transformer to evaluate MDX expressions using the provided context.
* Replaces mdxFlowExpression and mdxTextExpression nodes with their evaluated values.
Expand All @@ -18,50 +19,24 @@ const evaluateExpressions: Plugin<[{ context?: JSXContext }], Root> =
const expressionNode = node as MdxFlowExpression | MdxTextExpression;
if (!('value' in expressionNode)) return;

// JSX attribute expressions are handled by preprocessing; code blocks are protected
const expression = expressionNode.value.trim();

// Skip if expression is empty (shouldn't happen, but defensive)
if (!expression) return;

try {
// Evaluate the expression using the context
const contextKeys = Object.keys(context);
const contextValues = Object.values(context);

// If no context provided, leave expression as-is
Copy link
Contributor Author

@eaglethrost eaglethrost Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problematic check that is now removed

if (contextKeys.length === 0) {
parent.children.splice(index, 1, {
type: 'text',
value: `{${expression}}`,
position: node.position,
});
return;
}

// eslint-disable-next-line no-new-func
const func = new Function(...contextKeys, `return ${expression}`);
const result = func(...contextValues);

// Convert result to text node(s)
if (result === null || result === undefined) {
// Replace with empty text node (don't remove, as it affects positioning)
parent.children.splice(index, 1, {
type: 'text',
value: '',
position: node.position,
});
return;
}
const result = evaluateExpression(expression, context);

// Extract evaluated value text
let textValue: string;
if (typeof result === 'object') {
if (result === null || result === undefined) {
textValue = '';
} else if (typeof result === 'object') {
textValue = JSON.stringify(result);
} else {
textValue = String(result).replace(/\s+/g, ' ').trim();
}

// Replace expression node with text node
// Replace expression node with text node since the expression is conceptually a text
parent.children.splice(index, 1, {
type: 'text',
value: textValue,
Expand Down
15 changes: 7 additions & 8 deletions processor/transform/mdxish/preprocess-jsx-expressions.ts
Copy link
Contributor Author

@eaglethrost eaglethrost Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving some things around for cleanliness

Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
/**
* Pre-processes JSX-like expressions before markdown parsing.
* Converts href={'value'} to href="value", evaluates {expressions}, etc.
*/

export type JSXContext = Record<string, unknown>;

// Base64 encode (Node.js + browser compatible)
function base64Encode(str: string): string {
if (typeof Buffer !== 'undefined') {
Expand Down Expand Up @@ -35,6 +28,12 @@ interface ProtectCodeBlocksResult {
protectedContent: string;
}

/**
* Pre-processes JSX-like expressions before markdown parsing.
* Converts href={'value'} to href="value", evaluates {expressions}, etc.
*/
export type JSXContext = Record<string, unknown>;

/**
* Evaluates a JavaScript expression using context variables.
*
Expand All @@ -48,7 +47,7 @@ interface ProtectCodeBlocksResult {
* // Returns: 'https://example.com/api'
* ```
*/
function evaluateExpression(expression: string, context: JSXContext): unknown {
export function evaluateExpression(expression: string, context: JSXContext) {
const contextKeys = Object.keys(context);
const contextValues = Object.values(context);
// eslint-disable-next-line no-new-func
Expand Down