D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`--` in MDX Turns Into an Em Dash After Moving to Astro? `smartypants` Is On by Default, and Only `false` Turns It Off

· · 10 min read
`--` in MDX Turns Into an Em Dash After Moving to Astro? `smartypants` Is On by Default, and Only `false` Turns It Off

I hold every article on this blog to one style rule: no em dashes in prose, in the Indonesian version or the English one. Inside code blocks, em dashes are still allowed. When the blog was about to move from Next.js to Astro, it turned out that one option could break that rule in any article with a double hyphen in its prose, without a single article file changing. The option is smartypants, and in Astro it stays on unless you turn it off.

Caught before any code existed

My plan to rebuild this site in Astro was finished and survived an adversarial review on 6 September 2026. At that point the Astro folder held nothing but a bare 7.3.1 scaffold from npm create astro, with dependencies installed and not one line of code written. What would be copied into it was 238 pairs of MDX files, pure markdown plus frontmatter, verified that same day to contain no JSX. Each pair is an Indonesian and an English version, so 238 times two gives 476 articles.

The review produced 22 findings, three of them rated severe, and all 22 were accepted after each one was checked again against its source. Of the three findings singled out as mattering most, because each would have shipped a real defect, smartypants came first. It said that smartypants is on by default in Astro's markdown processor and turns -- into an en dash or an em dash. The write-up of that finding said the no-em-dash rule would be broken silently across 476 articles at once. That number is simply the whole corpus counted above, so what it describes is the option's reach: every article would go through the same processor. The violation itself only happens in articles that have a double hyphen in their prose.

Silently, at least, is the right word. On the old Next.js site, articles were rendered by MDXRemote with remarkGfm as the only remark plugin, plus rehypeSlug and rehypePrettyCode on the rehype side. Smartypants was not on that list. The same MDX files, copied over unchanged, would come out of Astro with different punctuation if that option were left on, and no diff would show it, because the change happens in the processor at build time, not in the files.

An em dash, not an en dash

The review still said en dash or em dash, and the documentation for the markdown.smartypants option in Astro 7.3.1's type definitions names both as well. According to those docs, the default is true, and the SmartyPants formatter turns straight quotes into smart quotes, dashes into "en/em dashes", and triple dots into ellipses. The first guess was an en dash. A spike on 8 September 2026, a small experiment before the real build, measured it directly, and the answer was an em dash. That is exactly the character this blog's rule forbids.

Both possibilities do exist, but they depend on a mode. The option docs of retext-smartypants, the package that does the replacing, say dashes defaults to true, which turns two dashes into an em dash, while the 'oldschool' mode turns three dashes into an em dash and two into an en dash. The code matches that description. Any dashes value that is not 'inverted', not 'oldschool', and not false uses dashesDefault, and that is the function that replaces --:

// retext-smartypants 6.2.0, lib/index.js (excerpt)
if (settings.dashes === 'inverted') {
  methods.push(dashesInverted)
} else if (settings.dashes === 'oldschool') {
  methods.push(dashesOldschool)
} else if (settings.dashes !== false) {
  methods.push(dashesDefault)
}
 
/**
 * Transform two dashes into an em dash.
 *
 * @type {Method}
 */
function dashesDefault(_, node) {
  if (node.value === '--') {
    node.value = '—'
  }
}

Astro does not pick a mode. When the value is true, the plugin is installed with an empty options object, so dashes is never set and the last branch above is the one that runs. The same spike also recorded triple dots turning into an ellipsis and straight quotes turning curly, while Indonesian reduplicated words with a single hyphen, such as benar-benar, came through untouched.

Only prose is in range

One thing limits its reach. The plugin Astro installs, remark-smartypants 3.0.3, only visits nodes of type text, inlineCode, paragraph, heading, and tableCell, and of those, only text nodes get their value rewritten:

// remark-smartypants 3.0.3, dist/plugin.js (excerpt)
const VISITED_NODES = new Set([
    "text",
    "inlineCode",
    "paragraph",
    "heading",
    "tableCell",
]);
// ...
if (node.type === "text") {
    const processedText = allText.slice(startIndex, endIndex);
    node.value = processor2.processSync(processedText).toString();
}

Fenced code blocks, which are code nodes in the markdown tree, are not on that list. Inline code is visited, but its value is never changed. So a -- in a terminal command, whether it sits in a code block or in inline code, is left alone. What changes is plain text, which is exactly the territory this blog's rule protects.

Why only false turns it off

The comment that now sits above the fix says it must be false and must be explicit, because the default is true and only the value false turns it off. Both claims can be traced through Astro's own code.

The markdown config defaults in @astrojs/internal-helpers set smartypants: true. createMarkdownProcessor in @astrojs/markdown-remark 7.3.0 falls back to that default when the option is not set, then installs remark-smartypants as long as the value is not false. The MDX path in the same package has a similar check:

// @astrojs/markdown-remark 7.3.0, dist/mdx/create-processor.js (excerpt)
smartypants: shared.smartypants ?? options.smartypants ?? true,
// ...
if (mdxOptions.smartypants !== false) {
  const smartypantsConfig = typeof mdxOptions.smartypants === "object" ? mdxOptions.smartypants : {};
  remarkPlugins.push([remarkSmartypants, smartypantsConfig]);
}

The ?? operator only moves on to the next operand when the left side is null or undefined. An option set nowhere therefore ends up as true, while false is never skipped. The check after it uses !== false, so only false itself keeps the plugin out. Astro's docs also say that to disable it, you set smartypants to false. Writing nothing means turning it on.

This blog's articles are .mdx files, and its two content collections, one per language, load *.mdx. So the integration that decides is @astrojs/mdx 8.0.0. It uses markdown.processor from the Astro config for MDX files as long as extendMarkdownConfig is on, and that option defaults to true. If mdx() does not set smartypants itself, the value is taken from that processor. If no processor is configured at all, the integration uses satteri(), and a comment in Astro 7.3.1's config schema says that active processor supplies a default with smart punctuation on. Whichever processor you end up with, the starting point is on.

One note specific to Astro 7.3.1: putting smartypants directly under markdown makes the config validator warn that the option is deprecated. The warning suggests moving it onto the processor instead, for example unified({ gfm: false, smartypants: false }) from @astrojs/markdown-remark, or satteri({ features: { gfm: false, smartPunctuation: false } }), and says the old option will be removed in a future major.

The fix

The plan was edited to set smartypants: false and to grep the built HTML as a gate. This site's processor is unified() plus rehype-pretty-code rather than Sätteri, for a separate reason recorded in the plan: Sätteri silently ignores rehype plugins in Astro 7, and the blog CSS depends on the figure and data-line shape. So the false lives inside unified(), with the spike's measurements as its comment:

// astro.config.mjs (trimmed to the relevant part, comment translated from Indonesian)
import { unified } from '@astrojs/markdown-remark';
 
export default defineConfig({
  // ...
  integrations: [mdx(), react()],
 
  markdown: {
    syntaxHighlight: false,
    processor: unified({
      // MUST be false and must be explicit. The default is true, and only the
      // value false turns it off. Measured in the spike: "--" becomes an em dash
      // (not an en dash as first guessed), triple dots become an ellipsis,
      // straight quotes turn curly. Reduplicated words with a single hyphen are safe.
      smartypants: false,
      // ...
    }),
  },
  // ...
});

That block went in with the foundation commit of the Astro repo on 8 September 2026 at 09:38:55. The commit message states that the config already reflects the spike results rather than the draft plan, and gives the reason briefly: the default is true and it turns two hyphens into an em dash.

Because mdx() is installed with no options, extendMarkdownConfig stays at its default, MDX files use the same processor, and the MDX integration picks up false from it. The versions pinned in package.json are astro 7.3.1, @astrojs/mdx 8.0.0, and @astrojs/markdown-remark 7.3.0, while the lockfile still contains remark-smartypants 3.0.3 and retext-smartypants 6.2.0. The packages are still installed. What is switched off is their use.

What was proven, and what is not a gate yet

That same day, 476 article pages plus the indexes in both languages were standing in Astro, with an 8 second build. The check at that stage stated that smartypants was proven off. The corpus held 26 em dashes, all of them inside code blocks and none in prose, and three of those have to stay, because the em dash is the very value the code example renders. The commit message for that stage recorded the same thing from the pipeline's side: zero typographic marks produced. That commit is stamped 10:59:51, so from the foundation commit at 09:38:55 the gap is 1 hour 20 minutes 56 seconds. On the same day, the blog's main domain started being served by the Astro site.

What does not exist yet, I should state just as plainly. The plan asked for a grep of the built HTML as a gate, but what is on record is the result of a sweep at that stage, not a step that runs on every build. The prebuild hook in package.json runs an env check and then the graph builder, postbuild checks the server bundle, and the em dash gate written in the README sweeps the src/ folder, not the build output. So what keeps smartypants off from one build to the next is the config line above. That line lives inside unified(), while satteri() brings its own default with smart punctuation on, so swapping the processor would throw the false away along with it. If the HTML sweep is ever turned into an automatic step, it has to tell prose apart from code blocks, because those 26 legitimate em dashes are printed on the page too.

The takeaway

Moving content to another renderer is not only about components and routes. Punctuation is part of the output, and an option that changes it can be on without a single line in the repo naming it. Before copying content into a new renderer, read the defaults of its markdown processor and explicitly turn off every option that rewrites text. In Astro, explicit means literally false, because the check is !== false.

The en-or-em question is not a matter of taste either. This blog's rule bans the em dash, so which character comes out decides whether the rule is broken, and documentation that names both possibilities cannot settle that. The spike settled it.

Finally, config states intent, and the built HTML shows the result. The sweep during the migration proved smartypants was off that day. Turning it into a step that runs on every build is separate work, and until that work is done, the sweep should not be called a gate.