Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 24 additions & 0 deletions .changeset/legal-rings-rhyme.md
Original file line number Diff line number Diff line change
@@ -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).
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 `retext-smartypants` options](https://github.com/retextjs/retext-smartypants?tab=readme-ov-file#fields) for more information.
31 changes: 30 additions & 1 deletion packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: '.',
Expand Down Expand Up @@ -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<Smartypants> = 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 +408,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): false | Smartypants => {
if (val === true) return smartypantsOptionsSchema.parse({});
return val;
})
.prefault(ASTRO_CONFIG_DEFAULTS.markdown.smartypants),
})
.prefault({}),
vite: z
Expand Down
21 changes: 11 additions & 10 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RemarkPlugins,
RemarkRehype,
ShikiConfig,
Smartypants,
SyntaxHighlightConfigType,
} from '@astrojs/markdown-remark';
import type { Config as SvgoConfig } from 'svgo';
Expand Down Expand Up @@ -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
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 ...
1 change: 1 addition & 0 deletions packages/markdown/remark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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
5 changes: 4 additions & 1 deletion packages/markdown/remark/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -58,7 +61,7 @@ export interface AstroMarkdownOptions {
rehypePlugins?: RehypePlugins;
remarkRehype?: RemarkRehype;
gfm?: boolean;
smartypants?: boolean;
smartypants?: boolean | SmartypantsOptions;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading