Skip to content

fix: propagate safeMode thru components - #1593

Merged
jboyens merged 2 commits into
nextfrom
jboyens/chore-pr-feedback/kzwzmouoyxpk
Aug 12, 2026
Merged

fix: propagate safeMode thru components#1593
jboyens merged 2 commits into
nextfrom
jboyens/chore-pr-feedback/kzwzmouoyxpk

Conversation

@jboyens

@jboyens jboyens commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
🎫 Resolve ISSUE_ID

🎯 What does this PR do?

  • ensure that safeMode is supported throughout the stack
  • verify that when enabled, JS is not executed in safeMode

🧪 QA tips

  • [ ]

📸 Screenshot or Loom

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds the public safeMode option to MDAST processing. Safe mode flattens JSX attribute expressions into source text before evaluation. HAST component conversion now propagates this option. Tag extraction paths always enable safe mode. Tests cover expression evaluation, global and side-effect prevention, component conversion, image transformers, and tag-name extraction.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

__tests__/lib/hast.test.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: Error while loading rule '@vitest/unbound-method': You have used a rule which requires type information, but don't have parserOptions set to generate type information for this file. See https://tseslint.com/typed-linting for enabling linting with type information.
Parser: /node_modules/@typescript-eslint/parser/dist/index.js
Occurred while linting /tests/lib/hast.test.ts
at throwError (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:40:11)
at getParserServices (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:29:9)
at create (/node_modules/@typescript-eslint/eslint-plugin/dist/rules/unbound-method.js:138:55)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at create (/node_modules/@vitest/eslint-plugin/dist/index.cjs:6355:35)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at createRuleListeners (/node_modules/eslint/lib/linter/linter.js:895:21)
at /node_modules/eslint/lib/linter/linter.js:1066:110
at Array.forEach ()
at runRules (/node_modules/eslint/lib/linter/linter.js:1003:34)

__tests__/lib/mdxishTags.test.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: Error while loading rule '@vitest/unbound-method': You have used a rule which requires type information, but don't have parserOptions set to generate type information for this file. See https://tseslint.com/typed-linting for enabling linting with type information.
Parser: /node_modules/@typescript-eslint/parser/dist/index.js
Occurred while linting /tests/lib/mdxishTags.test.ts
at throwError (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:40:11)
at getParserServices (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:29:9)
at create (/node_modules/@typescript-eslint/eslint-plugin/dist/rules/unbound-method.js:138:55)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at create (/node_modules/@vitest/eslint-plugin/dist/index.cjs:6355:35)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at createRuleListeners (/node_modules/eslint/lib/linter/linter.js:895:21)
at /node_modules/eslint/lib/linter/linter.js:1066:110
at Array.forEach ()
at runRules (/node_modules/eslint/lib/linter/linter.js:1003:34)

