diff --git a/.changeset/legal-rings-rhyme.md b/.changeset/legal-rings-rhyme.md new file mode 100644 index 000000000000..f989a59e08cb --- /dev/null +++ b/.changeset/legal-rings-rhyme.md @@ -0,0 +1,24 @@ +--- +'@astrojs/markdown-remark': minor +--- + +Updates `createMarkdownProcessor` to support advanced SmartyPants options. + +The `smartypants` property in `AstroMarkdownOptions` now accepts `Smartypants` options, allowing fine-grained control over typography transformations (backticks, dashes, ellipses, and quotes). + +```ts +import { createMarkdownProcessor } from '@astrojs/markdown-remark'; + +const processor = await createMarkdownProcessor({ + smartypants: { + backticks: 'all', + dashes: 'oldschool', + ellipses: 'unspaced', + openingQuotes: { double: '«', single: '‹' }, + closingQuotes: { double: '»', single: '›' }, + quotes: false, + } +}); +``` + +For the up-to-date supported properties, check out [the `retext-smartypants` options](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields). diff --git a/.changeset/red-heads-stare.md b/.changeset/red-heads-stare.md new file mode 100644 index 000000000000..342f19737188 --- /dev/null +++ b/.changeset/red-heads-stare.md @@ -0,0 +1,27 @@ +--- +'astro': minor +--- + +Adds support for advanced configuration of SmartyPants in Markdown. + +You can now pass an options object to `markdown.smartypants` in your Astro configuration to fine-tune how punctuation, dashes, and quotes are transformed. + +This is helpful for projects that require specific typographic standards, such as "oldschool" dash handling or localized quotation marks. + +```js +// astro.config.mjs +export default defineConfig({ + markdown: { + smartypants: { + backticks: 'all', + dashes: 'oldschool', + ellipses: 'unspaced', + openingQuotes: { double: '«', single: '‹' }, + closingQuotes: { double: '»', single: '›' }, + quotes: false, + }, + }, +}); +``` + +See [the `retext-smartypants` options](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields) for more information. diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 9bc4d2133fb0..f9cc154f3f1e 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -4,6 +4,7 @@ import type { RemarkPlugin as _RemarkPlugin, RemarkRehype as _RemarkRehype, ShikiConfig, + Smartypants as _Smartypants, } from '@astrojs/markdown-remark'; import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark'; import { type BuiltinTheme, bundledThemes } from 'shiki'; @@ -49,6 +50,8 @@ type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>; type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>; /** @lintignore */ export type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>; +/** @lintignore */ +export type Smartypants = ComplexifyWithOmit<_Smartypants>; export const ASTRO_CONFIG_DEFAULTS = { root: '.', @@ -118,6 +121,26 @@ const highlighterTypesSchema = z .union([z.literal('shiki'), z.literal('prism')]) .default(syntaxHighlightDefaults.type); +const quoteCharacterMapSchema = z.object({ + double: z.string(), + single: z.string(), +}); + +const smartypantsOptionsSchema: z.ZodType = z.object({ + backticks: z.union([z.boolean(), z.literal('all')]).default(true), + closingQuotes: quoteCharacterMapSchema.default({ + double: '”', + single: '’', + }), + dashes: z.union([z.boolean(), z.literal('inverted'), z.literal('oldschool')]).default(true), + ellipses: z.union([z.boolean(), z.literal('spaced'), z.literal('unspaced')]).default(true), + openingQuotes: quoteCharacterMapSchema.default({ + double: '“', + single: '‘', + }), + quotes: z.boolean().default(true), +}); + export const AstroConfigSchema = z.object({ root: z .string() @@ -385,7 +408,13 @@ export const AstroConfigSchema = z.object({ .custom((data) => data instanceof Object && !Array.isArray(data)) .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype), gfm: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.gfm), - smartypants: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), + smartypants: z + .union([z.boolean(), smartypantsOptionsSchema]) + .transform((val): false | Smartypants => { + if (val === true) return smartypantsOptionsSchema.parse({}); + return val; + }) + .prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants), }) .prefault({}), vite: z diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 37f522264566..d1cfb0ff44fe 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -5,6 +5,7 @@ import type { RemarkPlugins, RemarkRehype, ShikiConfig, + Smartypants, SyntaxHighlightConfigType, } from '@astrojs/markdown-remark'; import type { Config as SvgoConfig } from 'svgo'; @@ -2040,24 +2041,24 @@ export interface AstroUserConfig< * ``` */ gfm?: boolean; + /** * @docs * @name markdown.smartypants - * @type {boolean} + * @type {boolean | Smartypants} * @default `true` * @version 2.0.0 * @description - * Astro uses the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) by default. To disable this, set the `smartypants` flag to `false`: + * Whether to use the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) to transform straight quotes into smart quotes, dashes into en/em dashes, and triple dots into ellipses. * - * ```js - * { - * markdown: { - * smartypants: false, - * } - * } - * ``` + * To disable this, set the `smartypants` flag to `false`. + * + * For more control over typography, you can instead specify a configuration object with the [properties supported by `retext-smartypants`](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields). */ - smartypants?: boolean; + smartypants?: + | boolean + | Smartypants; + /** * @docs * @name markdown.remarkRehype diff --git a/packages/astro/test/astro-markdown-plugins.test.js b/packages/astro/test/astro-markdown-plugins.test.js index 78db0e6dcbf6..d5e0f66d2ba9 100644 --- a/packages/astro/test/astro-markdown-plugins.test.js +++ b/packages/astro/test/astro-markdown-plugins.test.js @@ -60,7 +60,7 @@ describe('Astro Markdown plugins', () => { const smartypantsHtml = await fixture.readFile('/with-smartypants/index.html'); const $2 = cheerio.load(smartypantsHtml); - assert.equal($2('p').html(), '“Smartypants” is — awesome'); + assert.equal($2('p').html(), '“Smartypants” is — awesome …'); testRemark(gfmHtml); testRehype(gfmHtml, '#github-flavored-markdown-test'); @@ -82,7 +82,7 @@ describe('Astro Markdown plugins', () => { const $ = cheerio.load(html); // test 1: smartypants applied correctly - assert.equal($('p').html(), '“Smartypants” is — awesome'); + assert.equal($('p').html(), '“Smartypants” is — awesome …'); testRemark(html); testRehype(html, '#smartypants-test'); @@ -115,7 +115,7 @@ describe('Astro Markdown plugins', () => { const html = await fixture.readFile('/with-smartypants/index.html'); const $ = cheerio.load(html); - assert.equal($('p').html(), '"Smartypants" is -- awesome'); + assert.equal($('p').html(), '"Smartypants" is -- awesome ...'); testRemark(html); testRehype(html, '#smartypants-test'); @@ -146,6 +146,79 @@ describe('Astro Markdown plugins', () => { ); }); }); + + describe('Advanced Smartypants configurations', () => { + it('Handles custom dashes (oldschool)', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-markdown-plugins/', + markdown: { + ...defaultMarkdownConfig, + smartypants: { dashes: 'oldschool' }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/with-smartypants/index.html'); + const $ = cheerio.load(html); + + // In 'oldschool', -- becomes en-dash (–) instead of em-dash (—) + assert.equal($('p').html(), '“Smartypants” is – awesome …'); + }); + + it('Handles disabled ellipses', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-markdown-plugins/', + markdown: { + ...defaultMarkdownConfig, + smartypants: { ellipses: false }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/with-smartypants/index.html'); + const $ = cheerio.load(html); + + // Dashes should still be smart (em-dash), but dots should remain dots + assert.equal($('p').html(), '“Smartypants” is — awesome ...'); + }); + + it('Handles custom opening and closing quotes', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-markdown-plugins/', + markdown: { + ...defaultMarkdownConfig, + smartypants: { + openingQuotes: { double: '«', single: '‹' }, + closingQuotes: { double: '»', single: '›' }, + }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/with-smartypants/index.html'); + const $ = cheerio.load(html); + + // Verify the custom guillemets are used + assert.equal($('p').html(), '«Smartypants» is — awesome …'); + }); + + it('Handles backticks: "all"', async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-markdown-plugins/', + markdown: { + ...defaultMarkdownConfig, + smartypants: { backticks: 'all', quotes: false }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/with-backticks/index.html'); + const $ = cheerio.load(html); + + // With backticks: 'all', single and double backticks are transformed + assert.ok($('p').html().includes('“Smarty”')); + }); + }); }); function testRehype(html, headingId) { diff --git a/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-backticks.md b/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-backticks.md new file mode 100644 index 000000000000..514e476ca7bd --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-plugins/src/pages/with-backticks.md @@ -0,0 +1,3 @@ +# Smartypants Backticks test + +``Smarty'' 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 index 5d7a85ab1ac8..0538abc98008 100644 --- 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 @@ -1,3 +1,3 @@ # Smartypants test -"Smartypants" is -- awesome +"Smartypants" is -- awesome ... diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 0ba0e4f23de8..d51a0693f7f3 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -50,6 +50,7 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", + "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 634a883ebe98..4fcd8d304b72 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -90,8 +90,9 @@ export async function createMarkdownProcessor( if (gfm) { parser.use(remarkGfm); } - if (smartypants) { - parser.use(remarkSmartypants); + if (smartypants !== false) { + const smartypantsConfig = typeof smartypants === 'object' ? smartypants : {}; + parser.use(remarkSmartypants, smartypantsConfig); } } diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index 490d23fea859..fb4d9d7a95c9 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -2,6 +2,7 @@ import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type * as hast from 'hast'; import type * as mdast from 'mdast'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; +import type { Options as SmartypantsOptions } from "retext-smartypants"; import type { BuiltinTheme } from 'shiki'; import type * as unified from 'unified'; import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; @@ -35,6 +36,8 @@ export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlug export type RemarkRehype = RemarkRehypeOptions; +export type Smartypants = SmartypantsOptions; + export type ThemePresets = BuiltinTheme | 'css-variables'; export type SyntaxHighlightConfigType = 'shiki' | 'prism'; @@ -58,7 +61,7 @@ export interface AstroMarkdownOptions { rehypePlugins?: RehypePlugins; remarkRehype?: RemarkRehype; gfm?: boolean; - smartypants?: boolean; + smartypants?: boolean | SmartypantsOptions; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445726cad266..66b7d31a3985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6992,6 +6992,9 @@ importers: remark-smartypants: specifier: ^3.0.2 version: 3.0.2 + retext-smartypants: + specifier: ^6.2.0 + version: 6.2.0 shiki: specifier: ^4.0.0 version: 4.0.2