diff --git a/README.md b/README.md index f8cdfec..45ab092 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,14 @@ log.Print(buf.String()) // # My Document Title You can control the style of various markdown elements via functional options that are passed to the renderer. -| Functional Option | Type | Description | -| ----------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- | -| WithIndentStyle | markdown.IndentStyle | Indent nested blocks with spaces or tabs. | -| WithHeadingStyle | markdown.HeadingStyle | Render markdown headings as ATX (`#`-based), Setext (underlined with `===` or `---`), or variants thereof. | -| WithThematicBreakStyle | markdown.ThematicBreakStyle | Render thematic breaks with `-`, `*`, or `_`. | -| WithThematicBreakLength | markdown.ThematicBreakLength | Number of characters to use in a thematic break (minimum 3). | -| WithNestedListLength | markdown.NestedListLength | Number of characters to use in a nested list indentation (minimum 1). | +| Functional Option | Type | Description | +| ---------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| WithIndentStyle | markdown.IndentStyle | Indent nested blocks with spaces or tabs. | +| WithHeadingStyle | markdown.HeadingStyle | Render markdown headings as ATX (`#`-based), Setext (underlined with `===` or `---`), or variants thereof. | +| WithThematicBreakStyle | markdown.ThematicBreakStyle | Render thematic breaks with `-`, `*`, or `_`. | +| WithThematicBreakLength | markdown.ThematicBreakLength | Number of characters to use in a thematic break (minimum 3). | +| WithNestedListLength | markdown.NestedListLength | Number of characters to use in a nested list indentation (minimum 1). | +| WithTypographerSubstitutions | markdown.TypographerSubstitutions | Whether characters should be substituted by the typographer extension. This setting has no effect unless the typographer extension is enabled. The renderer must be added as an extension (e.g. via `NewExtension`) for this to work. | ## As a markdown transformer diff --git a/extension.go b/extension.go new file mode 100644 index 0000000..28f797a --- /dev/null +++ b/extension.go @@ -0,0 +1,63 @@ +package markdown + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +type rendererExtension struct{ opts []Option } + +// NewExtension returns a new goldmark.Markdown extension that uses the markdown renderer. +func NewExtension(opts ...Option) *rendererExtension { + return &rendererExtension{opts: opts} +} + +// Extend implements goldmark.Extension.Extend +func (re *rendererExtension) Extend(md goldmark.Markdown) { + renderer := NewRenderer(re.opts...) + md.SetRenderer(renderer) + if renderer.config.TypographerSubstitutions { + enableTypographicSubstitutions(md) + } else { + disableTypographicSubstitutions(md) + } +} + +// enableTypographicSubstitutions configures the typographer extension +// to substitute punctuations with the corresponding unicode character, +// instead of the default HTML escape sequence. +// These substitutions are only applied IF the typographer extension is enabled. +func enableTypographicSubstitutions(md goldmark.Markdown) { + subs := make(extension.TypographicSubstitutions) + subs[extension.LeftSingleQuote] = []byte("‘") + subs[extension.RightSingleQuote] = []byte("’") + subs[extension.LeftDoubleQuote] = []byte("“") + subs[extension.RightDoubleQuote] = []byte("”") + subs[extension.EnDash] = []byte("–") + subs[extension.EmDash] = []byte("—") + subs[extension.Ellipsis] = []byte("…") + subs[extension.LeftAngleQuote] = []byte("«") + subs[extension.RightAngleQuote] = []byte("»") + subs[extension.Apostrophe] = []byte("'") + + md.Parser().AddOptions(extension.WithTypographicSubstitutions(subs)) +} + +// disableTypographicSubstitutions configures the typographer extension +// to substitute punctuations with their original values, +// effectively turning typographer into a no-op. +func disableTypographicSubstitutions(md goldmark.Markdown) { + subs := make(extension.TypographicSubstitutions) + subs[extension.LeftSingleQuote] = []byte("'") + subs[extension.RightSingleQuote] = []byte("'") + subs[extension.LeftDoubleQuote] = []byte("\"") + subs[extension.RightDoubleQuote] = []byte("\"") + subs[extension.EnDash] = []byte("--") + subs[extension.EmDash] = []byte("---") + subs[extension.Ellipsis] = []byte("...") + subs[extension.LeftAngleQuote] = []byte("<<") + subs[extension.RightAngleQuote] = []byte(">>") + subs[extension.Apostrophe] = []byte("'") + + md.Parser().AddOptions(extension.WithTypographicSubstitutions(subs)) +} diff --git a/extension_test.go b/extension_test.go new file mode 100644 index 0000000..a268281 --- /dev/null +++ b/extension_test.go @@ -0,0 +1,67 @@ +package markdown + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +func TestTypographerExtensionDisabled(t *testing.T) { + assert := assert.New(t) + md := goldmark.New( + goldmark.WithExtensions(NewExtension(), extension.NewTypographer()), + ) + buf := bytes.Buffer{} + source := `'LeftSingleQuote +RightSingleQuote' +"LeftDoubleQuote +RightDoubleQuote" +EnDash -- +EmDash --- +Ellipsis ... +LeftAngleQuote << +RightAngleQuote >> +Apostrophe 'twas +` + + err := md.Convert([]byte(source), &buf) + assert.NoError(err) + assert.Equal(source, buf.String()) +} + +func TestTypographerExtensionEnabled(t *testing.T) { + assert := assert.New(t) + md := goldmark.New( + goldmark.WithExtensions(NewExtension(WithTypographerSubstitutions(true)), extension.NewTypographer()), + ) + buf := bytes.Buffer{} + source := `'LeftSingleQuote +RightSingleQuote' +"LeftDoubleQuote +RightDoubleQuote" +EnDash -- +EmDash --- +Ellipsis ... +LeftAngleQuote << +RightAngleQuote >> +Apostrophe 'twas +` + expected := `‘LeftSingleQuote +RightSingleQuote’ +“LeftDoubleQuote +RightDoubleQuote” +EnDash – +EmDash — +Ellipsis … +LeftAngleQuote « +RightAngleQuote » +Apostrophe 'twas +` + + err := md.Convert([]byte(source), &buf) + assert.NoError(err) + assert.Equal(expected, buf.String()) +} diff --git a/options.go b/options.go index 1b870fd..9792cb1 100644 --- a/options.go +++ b/options.go @@ -11,6 +11,7 @@ type Config struct { ThematicBreakStyle ThematicBreakLength NestedListLength + TypographerSubstitutions } // NewConfig returns a new Config with defaults and the given options. @@ -269,3 +270,35 @@ func WithNestedListLength(style NestedListLength) interface { } { return &withNestedListLength{style} } + +// ============================================================================ +// TypographerSubstitutions Option +// ============================================================================ + +// optTypographerSubstitutions is an option name used in WithTypographerSubstitutions +const optTypographerSubstitutions renderer.OptionName = "TypographerSubstitutions" + +// TypographerSubstitutions specifies whether characters should be substituted by the typographer extension. +type TypographerSubstitutions bool + +type withTypographerSubstitutions struct { + value TypographerSubstitutions +} + +func (o *withTypographerSubstitutions) SetConfig(c *renderer.Config) { + c.Options[optTypographerSubstitutions] = o.value +} + +// SetMarkdownOption implements renderer.Option +func (o *withTypographerSubstitutions) SetMarkdownOption(c *Config) { + c.TypographerSubstitutions = o.value +} + +// WithTypographerSubstitutions is a functional option that determines whether characters should be substituted by the typographer extension. +// This setting has no effect unless the typographer extension is added to the goldmark.Markdown object. +func WithTypographerSubstitutions(enabled TypographerSubstitutions) interface { + renderer.Option + Option +} { + return &withTypographerSubstitutions{enabled} +} diff --git a/options_test.go b/options_test.go index 8b01c47..f693a26 100644 --- a/options_test.go +++ b/options_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark" "github.com/yuin/goldmark/renderer" ) @@ -27,6 +28,7 @@ func TestRendererOptions(t *testing.T) { WithThematicBreakStyle(ThematicBreakStyleDashed), WithThematicBreakLength(ThematicBreakLengthMinimum), WithNestedListLength(NestedListLengthMinimum), + WithTypographerSubstitutions(false), }, NewConfig(), }, @@ -50,6 +52,11 @@ func TestRendererOptions(t *testing.T) { r := NewRenderer(tc.options...) assert.Equal(tc.expected, r.config) + // Set options by passing them to the renderer extension + md := goldmark.New(goldmark.WithExtensions(NewExtension(tc.options...))) + r = md.Renderer().(*Renderer) + assert.Equal(tc.expected, r.config) + // Set options by name using AddOptions r = NewRenderer() // Convert markdown Option interface to renderer.Option interface diff --git a/renderer.go b/renderer.go index caa8c0f..b2c93ff 100644 --- a/renderer.go +++ b/renderer.go @@ -94,8 +94,7 @@ func (r *Renderer) Render(w io.Writer, source []byte, n ast.Node) error { r.nodeRendererFuncs[ast.KindLink] = r.renderLink r.nodeRendererFuncs[ast.KindRawHTML] = r.renderRawHTML r.nodeRendererFuncs[ast.KindText] = r.renderText - // TODO: add KindString - // r.nodeRendererFuncs[ast.KindString] = r.renderString + r.nodeRendererFuncs[ast.KindString] = r.renderString for kind, fun := range r.nodeRendererFuncsTmp { r.nodeRendererFuncs[kind] = r.transform(fun) @@ -322,6 +321,14 @@ func (r *Renderer) renderText(node ast.Node, entering bool) ast.WalkStatus { return ast.WalkContinue } +func (r *Renderer) renderString(node ast.Node, entering bool) ast.WalkStatus { + n := node.(*ast.String) + if entering { + r.rc.writer.WriteBytes(n.Value) + } + return ast.WalkContinue +} + func (r *Renderer) renderSegments(segments *text.Segments, asLines bool) { for i := 0; i < segments.Len(); i++ { segment := segments.At(i) diff --git a/renderer_test.go b/renderer_test.go index 1cd66b9..cbb45b0 100644 --- a/renderer_test.go +++ b/renderer_test.go @@ -17,6 +17,18 @@ import ( var transformer = testHelperASTTransformer{} +func NewTestMarkdown(options ...goldmark.Option) goldmark.Markdown { + testOptions := []goldmark.Option{ + goldmark.WithRenderer(NewRenderer()), + goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(&transformer, 0))), + } + testOptions = append( + testOptions, + options..., + ) + return goldmark.New(testOptions...) +} + // testHelperASTTransformer is a goldmark AST transformer that helps with debugging failed tests. type testHelperASTTransformer struct { lastDocument *ast.Document @@ -73,474 +85,470 @@ func TestCustomRenderers(t *testing.T) { // TestRenderedOutput tests that the renderer produces the expected output for all test cases func TestRenderedOutput(t *testing.T) { - md := goldmark.New( - goldmark.WithRenderer(NewRenderer()), - goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(&transformer, 0))), - ) testCases := []struct { name string - options []Option + options []goldmark.Option source string expected string }{ // Document { "Empty doc", - []Option{}, + nil, "", "", }, { "Non-empty doc trailing newline", - []Option{}, + nil, "x", "x\n", }, // Headings { "Setext to ATX heading", - []Option{WithHeadingStyle(HeadingStyleATX)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleATX))}, "Foo\n---", "## Foo\n", }, { "ATX to setext heading", - []Option{WithHeadingStyle(HeadingStyleSetext)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleSetext))}, "## FooBar", "FooBar\n---\n", }, { "Full width setext heading", - []Option{WithHeadingStyle(HeadingStyleFullWidthSetext)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleFullWidthSetext))}, "Foo Bar\n---", "Foo Bar\n-------\n", }, { "ATX heading with closing sequence", - []Option{WithHeadingStyle(HeadingStyleATXSurround)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleATXSurround))}, "## Foo", "## Foo ##\n", }, { "Empty ATX heading with closing sequence", - []Option{WithHeadingStyle(HeadingStyleATXSurround)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleATXSurround))}, "##", "## ##\n", }, { // Setext headings cannot be empty, will always be ATX "Empty setext heading", - []Option{WithHeadingStyle(HeadingStyleSetext)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleSetext))}, "##", "##\n", }, { // ATX headings cannot be multiline, must be setext "Multiline ATX heading", - []Option{WithHeadingStyle(HeadingStyleATX)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleATX))}, "Foo\nBar\n---", "Foo\nBar\n---\n", }, // Autolink { "Url autolink", - []Option{}, + nil, "", "\n", }, { "Mailto autolink", - []Option{}, + nil, "", "\n", }, // Blockquote { "Blockquote", - []Option{}, + nil, "> You will speak\n> an infinite deal\n> of nothing\n\n\\- William Shakespeare", "> You will speak\n> an infinite deal\n> of nothing\n\n\\- William Shakespeare\n", }, { "Nested blockquote", - []Option{}, + nil, "> one\n> > two\n> > > three\n\n> one again", "> one\n> > two\n> > > three\n\n> one again\n", }, // Code Block { "Space indented code block", - []Option{}, + nil, " foo", " foo\n", }, { "Tab indented code block", - []Option{WithIndentStyle(IndentStyleTabs)}, + []goldmark.Option{goldmark.WithRendererOptions(WithIndentStyle(IndentStyleTabs))}, " foo", "\tfoo\n", }, { "Multiline code block", - []Option{WithIndentStyle(IndentStyleSpaces)}, + []goldmark.Option{goldmark.WithRendererOptions(WithIndentStyle(IndentStyleSpaces))}, "\tfoo\n\tbar\n\tbaz", " foo\n bar\n baz\n", }, // Code Span { "Simple code span", - []Option{}, + nil, "`foo`", "`foo`\n", }, { "Multiline code span", - []Option{}, + nil, "`foo\nbar`", "`foo\nbar`\n", }, { "Two-backtick code span", - []Option{}, + nil, "``foo ` bar``", "``foo ` bar``\n", }, { "Reduced backtick code span", - []Option{}, + nil, "``foo bar``", "`foo bar`\n", }, { "Code span preserving leading and trailing spaces", - []Option{}, + nil, "` `` `", "` `` `\n", }, { "Code span preserving surrounding spaces", - []Option{}, + nil, "` `` `", "` `` `\n", }, { "Unstrippable left space only", - []Option{}, + nil, "` a`", "` a`\n", }, { "Unstrippable only spaces", - []Option{}, + nil, "` `\n` `", "` `\n` `\n", }, { "Line-ending treated as space", - []Option{}, + nil, "``\nfoo \n``", "`foo `\n", }, { "Backlashes are treated literally", - []Option{}, + nil, "`foo\\`bar`", "`foo\\`bar`\n", }, { "Two backticks act as delimiters", - []Option{}, + nil, "``foo`bar``", "``foo`bar``\n", }, { "Two backtics inside single ones with spaces trimmed", - []Option{}, + nil, "` foo `` bar `", "`foo `` bar`\n", }, { "Codespan backticks have precedence over emphasis", - []Option{}, + nil, "*foo`*`", "*foo`*`\n", }, { "Codespan backticks have equal precedence with HTML", - []Option{}, + nil, "``", "``\n", }, { "HTML tag with backtick", - []Option{}, + nil, "`", "`\n", }, { "Autolink split by a backtick", - []Option{}, + nil, "``", "``\n", }, { "Unbalanced 3-2 backticks remain intact", - []Option{}, + nil, "```foo``", "```foo``\n", }, { "Unbalanced 1-0 backticks remain intact", - []Option{}, + nil, "`foo", "`foo\n", }, { "Unbalanced double backticks", - []Option{}, + nil, "`foo``bar``", "`foo`bar`\n", }, // Emphasis { "Emphasis", - []Option{}, + nil, "*emph*", "*emph*\n", }, { "Strong", - []Option{}, + nil, "**strong**", "**strong**\n", }, { "Strong emphasis", - []Option{}, + nil, "***strong emph***", "***strong emph***\n", }, { "Strong in emphasis", - []Option{}, + nil, "***strong** in emph*", "***strong** in emph*\n", }, { "Emphasis in strong", - []Option{}, + nil, "***emph* in strong**", "***emph* in strong**\n", }, { "Escaped emphasis", - []Option{}, + nil, "*escaped\\*emphasis*", "*escaped\\*emphasis*\n", }, { "In emphasis strong", - []Option{}, + nil, "*in emph **strong***", "*in emph **strong***\n", }, // Paragraph { "Simple paragraph", - []Option{}, + nil, "foo", "foo\n", }, { "Paragraph with escaped characters", - []Option{}, + nil, "\\# foo \\*bar\\* \\__baz\\_\\_", "\\# foo \\*bar\\* \\__baz\\_\\_\n", }, // Thematic Break { "Thematic break default style", - []Option{}, + nil, "---", "---\n", }, { "Thematic break underline style", - []Option{WithThematicBreakStyle(ThematicBreakStyleUnderlined)}, + []goldmark.Option{goldmark.WithRendererOptions(WithThematicBreakStyle(ThematicBreakStyleUnderlined))}, "---", "___\n", }, { "Thematic break starred style", - []Option{WithThematicBreakStyle(ThematicBreakStyleStarred)}, + []goldmark.Option{goldmark.WithRendererOptions(WithThematicBreakStyle(ThematicBreakStyleStarred))}, "---", "***\n", }, { // Thematic breaks are a minimum of three characters "Thematic break zero value", - []Option{WithThematicBreakLength(ThematicBreakLength(0))}, + []goldmark.Option{goldmark.WithRendererOptions(WithThematicBreakLength(ThematicBreakLength(0)))}, "---", "---\n", }, { "Thematic break longer length", - []Option{WithThematicBreakLength(ThematicBreakLength(10))}, + []goldmark.Option{goldmark.WithRendererOptions(WithThematicBreakLength(ThematicBreakLength(10)))}, "---", "----------\n", }, // Fenced Code Block { "Fenced Code Block", - []Option{}, + nil, "```\nfoo\nbar\nbaz\n```", "```\nfoo\nbar\nbaz\n```\n", }, { "Fenced Code Block with info", - []Option{}, + nil, "```ruby startline=3\ndef foo(x)\n return 3\nend\n```", "```ruby startline=3\ndef foo(x)\n return 3\nend\n```\n", }, { "Fenced Code Block with special chars", - []Option{}, + nil, "```\n!@#$%^&*\\[],./;'()\n```", "```\n!@#$%^&*\\[],./;'()\n```\n", }, // Raw HTML { "Raw HTML open tags", - []Option{}, + nil, "", "\n", }, { "Raw HTML empty elements", - []Option{}, + nil, "", "\n", }, { "Raw HTML with attributes", - []Option{}, + nil, "", "\n", }, // HTML blocks { "HTML Block Type 1", - []Option{}, + nil, "
\nfoo\n
", "
\nfoo\n
\n", }, { "HTML Block Type 2", - []Option{}, + nil, "", "\n", }, { "HTML Block Type 3", - []Option{}, + nil, "", "\n", }, { "HTML Block Type 4", - []Option{}, + nil, "", "\n", }, { "HTML Block Type 5", - []Option{}, + nil, "", "\n", }, { "HTML Block Type 6", - []Option{}, + nil, "
", "
\n", }, { "HTML Block Type 7", - []Option{}, + nil, "
", "\n", }, // Lists { "Unordered list", - []Option{}, + nil, "- A1\n- B1\n - C2\n - D3\n- E1", "- A1\n- B1\n - C2\n - D3\n- E1\n", }, { "Ordered list", - []Option{}, + nil, "1. X1\n2. B1\n 1. C2\n 1. D3\n3. E1\n", "1. X1\n2. B1\n 1. C2\n 1. D3\n3. E1\n", }, { "Mixed list", - []Option{}, + nil, "1. A1\n2. B1\n - C2\n 1. D3\n 2. E3\n - F2\n - G2\n3. H1\n", "1. A1\n2. B1\n - C2\n 1. D3\n 2. E3\n - F2\n - G2\n3. H1\n", }, { "Nested list length", - []Option{WithNestedListLength(2)}, + []goldmark.Option{goldmark.WithRendererOptions(WithNestedListLength(2))}, "1. A1\n2. B1\n - C2\n 1. D3\n 2. E3\n - F2\n - G2\n3. H1\n", "1. A1\n2. B1\n - C2\n 1. D3\n 2. E3\n - F2\n - G2\n3. H1\n", }, // Block separators { "ATX heading block separator", - []Option{}, + nil, "# Foo\n# Bar\n\n# Baz", "# Foo\n# Bar\n\n# Baz\n", }, { "Setext heading block separator", - []Option{WithHeadingStyle(HeadingStyleSetext)}, + []goldmark.Option{goldmark.WithRendererOptions(WithHeadingStyle(HeadingStyleSetext))}, "Foo\n---\nBar\n---\n\nBaz\n---", "Foo\n---\nBar\n---\n\nBaz\n---\n", }, { "Code block separator", - []Option{WithIndentStyle(IndentStyleTabs)}, + []goldmark.Option{goldmark.WithRendererOptions(WithIndentStyle(IndentStyleTabs))}, "\tcode 1\n---\n\tcode 2\n---\n\n\tcode 3", "\tcode 1\n---\n\tcode 2\n---\n\n\tcode 3\n", }, { "Fenced code block separator", - []Option{}, + nil, "```\ncode 1\n```\n```\ncode 2\n```\n\n```\ncode 3\n```", "```\ncode 1\n```\n```\ncode 2\n```\n\n```\ncode 3\n```\n", }, { "HTML block separator", - []Option{}, + nil, "\n\n\n", "\n\n\n\n", }, { "List block separator", - []Option{}, + nil, "- foo\n+ bar\n\n* baz", "- foo\n+ bar\n\n* baz\n", }, { "List item block separator", - []Option{}, + nil, "- foo\n- bar\n\n- baz", "- foo\n- bar\n\n- baz\n", }, { "Text block separator", - []Option{}, + nil, "- foo\n- bar\n\n- baz", "- foo\n- bar\n\n- baz\n", }, @@ -548,51 +556,51 @@ func TestRenderedOutput(t *testing.T) { // Tight and "loose" lists { "Tight list", - []Option{}, + nil, "Paragraph\n- A1\n- B1", "Paragraph\n- A1\n- B1\n", }, { "Loose list", - []Option{}, + nil, "Paragraph\n\n- A1\n- B1", "Paragraph\n\n- A1\n- B1\n", }, // Links { "Empty Link", - []Option{}, + nil, "[]()", "[]()\n", }, { "Link", - []Option{}, + nil, "[link](/uri)", "[link](/uri)\n", }, { "Link with title", - []Option{}, + nil, "[link](/uri \"title\")", "[link](/uri \"title\")\n", }, // Images { "Empty image", - []Option{}, + nil, "![]()", "![]()\n", }, { "Image", - []Option{}, + nil, "![image](/uri)", "![image](/uri)\n", }, { "Image with title", - []Option{}, + nil, "![image](/uri \"title\")", "![image](/uri \"title\")\n", }, @@ -602,9 +610,8 @@ func TestRenderedOutput(t *testing.T) { t.Run(tc.name, func(t *testing.T) { assert := assert.New(t) buf := bytes.Buffer{} + md := NewTestMarkdown(tc.options...) - renderer := NewRenderer(tc.options...) - md.SetRenderer(renderer) err := md.Convert([]byte(tc.source), &buf) assert.NoError(err) assert.Equal(tc.expected, buf.String())