__tests__/lib/tags.test.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: Error while loading rule '@vitest/unbound-method': You have used a rule which requires type information, but don't have parserOptions set to generate type information for this file. See https://tseslint.com/typed-linting for enabling linting with type information.
Parser: /node_modules/@typescript-eslint/parser/dist/index.js
Occurred while linting /tests/lib/tags.test.ts
at throwError (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:40:11)
at getParserServices (/node_modules/@typescript-eslint/utils/dist/eslint-utils/getParserServices.js:29:9)
at create (/node_modules/@typescript-eslint/eslint-plugin/dist/rules/unbound-method.js:138:55)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at create (/node_modules/@vitest/eslint-plugin/dist/index.cjs:6355:35)
at Object.create (/node_modules/@typescript-eslint/utils/dist/eslint-utils/RuleCreator.js:39:20)
at createRuleListeners (/node_modules/eslint/lib/linter/linter.js:895:21)
at /node_modules/eslint/lib/linter/linter.js:1066:110
at Array.forEach ()
at runRules (/node_modules/eslint/lib/linter/linter.js:1003:34)

  • 1 others

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@processor/transform/flatten-attribute-expressions.ts`:
- Around line 16-23: Update flattenAttributeExpressions so flattened expression
values retain their original source provenance or are marked to bypass
subsequent decodeHTMLStrict processing in getAttrs(), preserving character
references such as String("&") exactly. Add a regression test asserting the
resulting attribute source remains String("&").
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c396c049-b918-4986-b5b4-bc325e5f060f

📥 Commits

Reviewing files that changed from the base of the PR and between 279c200 and 31b6af4.

📒 Files selected for processing (11)
  • __tests__/lib/hast.test.ts
  • __tests__/lib/mdxishTags.test.ts
  • __tests__/lib/tags.test.ts
  • __tests__/transformers/readme-components.test.ts
  • lib/ast-processor.ts
  • lib/hast.ts
  • lib/mdxishTags.ts
  • lib/tags.ts
  • processor/transform/flatten-attribute-expressions.ts
  • processor/transform/index.ts
  • processor/utils.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • readmeio/ai (manual)
  • readmeio/gitto (manual)
  • readmeio/markdown (manual)
  • readmeio/readme (manual)

Comment on lines +16 to +23
const flattenAttributeExpressions = (): Transform => tree => {
visit(tree, isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => {
node.attributes.forEach(attr => {
if (!('name' in attr)) return;
if (attr.value === null || typeof attr.value === 'string') return;

attr.value = attr.value.value;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve character references in flattened expression source.

Line 22 converts expression source into a normal string. getAttrs() then applies decodeHTMLStrict() to that value. For icon={String("&")}, safe mode returns String("&") instead of the original expression source.

Keep provenance for flattened expression values, or bypass HTML decoding for them. Add a regression test that asserts the exact source text remains String("&").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@processor/transform/flatten-attribute-expressions.ts` around lines 16 - 23,
Update flattenAttributeExpressions so flattened expression values retain their
original source provenance or are marked to bypass subsequent decodeHTMLStrict
processing in getAttrs(), preserving character references such as
String("&") exactly. Add a regression test asserting the resulting attribute
source remains String("&").

@jboyens
jboyens merged commit 87761af into next Aug 12, 2026
8 checks passed
@jboyens
jboyens deleted the jboyens/chore-pr-feedback/kzwzmouoyxpk branch August 12, 2026 18:56
eaglethrost added a commit that referenced this pull request Aug 13, 2026
| 🎫 Resolve ISSUE_ID |
| :-----------------: |

## 🎯 What does this PR do?

- **Context.** `getAttrs()` resolves attribute expressions with `new
Function`, and it does so as a side effect of *building* the tree. Every
`mdast()`, `hast()`, `tags()` and `exports()` call therefore executes
whatever sits in `attr={…}`, no matter what the caller does with the
result — `tags()` only wants component names and still runs the code.
- **Solution.** New `evaluateLiteralExpression()` resolves those
expressions by folding the acorn AST instead of executing it. It's an
allowlist of literal node types (literals, arrays, objects,
substitution-free templates, `+ - * /`, unary `-`, `undefined`);
everything else throws and the caller keeps the raw source, which is the
fallback a thrown `evaluate()` already had. `evaluate()` itself is
untouched and now has no ungated callers — the four that remain are
already behind mdxish's `safeMode`.
- **Why `evaluate()` was there.** `getAttrs()` used to `JSON.parse` the
expression source. That can't take the forms JSX attributes actually use
— `{ textAlign: "left" }`, `{ color: 'red' }` — and it threw uncaught,
so those crashed the parse. It worked in practice only because mdxish's
preprocessor pre-evaluated attributes before `getAttrs` ever saw them.
When that preprocessing was removed (it was destroying the authored
source, `{1 + 1}` → `"2"`), `evaluate()` filled the gap. Folding covers
the same forms without running anything.
- **Limitation** Expressions that need a scope or call a method:
`{item.url}`, `{"a".toUpperCase()}`, `{['a','b'].join('/')}`, no longer
resolve in `getAttrs`. **mdxish rendering is unaffected** since
`resolveDeferredAttributeExpressionProps` still resolves those
downstream with scope. What changes is anything reading attributes
straight off the tree: (`mdast`, `hast`, `tags`, `exports`), and the
mdxish editor AST.
- For the editor side, this affects components that's transformers use
`getAttrs` such as images, callouts, etc. This means those components
containing function calls in the attributes won't get WYSIWYG, and if
pasted, not evaluate. E.g. Pasting "<Image
src="https://files.readme.io/b8674d6-pizzabro.jpg" align="center"
caption={"aaa".toUpperCase()} />" wouldn't execute the `toUpperCase()`
anymore. However, this is same behaviour as the old editor so I think
it's fine to have, and if we manually type the expressions in the editor
it would still get retained.
- **Overlap with #1593.** Since folding never runs code, `safeMode` no
longer changes the *outcome* for attribute expressions — both settings
yield the source string, so its two "evaluates by default" tests are
flipped here. `safeMode` still gates the ESM and expression
transformers, and `flattenAttributeExpressions` still flattens earlier
and more explicitly; the two just overlap on attributes now.

