Skip to content

fix: stabilies heading slug generations#1334

Closed
maximilianfalco wants to merge 2 commits intonextfrom
falco/stabilise-heading-slugs
Closed

fix: stabilies heading slug generations#1334
maximilianfalco wants to merge 2 commits intonextfrom
falco/stabilise-heading-slugs

Conversation

@maximilianfalco
Copy link
Copy Markdown
Contributor

@maximilianfalco maximilianfalco commented Feb 11, 2026

PR App Fix RM-XYZ

🧰 Changes

Replaces rehype-slug with a custom slug plugin (mdxish-slug) that reconstructs {user.*} text from <variable> HAST elements when generating heading IDs, so ## Hello {user.name} produces slug hello-username instead of hello.

For legacy <<VARIABLE>> syntax (resolved before the pipeline), the consumer extracts heading texts from the raw markdown and passes them as sourceHeadingTexts, letting slugs stay stable regardless of variable values.

🧬 QA & Testing

visit(tree, 'element', (node: Element) => {
if (isHeading(node) && !node.properties.id) {
const text =
sourceHeadingTexts && headingIndex < sourceHeadingTexts.length
Copy link
Copy Markdown
Contributor

@eaglethrost eaglethrost Feb 12, 2026

Choose a reason for hiding this comment

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

Can you put a comment here explaining that sourceHeadingTexts is for legacy variables, and the other is for mdx variables 🙏

return /^h[1-6]$/.test(node.tagName);
}

/** Extract text content from a HAST node, reconstructing {user.*} from <variable> elements. */
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.

Also explain that at this point the {user.*} is converted to a variable node, and that we're try to get back the variable name from it

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.

I think the tests here can be in a new file specific to test the heading id's

});

