Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/legal-rings-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@astrojs/markdown-remark': minor
---

Updates `createMarkdownProcessor` to support advanced SmartyPants options.

The `smartypants` property in `AstroMarkdownOptions` now accepts a `SmartypantsOptions` object, 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,
}
});
27 changes: 27 additions & 0 deletions .changeset/red-heads-stare.md
Original file line number Diff line number Diff line change
@@ -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 `markdown.smartypants` reference documentation](https://docs.astro.build/en/reference/configuration-reference/#markdownsmartypants) for more information.
28 changes: 27 additions & 1 deletion packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,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.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()
Expand Down Expand Up @@ -385,7 +405,13 @@ export const AstroConfigSchema = z.object({
.custom<RemarkRehype>((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) => {
if (val === true) return smartypantsOptionsSchema.parse({});
return val;
})
.default(smartypantsOptionsSchema.parse({})),
})
.prefault({}),
vite: z
Expand Down
100 changes: 90 additions & 10 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { OutgoingHttpHeaders } from 'node:http';
import type { RemotePattern } from '@astrojs/internal-helpers/remote';
import type {
QuoteCharacterMap,
RehypePlugins,
RemarkPlugins,
RemarkRehype,
Expand Down Expand Up @@ -2040,24 +2041,103 @@ export interface AstroUserConfig<
* ```
*/
gfm?: boolean;

/**
* @docs
* @name markdown.smartypants
* @type {boolean}
* @type {boolean | SmartypantsOptions}
* @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 listed below.
*/
smartypants?: boolean;
smartypants?:
| boolean
| {
/**
* @docs
* @name markdown.smartypants.backticks
* @kind h4
* @type {boolean | 'all'}
* @default `true`
* @version 6.1.0
* @description
* Whether to transform backticks into smart quotes.
* When set to `'all'`, double backticks are converted to double quotes and single backticks are converted to single quotes.
*/
backticks?: boolean | 'all';

/**
* @docs
* @name markdown.smartypants.quotes
* @kind h4
* @type {boolean}
* @default `true`
* @version 6.1.0
* @description
* Whether to transform straight quotes into curly "smart" quotes.
* * Note: If `backticks` is set to `'all'`, this should typically be set to `false`.
*/
quotes?: boolean;

/**
* @docs
* @name markdown.smartypants.dashes
* @kind h4
* @type {boolean | 'oldschool' | 'inverted'}
* @default `true`
* @version 6.1.0
* @description
* How to transform dashes.
* - `true`: turns two dashes into an em dash.
* - `'oldschool'`: turns three dashes into an em dash and two into an en dash.
* - `'inverted'`: turns three dashes into an en dash and two into an em dash.
*/
dashes?: boolean | 'oldschool' | 'inverted';

/**
* @docs
* @name markdown.smartypants.ellipses
* @kind h4
* @type {boolean | 'spaced' | 'unspaced'}
* @default `true`
* @version 6.1.0
* @description
* Whether to transform triple dots into ellipses.
* - `'spaced'`: only transforms triple dots with spaces (e.g. `. . .`).
* - `'unspaced'`: only transforms triple dots without spaces (e.g. `...`).
*/
ellipses?: boolean | 'spaced' | 'unspaced';

/**
* @docs
* @name markdown.smartypants.openingQuotes
* @kind h4
* @type {QuoteCharacterMap}
* @default `{ double: '“', single: '‘' }`
* @version 6.1.0
* @description
* The specific characters to use for opening double and single quotes.
*/
openingQuotes?: QuoteCharacterMap;

/**
* @docs
* @name markdown.smartypants.closingQuotes
* @kind h4
* @type {QuoteCharacterMap}
* @default `{ double: '”', single: '’' }`
* @version 6.1.0
* @description
* The specific characters to use for closing double and single quotes.
*/
closingQuotes?: QuoteCharacterMap;
};

/**
* @docs
* @name markdown.remarkRehype
Expand Down
79 changes: 76 additions & 3 deletions packages/astro/test/astro-markdown-plugins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Smartypants Backticks test

``Smarty''
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Smartypants test

"Smartypants" is -- awesome
"Smartypants" is -- awesome ...
5 changes: 3 additions & 2 deletions packages/markdown/remark/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
16 changes: 15 additions & 1 deletion packages/markdown/remark/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlug

export type RemarkRehype = RemarkRehypeOptions;

export interface QuoteCharacterMap {
double: string;
single: string;
}

export interface SmartypantsOptions {
backticks?: boolean | 'all';
closingQuotes?: QuoteCharacterMap;
dashes?: boolean | 'oldschool' | 'inverted';
ellipses?: boolean | 'spaced' | 'unspaced';
openingQuotes?: QuoteCharacterMap;
quotes?: boolean;
}

export type ThemePresets = BuiltinTheme | 'css-variables';

export type SyntaxHighlightConfigType = 'shiki' | 'prism';
Expand All @@ -58,7 +72,7 @@ export interface AstroMarkdownOptions {
rehypePlugins?: RehypePlugins;
remarkRehype?: RemarkRehype;
gfm?: boolean;
smartypants?: boolean;
smartypants?: boolean | SmartypantsOptions;
}

/**
Expand Down
Loading