## 🧪 QA tips

- [ ] Paste the block below into a page and diff against `next`. **In
the MDXish renderer everything should be identical, above and below the
divider** — rendering is unchanged. In the MDX renderer, and in the
MDXish editor's attribute fields, the cases below the divider should now
show the authored source instead of a computed value.

```markdown
<!-- Still resolves — unchanged from `next`, in both engines -->

<Image src="/x.png" border={true} width={100} />

<Callout icon={undefined} theme={"info"}>Quoted string, either quote style</Callout>

<Callout icon={`plain`}>Template literal with no substitution</Callout>

<Callout icon={1 + 1}>Arithmetic between literals</Callout>

<Anchor href={'https://' + 'example.com' + '/x'}>Concatenated literals</Anchor>

<Foo align={["left", null, "center"]} style={{ textAlign: "left", color: 'red' }} />

<!-- ————— No longer resolves: renders the source text as written ————— -->

<Callout icon={"hi".toUpperCase()}>Method call on a literal</Callout>

<Anchor href={['a', 'b'].join('/')}>Method call on an array</Anchor>

<Callout icon={item.url}>Identifier / member access (already unresolved on `next`)</Callout>
```

- [ ] Confirm nothing executes: `<Callout icon={process.env.HOME} />`
must render the source, not your home directory.
`__tests__/lib/utils/literal-expression.test.ts` asserts this along with
shell-command and sandbox-escape payloads; each of those tests fails if
`evaluate()` is put back in `getAttrs`.

## 📸 Screenshot or Loom

Changes in the editor (left is prod, right is the new behaviour):



https://github.com/user-attachments/assets/b70cb312-16b8-4708-ad7c-acb7601e53c8



https://github.com/user-attachments/assets/f7525662-3a13-405e-b775-e265fb83c724

While these are behaviour changes, I think it is still acceptable & made
sense. The first video behaviour change I would argue is the more
correct one, whilst the second one we can revisit the serializer if we
want keep the WYSIWSYG-ness
rafegoldberg pushed a commit that referenced this pull request Aug 14, 2026
## Version 15.0.2
### 🛠 Fixes & Updates

* **editor:** add spacing between items inside of tabs ([#1590](#1590)) ([a1dbd12](a1dbd12))
* propagate safeMode thru components ([#1593](#1593)) ([87761af](87761af))
* resolve attribute expressions without executing them ([#1585](#1585)) ([50441cf](50441cf))
* support structured user variables ([#1488](#1488)) ([279c200](279c200)), closes [mdx-renderer#317](https://github.com/readmeio/mdx-renderer/issues/317)

<!--SKIP CI-->
@rafegoldberg

Copy link
Copy Markdown
Collaborator

This PR was released!

🚀 Changes included in v15.0.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants