Skip to content

Commit 10a1a5a

Browse files
trueberrylessPrincesseuhArmandPhilippot
authored
feat: Smartypants config (#15340)
* feat(markdown/remark): Smartypants config * feat(astro): Smartypants config * test: add e2e Smartypants config tests * docs: add changesets * docs: update public Astro config types docs this file is used to autogenerate the configuration reference documentation * fix: unused import * feat: change docs version * feat: set since version in config to 6.1.0 * fix: adapt default type definition * refactor: use retext-smartypants options * fix: remove export of Smartypants type from astro core * fix: reexport Smartypants type but lintignore it * Update packages/astro/src/types/public/config.ts Co-authored-by: Armand Philippot <git@armand.philippot.eu> --------- Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com> Co-authored-by: Armand Philippot <git@armand.philippot.eu>
1 parent ee3ab41 commit 10a1a5a

11 files changed

Lines changed: 183 additions & 18 deletions

File tree

.changeset/legal-rings-rhyme.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@astrojs/markdown-remark': minor
3+
---
4+
5+
Updates `createMarkdownProcessor` to support advanced SmartyPants options.
6+
7+
The `smartypants` property in `AstroMarkdownOptions` now accepts `Smartypants` options, allowing fine-grained control over typography transformations (backticks, dashes, ellipses, and quotes).
8+
9+
```ts
10+
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
11+
12+
const processor = await createMarkdownProcessor({
13+
smartypants: {
14+
backticks: 'all',
15+
dashes: 'oldschool',
16+
ellipses: 'unspaced',
17+
openingQuotes: { double: '«', single: '' },
18+
closingQuotes: { double: '»', single: '' },
19+
quotes: false,
20+
}
21+
});
22+
```
23+
24+
For the up-to-date supported properties, check out [the `retext-smartypants` options](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields).

.changeset/red-heads-stare.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds support for advanced configuration of SmartyPants in Markdown.
6+
7+
You can now pass an options object to `markdown.smartypants` in your Astro configuration to fine-tune how punctuation, dashes, and quotes are transformed.
8+
9+
This is helpful for projects that require specific typographic standards, such as "oldschool" dash handling or localized quotation marks.
10+
11+
```js
12+
// astro.config.mjs
13+
export default defineConfig({
14+
markdown: {
15+
smartypants: {
16+
backticks: 'all',
17+
dashes: 'oldschool',
18+
ellipses: 'unspaced',
19+
openingQuotes: { double: '«', single: '' },
20+
closingQuotes: { double: '»', single: '' },
21+
quotes: false,
22+
},
23+
},
24+
});
25+
```
26+
27+
See [the `retext-smartypants` options](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields) for more information.

packages/astro/src/core/config/schemas/base.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
RemarkPlugin as _RemarkPlugin,
55
RemarkRehype as _RemarkRehype,
66
ShikiConfig,
7+
Smartypants as _Smartypants,
78
} from '@astrojs/markdown-remark';
89
import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark';
910
import { type BuiltinTheme, bundledThemes } from 'shiki';
@@ -49,6 +50,8 @@ type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>;
4950
type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>;
5051
/** @lintignore */
5152
export type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>;
53+
/** @lintignore */
54+
export type Smartypants = ComplexifyWithOmit<_Smartypants>;
5255

5356
export const ASTRO_CONFIG_DEFAULTS = {
5457
root: '.',
@@ -118,6 +121,26 @@ const highlighterTypesSchema = z
118121
.union([z.literal('shiki'), z.literal('prism')])
119122
.default(syntaxHighlightDefaults.type);
120123

124+
const quoteCharacterMapSchema = z.object({
125+
double: z.string(),
126+
single: z.string(),
127+
});
128+
129+
const smartypantsOptionsSchema: z.ZodType<Smartypants> = z.object({
130+
backticks: z.union([z.boolean(), z.literal('all')]).default(true),
131+
closingQuotes: quoteCharacterMapSchema.default({
132+
double: '”',
133+
single: '’',
134+
}),
135+
dashes: z.union([z.boolean(), z.literal('inverted'), z.literal('oldschool')]).default(true),
136+
ellipses: z.union([z.boolean(), z.literal('spaced'), z.literal('unspaced')]).default(true),
137+
openingQuotes: quoteCharacterMapSchema.default({
138+
double: '“',
139+
single: '‘',
140+
}),
141+
quotes: z.boolean().default(true),
142+
});
143+
121144
export const AstroConfigSchema = z.object({
122145
root: z
123146
.string()
@@ -385,7 +408,13 @@ export const AstroConfigSchema = z.object({
385408
.custom<RemarkRehype>((data) => data instanceof Object && !Array.isArray(data))
386409
.default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype),
387410
gfm: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.gfm),
388-
smartypants: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.smartypants),
411+
smartypants: z
412+
.union([z.boolean(), smartypantsOptionsSchema])
413+
.transform((val): false | Smartypants => {
414+
if (val === true) return smartypantsOptionsSchema.parse({});
415+
return val;
416+
})
417+
.prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants),
389418
})
390419
.prefault({}),
391420
vite: z