describe('heading slugs', () => {
it('should generate slugs from variable names, not resolved values', () => {
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.

Can we also add tests where there's other {} expressions? To verify that they don't get picked up as variables & cause issues

@eaglethrost
Copy link
Copy Markdown
Contributor

Code looks good for the approach you're trying to do! I think we should verify if we should go with this approach cause I agree needing to pass in the legacy variables list through sourceHeadingText is not ideal since we'd have to do another doc traversal, but it should work.

@maximilianfalco
Copy link
Copy Markdown
Contributor Author

maximilianfalco commented Feb 12, 2026

the current approach works but is a bit hacky imo...

legacy variables with the <<>> syntax is preresolved and expanded even BEFORE hitting the pipeline here over in the main application, meaning the mdxish pipeline doesnt have any data about what the actual heading looks like. even if the original heading was # Good<<DAYTIME>>, mdxish would only ever know # Goodafternoon assuming afternoon is the var value

hence, why we require passing the new sourceHeadingTexts argument which involves extracting headings from the original body and passing them through (this is in my own branch)

however, this is not the case for the modern variables. superhub variables with the {user.*} syntax can be resolved nicely because they are resolved WITHIN the pipeline to proper Variable MDAST nodes. meaning we can reverse engineer them back to their original state.

ideally, we could convert all legacy variables into the superhub one before entering the mdxish pipeline, so we can properly reverse engineer all variables without needing to pass the sourceHeadingTexts argument. BUT, when playing around, I saw there was some differences in the naming rules of legacy and superhub variables one of which is that legacy allows whitespace and the superhub syntax does not. we can still normalize them but it would mean changing the var names, so like this

<<with space>> -> {user.with_space}

if this renaming is acceptable, then i would generally prefer this option

cc @eaglethrost @kevinports @rafegoldberg

@kevinports
Copy link
Copy Markdown
Contributor

kevinports commented Feb 12, 2026

legacy variables with the <<>> syntax is preresolved and expanded even BEFORE hitting the pipeline here over in the main application, meaning the mdxish pipeline doesnt have any data about what the actual heading looks like.

Absolutely not what we want. The new editor is only going to receive resolved values as text instead of the correct ast nodes.

ideally, we could convert all legacy variables into the superhub one before entering the mdxish pipeline, so we can properly reverse engineer all variables without needing to pass the sourceHeadingTexts argument.

If we're talking ideal path the <<>> syntax should have a tokenizer that creates the correct variable or glossary node within the pipeline right?

Regardless this should absolutely be happening somewhere IN the pipeline, even if you think the best approach is to move preprocessing into the pipeline but before the remark phase. Currently it looks like we do it on the application side before calling mdxish? https://github.com/readmeio/readme/blob/787c869a9836d30bcad115984d7758b8fb4dcd8b/packages/react/src/ui/RDMD/RMDXISH.tsx#L78-L82

Consumers shouldn't have to handle this.

we can still normalize them but it would mean changing the var names, so like this

<> -> {user.with_space}
if this renaming is acceptable, then i would generally prefer this option

Not the end of the world if we approached it this way BUT:

  1. Probably worth referencing the legacy engine's parsing logic for this and careful to handle glossary: terms correctly.
  2. Regarding how legacy vs. superhub allows whitespace -- looking at this code in the mdx pipeline, it seems like if there is whitespace in the property, it falls back to bracket syntax eg {user['with space'}}
    const value = validIdentifier ? `user.${identifier}` : `user[${JSON.stringify(identifier)}]`;

@maximilianfalco
Copy link
Copy Markdown
Contributor Author

Regarding how legacy vs. superhub allows whitespace -- looking at this code in the mdx pipeline, it seems like if there is whitespace in the property, it falls back to bracket syntax eg {user['with space'}}

I tried doing this but even in mdx, if I have a variable say like {user.with name} the page wont save correctly, is this expected?

cc @kevinports

@HugoHSun
Copy link
Copy Markdown
Contributor

Regarding how legacy vs. superhub allows whitespace -- looking at this code in the mdx pipeline, it seems like if there is whitespace in the property, it falls back to bracket syntax eg {user['with space'}}

I tried doing this but even in mdx, if I have a variable say like {user.with name} the page wont save correctly, is this expected?

cc @kevinports

Yea it shouldn't save in MDX since anything between {} is interpreted as an JS expression. user.with name is not a valid JS expression. So for weird variable names the bracket notation is probably the way to go.

An issue currently is that the MDX editor serialises standalone bracket notation variables in a weird way, e.g. {user["name"]} turns into {user.{user["name"]}}. I'm gonna address it in #1320

@eaglethrost
Copy link
Copy Markdown
Contributor

I think we should move this PR to #1340

eaglethrost added a commit that referenced this pull request Feb 20, 2026
[![PR App][icn]][demo] | Fix CX-2929
:-------------------:|:----------:

## 🧰 Changes

Retake of #1334 because now we
have legacy variable tokenizer ready and no longer need to pass in the
legacy variables mapping.

Refactors the slug generation logic for headings (which will be used for
anchor section links) in MDXish so that if it contains a legacy <<>> or
{} variable, the generated slug will use the variable name instead of
the variable value. The change is in the heading id generation, which
later will be converted to slugs in `renderMdxish`.

We wanted to this to make the anchor slug more stable. In legacy, the
variable part will actually get dropped in the slug generation, so it
can unexpectedly clash with other headings & look incomplete. In mdxish,
before it was resolving the variable value in the slugs, which means the
slugs can change if the value change, and a hardcoded link can get
outdated without the user knowing.

This is the first step in addressing this Akamai QA issue:
https://linear.app/readme-io/issue/CX-2867/links-to-section-headings-that-involve-variables-personalized-docs

**Note:**
This change will modify the anchor slug url if it has variables in them,
which would make hardcoded links not work for those cases. But given
MDXish hasn't been on for a long time, hopefully there's not much
customers doing that yet.

## 🧬 QA & Testing

1. I think it's best to test this by locally linking this branch to
readme
2. In the [MDXish RDMD
here](https://github.com/readmeio/readme/blob/169a94b6372bd68282d78cd8ea70d9f14ce78fa3/packages/react/src/ui/RDMD/RMDXISH.tsx#L79),
remove the `opts.mdxish` part to avoid preprocessing legacy variables
3. In an mdxish project in the site, create several H1-H3 headings that
contain legacy <<>> and {} variables in them
4. When hovering the anchor logo next to the headings, look at the
bottom left of your screen for the slug. The slug should use the
variable name and not the value

[demo]: https://markdown-pr-PR_NUMBER.herokuapp.com
[prod]: https://SUBDOMAIN.readme.io
[icn]:
https://user-images.githubusercontent.com/886627/160426047-1bee9488-305a-4145-bb2b-09d8b757d38a.svg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants