diff --git a/.changeset/angry-pots-boil.md b/.changeset/angry-pots-boil.md new file mode 100644 index 000000000000..7d367da3c1cc --- /dev/null +++ b/.changeset/angry-pots-boil.md @@ -0,0 +1,34 @@ +--- +'astro': minor +'@astrojs/mdx': minor +'@astrojs/markdown-remark': minor +--- + +Introduce a `smartypants` flag to opt-out of Astro's default SmartyPants plugin. + +```js +{ + markdown: { + smartypants: false, + } +} +``` + + #### Migration + + You may have disabled Astro's built-in plugins (GitHub-Flavored Markdown and Smartypants) with the `extendDefaultPlugins` option. This has now been split into 2 flags to disable each plugin individually: + - `markdown.gfm` to disable GitHub-Flavored Markdown + - `markdown.smartypants` to disable SmartyPants + + ```diff + // astro.config.mjs + import { defineConfig } from 'astro/config'; + + export default defineConfig({ + markdown: { + - extendDefaultPlugins: false, + + smartypants: false, + + gfm: false, + } + }); + ``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 8a8c87c4f471..bcb58f5bc03f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -785,6 +785,23 @@ export interface AstroUserConfig { * ``` */ gfm?: boolean; + /** + * @docs + * @name markdown.smartypants + * @type {boolean} + * @default `true` + * @description + * Astro uses the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) by default. To disable this, set the `smartypants` flag to `false`: + * + * ```js + * { + * markdown: { + * smartypants: false, + * } + * } + * ``` + */ + smartypants?: boolean; /** * @docs * @name markdown.remarkRehype diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 4d139d322047..3644bb2346b5 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -165,6 +165,7 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype), gfm: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.gfm), + smartypants: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), }) .default({}), vite: z diff --git a/packages/astro/test/astro-markdown-plugins.test.js b/packages/astro/test/astro-markdown-plugins.test.js index 1fc6218b3e30..63f6eda67b9b 100644 --- a/packages/astro/test/astro-markdown-plugins.test.js +++ b/packages/astro/test/astro-markdown-plugins.test.js @@ -47,25 +47,23 @@ describe('Astro Markdown plugins', () => { }); // Asserts Astro 1.0 behavior is removed. Test can be removed in Astro 3.0. - it('Still applies GFM when user plugins are provided', async () => { + it('Still applies default plugins when user plugins are provided', async () => { const fixture = await buildFixture({ markdown: { remarkPlugins: [remarkExamplePlugin], rehypePlugins: [[addClasses, { 'h1,h2,h3': 'title' }]], }, }); - const html = await fixture.readFile('/with-gfm/index.html'); - const $ = cheerio.load(html); - - // test 1: GFM autolink applied correctly - expect($('a[href="https://example.com"]')).to.have.lengthOf(1); + const gfmHtml = await fixture.readFile('/with-gfm/index.html'); + const $1 = cheerio.load(gfmHtml); + expect($1('a[href="https://example.com"]')).to.have.lengthOf(1); - // test 2: remark plugins still applied - expect(html).to.include('Remark plugin applied!'); + const smartypantsHtml = await fixture.readFile('/with-smartypants/index.html'); + const $2 = cheerio.load(smartypantsHtml); + expect($2('p').html()).to.equal('“Smartypants” is — awesome'); - // test 3: rehype plugins still applied - expect($('#github-flavored-markdown-test')).to.have.lengthOf(1); - expect($('#github-flavored-markdown-test').hasClass('title')).to.equal(true); + testRemark(gfmHtml); + testRehype(gfmHtml, '#github-flavored-markdown-test'); }); for (const gfm of [true, false]) { @@ -87,12 +85,42 @@ describe('Astro Markdown plugins', () => { expect($('a[href="https://example.com"]')).to.have.lengthOf(0); } - // test 2: remark plugins still applied - expect(html).to.include('Remark plugin applied!'); + testRemark(html); + testRehype(html, '#github-flavored-markdown-test'); + }); + } + + for (const smartypants of [true, false]) { + it(`Handles SmartyPants when smartypants = ${smartypants}`, async () => { + const fixture = await buildFixture({ + markdown: { + remarkPlugins: [remarkExamplePlugin], + rehypePlugins: [[addClasses, { 'h1,h2,h3': 'title' }]], + smartypants, + }, + }); + const html = await fixture.readFile('/with-smartypants/index.html'); + const $ = cheerio.load(html); + + // test 1: GFM autolink applied correctly + if (smartypants === true) { + expect($('p').html()).to.equal('“Smartypants” is — awesome'); + } else { + expect($('p').html()).to.equal('"Smartypants" is -- awesome'); + } - // test 3: rehype plugins still applied - expect($('#github-flavored-markdown-test')).to.have.lengthOf(1); - expect($('#github-flavored-markdown-test').hasClass('title')).to.equal(true); + testRemark(html); + testRehype(html, '#smartypants-test'); }); } }); + +function testRehype(html, headingId) { + const $ = cheerio.load(html); + expect($(headingId)).to.have.lengthOf(1); + expect($(headingId).hasClass('title')).to.equal(true); +} + +function testRemark(html) { + expect(html).to.include('Remark plugin applied!'); +} diff --git a/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-smartypants.md b/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-smartypants.md new file mode 100644 index 000000000000..5d7a85ab1ac8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-smartypants.md @@ -0,0 +1,3 @@ +# Smartypants test + +"Smartypants" is -- awesome diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 1d3caad5e7ba..053cb3efb13c 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -43,6 +43,7 @@ "rehype-raw": "^6.1.1", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", + "remark-smartypants": "^2.0.0", "shiki": "^0.11.1", "unist-util-visit": "^4.1.0", "vfile": "^5.3.2" diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 7a49498e649a..756e6d24f23b 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -186,6 +186,7 @@ function applyDefaultOptions({ recmaPlugins: options.recmaPlugins ?? defaults.recmaPlugins, remarkRehype: options.remarkRehype ?? defaults.remarkRehype, gfm: options.gfm ?? defaults.gfm, + smartypants: options.smartypants ?? defaults.smartypants, remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins, rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins, shikiConfig: options.shikiConfig ?? defaults.shikiConfig, diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index f5557b8a30a0..6637b57d7bf9 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -14,6 +14,7 @@ import type { Image } from 'mdast'; import { pathToFileURL } from 'node:url'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; +import remarkSmartypants from 'remark-smartypants'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; import { MdxOptions } from './index.js'; @@ -153,6 +154,9 @@ export async function getRemarkPlugins( if (mdxOptions.gfm) { remarkPlugins.push(remarkGfm); } + if (mdxOptions.smartypants) { + remarkPlugins.push(remarkSmartypants); + } remarkPlugins = [...remarkPlugins, ...ignoreStringPlugins(mdxOptions.remarkPlugins)]; diff --git a/packages/integrations/mdx/test/fixtures/mdx-plugins/src/pages/with-plugins.mdx b/packages/integrations/mdx/test/fixtures/mdx-plugins/src/pages/with-plugins.mdx index fcd8ae181827..8699f4a2284c 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-plugins/src/pages/with-plugins.mdx +++ b/packages/integrations/mdx/test/fixtures/mdx-plugins/src/pages/with-plugins.mdx @@ -21,3 +21,5 @@ Oh cool, more text! And section 2, with a hyperlink to check GFM is preserved: https://handle-me-gfm.com
+ +> "Smartypants" is -- awesome diff --git a/packages/integrations/mdx/test/mdx-plugins.test.js b/packages/integrations/mdx/test/mdx-plugins.test.js index f74ded3ea64e..828bcb3d5a0c 100644 --- a/packages/integrations/mdx/test/mdx-plugins.test.js +++ b/packages/integrations/mdx/test/mdx-plugins.test.js @@ -36,6 +36,19 @@ describe('MDX plugins', () => { expect(selectGfmLink(document)).to.not.be.null; }); + it('Applies SmartyPants by default', async () => { + const fixture = await buildFixture({ + integrations: [mdx()], + }); + + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + const quote = selectSmartypantsQuote(document); + expect(quote).to.not.be.null; + expect(quote.textContent).to.contain('“Smartypants” is — awesome'); + }); + it('supports custom rehype plugins', async () => { const fixture = await buildFixture({ integrations: [ @@ -88,6 +101,7 @@ describe('MDX plugins', () => { markdown: { remarkPlugins: [remarkToc], gfm: false, + smartypants: false, }, integrations: [ mdx({ @@ -129,6 +143,23 @@ describe('MDX plugins', () => { expect(selectGfmLink(document), 'Respects `markdown.gfm` unexpectedly.').to.not.be.null; } }); + + it('Handles smartypants', async () => { + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + const quote = selectSmartypantsQuote(document); + + if (extendMarkdownConfig === true) { + expect(quote.textContent, 'Does not respect `markdown.smartypants` option.').to.contain( + '"Smartypants" is -- awesome' + ); + } else { + expect(quote.textContent, 'Respects `markdown.smartypants` unexpectedly.').to.contain( + '“Smartypants” is — awesome' + ); + } + }); }); } @@ -202,6 +233,10 @@ function selectGfmLink(document) { return document.querySelector('a[href="https://handle-me-gfm.com"]'); } +function selectSmartypantsQuote(document) { + return document.querySelector('blockquote'); +} + function selectRemarkExample(document) { return document.querySelector('div[data-remark-plugin-works]'); } diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index ae3853430e71..4368d6bdd785 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -43,6 +43,7 @@ "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", + "remark-smartypants": "^2.0.0", "shiki": "^0.11.1", "unified": "^10.1.2", "unist-util-map": "^3.1.1", diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index fc134ee46114..ec25870dec27 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -24,6 +24,7 @@ import remarkUnwrap from './remark-unwrap.js'; import rehypeRaw from 'rehype-raw'; import rehypeStringify from 'rehype-stringify'; import remarkGfm from 'remark-gfm'; +import remarkSmartypants from 'remark-smartypants'; import markdown from 'remark-parse'; import markdownToHtml from 'remark-rehype'; import { unified } from 'unified'; @@ -43,6 +44,7 @@ export const markdownConfigDefaults: Omit, 'draft rehypePlugins: [], remarkRehype: {}, gfm: true, + smartypants: true, }; /** Shared utility for rendering markdown */ @@ -58,6 +60,7 @@ export async function renderMarkdown( rehypePlugins = markdownConfigDefaults.rehypePlugins, remarkRehype = markdownConfigDefaults.remarkRehype, gfm = markdownConfigDefaults.gfm, + smartypants = markdownConfigDefaults.smartypants, isAstroFlavoredMd = false, isExperimentalContentCollections = false, contentDir, @@ -75,6 +78,10 @@ export async function renderMarkdown( parser.use(remarkGfm); } + if (smartypants) { + parser.use(remarkSmartypants); + } + const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins)); const loadedRehypePlugins = await Promise.all(loadPlugins(rehypePlugins)); diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index ccab542e9463..d5133aaf0e89 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -48,6 +48,7 @@ export interface AstroMarkdownOptions { rehypePlugins?: RehypePlugins; remarkRehype?: RemarkRehype; gfm?: boolean; + smartypants?: boolean; } export interface MarkdownRenderingOptions extends AstroMarkdownOptions { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d92cb74efeab..bccb65f053db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2917,6 +2917,7 @@ importers: remark-gfm: ^3.0.1 remark-rehype: ^10.1.0 remark-shiki-twoslash: ^3.1.0 + remark-smartypants: ^2.0.0 remark-toc: ^8.0.1 shiki: ^0.11.1 unist-util-visit: ^4.1.0 @@ -2936,6 +2937,7 @@ importers: rehype-raw: 6.1.1 remark-frontmatter: 4.0.1 remark-gfm: 3.0.1 + remark-smartypants: 2.0.0 shiki: 0.11.1 unist-util-visit: 4.1.1 vfile: 5.3.6 @@ -3520,6 +3522,7 @@ importers: remark-gfm: ^3.0.1 remark-parse: ^10.0.1 remark-rehype: ^10.1.0 + remark-smartypants: ^2.0.0 shiki: ^0.11.1 unified: ^10.1.2 unist-util-map: ^3.1.1 @@ -3544,6 +3547,7 @@ importers: remark-gfm: 3.0.1 remark-parse: 10.0.1 remark-rehype: 10.1.0 + remark-smartypants: 2.0.0 shiki: 0.11.1 unified: 10.1.2 unist-util-map: 3.1.2