packages/astro/src/types/public/config.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
RemarkPlugins,
66
RemarkRehype,
77
ShikiConfig,
8+
Smartypants,
89
SyntaxHighlightConfigType,
910
} from '@astrojs/markdown-remark';
1011
import type { Config as SvgoConfig } from 'svgo';
@@ -2107,24 +2108,24 @@ export interface AstroUserConfig<
21072108
* ```
21082109
*/
21092110
gfm?: boolean;
2111+
21102112
/**
21112113
* @docs
21122114
* @name markdown.smartypants
2113-
* @type {boolean}
2115+
* @type {boolean | Smartypants}
21142116
* @default `true`
21152117
* @version 2.0.0
21162118
* @description
2117-
* Astro uses the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) by default. To disable this, set the `smartypants` flag to `false`:
2119+
* 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.
21182120
*
2119-
* ```js
2120-
* {
2121-
* markdown: {
2122-
* smartypants: false,
2123-
* }
2124-
* }
2125-
* ```
2121+
* To disable this, set the `smartypants` flag to `false`.
2122+
*
2123+
* 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).
21262124
*/
2127-
smartypants?: boolean;
2125+
smartypants?:
2126+
| boolean
2127+
| Smartypants;
2128+
21282129
/**
21292130
* @docs
21302131
* @name markdown.remarkRehype

packages/astro/test/astro-markdown-plugins.test.js

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('Astro Markdown plugins', () => {
6060

6161
const smartypantsHtml = await fixture.readFile('/with-smartypants/index.html');
6262
const $2 = cheerio.load(smartypantsHtml);
63-
assert.equal($2('p').html(), '“Smartypants” is — awesome');
63+
assert.equal($2('p').html(), '“Smartypants” is — awesome');
6464

6565
testRemark(gfmHtml);
6666
testRehype(gfmHtml, '#github-flavored-markdown-test');
@@ -82,7 +82,7 @@ describe('Astro Markdown plugins', () => {
8282
const $ = cheerio.load(html);
8383

8484
// test 1: smartypants applied correctly
85-
assert.equal($('p').html(), '“Smartypants” is — awesome');
85+
assert.equal($('p').html(), '“Smartypants” is — awesome');
8686

8787
testRemark(html);
8888
testRehype(html, '#smartypants-test');
@@ -115,7 +115,7 @@ describe('Astro Markdown plugins', () => {
115115
const html = await fixture.readFile('/with-smartypants/index.html');
116116
const $ = cheerio.load(html);
117117

118-
assert.equal($('p').html(), '"Smartypants" is -- awesome');
118+
assert.equal($('p').html(), '"Smartypants" is -- awesome ...');
119119

120120
testRemark(html);
121121
testRehype(html, '#smartypants-test');
@@ -146,6 +146,79 @@ describe('Astro Markdown plugins', () => {
146146
);
147147
});
148148
});
149+
150+
describe('Advanced Smartypants configurations', () => {
151+
it('Handles custom dashes (oldschool)', async () => {
152+
const fixture = await loadFixture({
153+
root: './fixtures/astro-markdown-plugins/',
154+
markdown: {
155+
...defaultMarkdownConfig,
156+
smartypants: { dashes: 'oldschool' },
157+
},
158+
});
159+
await fixture.build();
160+
161+
const html = await fixture.readFile('/with-smartypants/index.html');
162+
const $ = cheerio.load(html);
163+
164+
// In 'oldschool', -- becomes en-dash (–) instead of em-dash (—)
165+
assert.equal($('p').html(), '“Smartypants” is – awesome …');
166+
});
167+
168+
it('Handles disabled ellipses', async () => {
169+
const fixture = await loadFixture({
170+
root: './fixtures/astro-markdown-plugins/',
171+
markdown: {
172+
...defaultMarkdownConfig,
173+
smartypants: { ellipses: false },
174+
},
175+
});
176+
await fixture.build();
177+
178+
const html = await fixture.readFile('/with-smartypants/index.html');
179+
const $ = cheerio.load(html);
180+
181+
// Dashes should still be smart (em-dash), but dots should remain dots
182+
assert.equal($('p').html(), '“Smartypants” is — awesome ...');
183+
});
184+
185+
it('Handles custom opening and closing quotes', async () => {
186+
const fixture = await loadFixture({
187+
root: './fixtures/astro-markdown-plugins/',
188+
markdown: {
189+
...defaultMarkdownConfig,
190+
smartypants: {
191+
openingQuotes: { double: '«', single: '‹' },
192+
closingQuotes: { double: '»', single: '›' },
193+
},
194+
},
195+
});
196+
await fixture.build();
197+
198+
const html = await fixture.readFile('/with-smartypants/index.html');
199+
const $ = cheerio.load(html);
200+
201+
// Verify the custom guillemets are used
202+
assert.equal($('p').html(), '«Smartypants» is — awesome …');
203+
});
204+
205+
it('Handles backticks: "all"', async () => {
206+
const fixture = await loadFixture({
207+
root: './fixtures/astro-markdown-plugins/',
208+
markdown: {
209+
...defaultMarkdownConfig,
210+
smartypants: { backticks: 'all', quotes: false },
211+
},
212+
});
213+
await fixture.build();
214+
215+
const html = await fixture.readFile('/with-backticks/index.html');
216+
const $ = cheerio.load(html);
217+
218+
// With backticks: 'all', single and double backticks are transformed
219+
assert.ok($('p').html().includes('“Smarty”'));
220+
});
221+
});
149222
});
150223

151224
function testRehype(html, headingId) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Smartypants Backticks test
2+
3+
``Smarty''
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Smartypants test
22

3-
"Smartypants" is -- awesome
3+
"Smartypants" is -- awesome ...

packages/markdown/remark/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"remark-parse": "^11.0.0",
5151
"remark-rehype": "^11.1.2",
5252
"remark-smartypants": "^3.0.2",
53+
"retext-smartypants": "^6.2.0",
5354
"shiki": "^4.0.0",
5455
"smol-toml": "^1.6.0",
5556
"unified": "^11.0.5",

packages/markdown/remark/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ export async function createMarkdownProcessor(
9090
if (gfm) {
9191
parser.use(remarkGfm);
9292
}
93-
if (smartypants) {
94-
parser.use(remarkSmartypants);
93+
if (smartypants !== false) {
94+
const smartypantsConfig = typeof smartypants === 'object' ? smartypants : {};
95+
parser.use(remarkSmartypants, smartypantsConfig);
9596
}
9697
}
9798

packages/markdown/remark/src/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RemotePattern } from '@astrojs/internal-helpers/remote';
22
import type * as hast from 'hast';
33
import type * as mdast from 'mdast';
44
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
5+
import type { Options as SmartypantsOptions } from "retext-smartypants";
56
import type { BuiltinTheme } from 'shiki';
67
import type * as unified from 'unified';
78
import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js';
@@ -35,6 +36,8 @@ export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlug
3536

3637
export type RemarkRehype = RemarkRehypeOptions;
3738

39+
export type Smartypants = SmartypantsOptions;
40+
3841
export type ThemePresets = BuiltinTheme | 'css-variables';
3942

4043
export type SyntaxHighlightConfigType = 'shiki' | 'prism';
@@ -58,7 +61,7 @@ export interface AstroMarkdownOptions {
5861
rehypePlugins?: RehypePlugins;
5962
remarkRehype?: RemarkRehype;
6063
gfm?: boolean;
61-
smartypants?: boolean;
64+
smartypants?: boolean | SmartypantsOptions;
6265
}
6366

6467
/**

0 commit comments

Comments
 (0)