Skip to content

Commit 8bc0917

Browse files
authored
Implement passthrough extension
Implement passthrough extension Closes #1 Co-authored-by: Jeremy Kun <j2kun@users.noreply.github.com>
1 parent 92547d9 commit 8bc0917

File tree

4 files changed

+892
-5
lines changed

4 files changed

+892
-5
lines changed

‎passthrough/go.mod‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.20
55
require github.com/frankban/quicktest v1.14.6
66

77
require (
8+
github.com/yuin/goldmark v1.6.0
89
github.com/google/go-cmp v0.5.9 // indirect
910
github.com/kr/pretty v0.3.1 // indirect
1011
github.com/kr/text v0.2.0 // indirect

‎passthrough/go.sum‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
1010
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
1111
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
1212
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
13+
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
14+
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

‎passthrough/passthrough.go‎

Lines changed: 379 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,382 @@
11
package passthrough
22

3-
// Foo returns a string.
4-
func Foo() string {
5-
return "foo"
3+
import (
4+
"bytes"
5+
"fmt"
6+
"github.com/yuin/goldmark"
7+
"github.com/yuin/goldmark/ast"
8+
"github.com/yuin/goldmark/parser"
9+
"github.com/yuin/goldmark/renderer"
10+
"github.com/yuin/goldmark/text"
11+
"github.com/yuin/goldmark/util"
12+
"strings"
13+
)
14+
15+
type Delimiters struct {
16+
Open string
17+
Close string
18+
}
19+
20+
// Determine if a byte array starts with a given string
21+
func startsWith(b []byte, s string) bool {
22+
if len(b) < len(s) {
23+
return false
24+
}
25+
26+
return string(b[:len(s)]) == s
27+
}
28+
29+
type PassthroughInline struct {
30+
ast.BaseInline
31+
32+
// The segment of text that this inline passthrough represents.
33+
Segment text.Segment
34+
35+
// The matched delimiters
36+
Delimiters *Delimiters
37+
}
38+
39+
func NewPassthroughInline(segment text.Segment, delimiters *Delimiters) *PassthroughInline {
40+
return &PassthroughInline{
41+
Segment: segment,
42+
Delimiters: delimiters,
43+
}
44+
}
45+
46+
// Text implements Node.Text.
47+
func (n *PassthroughInline) Text(source []byte) []byte {
48+
return n.Segment.Value(source)
49+
}
50+
51+
// Dump implements Node.Dump.
52+
func (n *PassthroughInline) Dump(source []byte, level int) {
53+
indent := strings.Repeat(" ", level)
54+
fmt.Printf("%sPassthroughInline {\n", indent)
55+
indent2 := strings.Repeat(" ", level+1)
56+
fmt.Printf("%sSegment: \"%s\"\n", indent2, n.Text(source))
57+
fmt.Printf("%s}\n", indent)
58+
}
59+
60+
// KindPassthroughInline is a NodeKind of the PassthroughInline node.
61+
var KindPassthroughInline = ast.NewNodeKind("PassthroughInline")
62+
63+
// Kind implements Node.Kind.
64+
func (n *PassthroughInline) Kind() ast.NodeKind {
65+
return KindPassthroughInline
66+
}
67+
68+
type inlinePassthroughParser struct {
69+
PassthroughDelimiters []Delimiters
70+
}
71+
72+
func NewInlinePassthroughParser(ds []Delimiters) parser.InlineParser {
73+
return &inlinePassthroughParser{
74+
PassthroughDelimiters: ds,
75+
}
76+
}
77+
78+
// Determine if the input slice starts with a full valid opening delimiter.
79+
// If so, returns the delimiter struct, otherwise returns nil.
80+
func GetFullOpeningDelimiter(delims []Delimiters, line []byte) *Delimiters {
81+
for _, d := range delims {
82+
if startsWith(line, d.Open) {
83+
return &d
84+
}
85+
}
86+
87+
return nil
88+
}
89+
90+
// Return an array of bytes containing the first byte of each opening
91+
// delimiter. Used to populate the trigger list for inline and block parsers.
92+
// `Parse` will be executed once for each character that is in this list of
93+
// allowed trigger characters. Our parse function needs to do some additional
94+
// checks because Trigger only works for single-byte delimiters.
95+
func OpenersFirstByte(delims []Delimiters) []byte {
96+
var firstBytes []byte
97+
containsBackslash := false
98+
for _, d := range delims {
99+
if d.Open[0] == '\\' {
100+
containsBackslash = true
101+
}
102+
firstBytes = append(firstBytes, d.Open[0])
103+
}
104+
105+
if !containsBackslash {
106+
// always trigger on backslash because it can be used to escape the opening
107+
// delimiter.
108+
firstBytes = append(firstBytes, '\\')
109+
}
110+
return firstBytes
111+
}
112+
113+
// Determine if the input list of delimiters contains the given delimiter pair
114+
func ContainsDelimiters(delims []Delimiters, toFind *Delimiters) bool {
115+
for _, d := range delims {
116+
if d.Open == toFind.Open && d.Close == toFind.Close {
117+
return true
118+
}
119+
}
120+
121+
return false
122+
}
123+
124+
func (s *inlinePassthroughParser) Trigger() []byte {
125+
return OpenersFirstByte(s.PassthroughDelimiters)
126+
}
127+
128+
func (s *inlinePassthroughParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
129+
// In order to prevent other parser extensions from operating on the text
130+
// between passthrough delimiters, we must process the entire inline
131+
// passthrough in one execution of Parse. This means we can't use the style
132+
// of multiple triggers with parser.Context state saved between calls.
133+
line, startSegment := block.PeekLine()
134+
135+
fencePair := GetFullOpeningDelimiter(s.PassthroughDelimiters, line)
136+
// fencePair == nil can happen if only the first byte of an opening delimiter
137+
// matches, but it is not the complete opening delimiter. The trigger causes
138+
// this Parse function to execute, but the trigger interface is limited to
139+
// matching single bytes.
140+
// It can also be because the opening delimiter is escaped with a
141+
// double-backslash. In this case, we advance and return nil.
142+
if fencePair == nil {
143+
if len(line) > 2 && line[0] == '\\' && line[1] == '\\' {
144+
fencePair = GetFullOpeningDelimiter(s.PassthroughDelimiters, line[2:])
145+
if fencePair != nil {
146+
// Opening delimiter is escaped, return the escaped opener as plain text
147+
// So that the characters are not processed again.
148+
block.Advance(2 + len(fencePair.Open))
149+
return ast.NewTextSegment(startSegment.WithStop(startSegment.Start + len(fencePair.Open) + 2))
150+
}
151+
}
152+
return nil
153+
}
154+
155+
// This roughly follows goldmark/parser/code_span.go
156+
block.Advance(len(fencePair.Open))
157+
openerSize := len(fencePair.Open)
158+
l, pos := block.Position()
159+
160+
for {
161+
line, lineSegment := block.PeekLine()
162+
if line == nil {
163+
block.SetPosition(l, pos)
164+
return ast.NewTextSegment(startSegment.WithStop(startSegment.Start + openerSize))
165+
}
166+
167+
closingDelimiterPos := bytes.Index(line, []byte(fencePair.Close))
168+
if closingDelimiterPos == -1 { // no closer on this line
169+
block.AdvanceLine()
170+
continue
171+
}
172+
173+
// This segment spans from the original starting trigger (including the delimiter)
174+
// up to and including the closing delimiter.
175+
seg := startSegment.WithStop(lineSegment.Start + closingDelimiterPos + len(fencePair.Close))
176+
if seg.Len() == len(fencePair.Open)+len(fencePair.Close) {
177+
return nil
178+
}
179+
180+
block.Advance(closingDelimiterPos + len(fencePair.Close))
181+
return NewPassthroughInline(seg, fencePair)
182+
}
183+
}
184+
185+
type passthroughInlineRenderer struct {
186+
}
187+
188+
func (r *passthroughInlineRenderer) renderRawInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
189+
if entering {
190+
w.WriteString(string(n.Text(source)))
191+
}
192+
return ast.WalkContinue, nil
193+
}
194+
195+
// A PassthroughBlock struct represents a fenced block of raw text to pass
196+
// through unchanged. This is not parsed directly, but emitted by an
197+
// ASTTransformer that splits a paragraph at the point of an inline passthrough
198+
// with the matching block delimiters.
199+
type PassthroughBlock struct {
200+
ast.BaseBlock
201+
}
202+
203+
// Dump implements Node.Dump.
204+
func (n *PassthroughBlock) Dump(source []byte, level int) {
205+
ast.DumpHelper(n, source, level, nil, nil)
206+
}
207+
208+
// KindPassthroughBlock is a NodeKind of the PassthroughBlock node.
209+
var KindPassthroughBlock = ast.NewNodeKind("PassthroughBlock")
210+
211+
// Kind implements Node.Kind.
212+
func (n *PassthroughBlock) Kind() ast.NodeKind {
213+
return KindPassthroughBlock
214+
}
215+
216+
// NewPassthroughBlock return a new PassthroughBlock node.
217+
func NewPassthroughBlock() *PassthroughBlock {
218+
return &PassthroughBlock{
219+
BaseBlock: ast.BaseBlock{},
220+
}
221+
}
222+
223+
type passthroughBlockRenderer struct {
224+
}
225+
226+
func (r *passthroughBlockRenderer) renderRawBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
227+
if entering {
228+
l := n.Lines().Len()
229+
for i := 0; i < l; i++ {
230+
line := n.Lines().At(i)
231+
w.WriteString(string(line.Value(source)))
232+
}
233+
w.WriteString("\n")
234+
}
235+
return ast.WalkSkipChildren, nil
236+
}
237+
238+
// To support the use of passthrough block delimiters in inline contexts, I
239+
// wasn't able to get the normal block parser to work. Goldmark seems to only
240+
// trigger the inline parser when the trigger is not the first characters in a
241+
// block. So instead we hook into the transformer interface, and process an
242+
// inline passthrough after it's parsed, looking for nodes whose delimiters
243+
// match the block delimiters, and splitting the paragraph at that point.
244+
type passthroughInlineTransformer struct {
245+
BlockDelimiters []Delimiters
246+
}
247+
248+
var PassthroughInlineTransformer = &passthroughInlineTransformer{}
249+
250+
// Note, this transformer destroys the RawText attributes of the paragraph
251+
// nodes that it transforms. However, this does not seem to have an impact on
252+
// rendering.
253+
func (p *passthroughInlineTransformer) Transform(
254+
doc *ast.Document, reader text.Reader, pc parser.Context) {
255+
256+
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
257+
// Anchor on paragraphs
258+
if n.Kind() != ast.KindParagraph || !entering {
259+
return ast.WalkContinue, nil
260+
}
261+
262+
// If no direct children are passthroughs, skip it.
263+
foundInlinePassthrough := false
264+
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
265+
if c.Kind() == KindPassthroughInline {
266+
foundInlinePassthrough = true
267+
break
268+
}
269+
}
270+
if !foundInlinePassthrough {
271+
return ast.WalkContinue, nil
272+
}
273+
274+
parent := n.Parent()
275+
currentParagraph := ast.NewParagraph()
276+
// AppendChild breaks the link between the node and its siblings, so we
277+
// need to manually track the current and next node.
278+
currentNode := n.FirstChild()
279+
insertionPoint := n
280+
281+
for currentNode != nil {
282+
nextNode := currentNode.NextSibling()
283+
if currentNode.Kind() != KindPassthroughInline {
284+
currentParagraph.AppendChild(currentParagraph, currentNode)
285+
currentNode = nextNode
286+
} else if currentNode.Kind() == KindPassthroughInline {
287+
inline := currentNode.(*PassthroughInline)
288+
289+
// Only split into a new block if the delimiters are block delimiters
290+
if !ContainsDelimiters(p.BlockDelimiters, inline.Delimiters) {
291+
currentParagraph.AppendChild(currentParagraph, currentNode)
292+
currentNode = nextNode
293+
continue
294+
}
295+
296+
newBlock := NewPassthroughBlock()
297+
newBlock.Lines().Append(inline.Segment)
298+
if len(currentParagraph.Text(reader.Source())) > 0 {
299+
parent.InsertAfter(parent, insertionPoint, currentParagraph)
300+
insertionPoint = currentParagraph
301+
}
302+
parent.InsertAfter(parent, insertionPoint, newBlock)
303+
insertionPoint = newBlock
304+
currentParagraph = ast.NewParagraph()
305+
currentNode = nextNode
306+
}
307+
}
308+
309+
if currentParagraph.ChildCount() > 0 {
310+
parent.InsertAfter(parent, insertionPoint, currentParagraph)
311+
}
312+
313+
parent.RemoveChild(parent, n)
314+
return ast.WalkContinue, nil
315+
})
316+
}
317+
318+
func NewPassthroughInlineTransformer(ds []Delimiters) parser.ASTTransformer {
319+
return &passthroughInlineTransformer{
320+
BlockDelimiters: ds,
321+
}
322+
}
323+
324+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
325+
func (r *passthroughInlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
326+
reg.Register(KindPassthroughInline, r.renderRawInline)
327+
}
328+
329+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
330+
func (r *passthroughBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
331+
reg.Register(KindPassthroughBlock, r.renderRawBlock)
332+
}
333+
334+
func NewPassthroughInlineRenderer() renderer.NodeRenderer {
335+
return &passthroughInlineRenderer{}
336+
}
337+
338+
func NewPassthroughBlockRenderer() renderer.NodeRenderer {
339+
return &passthroughBlockRenderer{}
340+
}
341+
342+
// ---- Extension and config ----
343+
344+
type passthrough struct {
345+
InlineDelimiters []Delimiters
346+
BlockDelimiters []Delimiters
347+
}
348+
349+
func NewPassthroughWithDelimiters(
350+
InlineDelimiters []Delimiters,
351+
BlockDelimiters []Delimiters) goldmark.Extender {
352+
// The parser executes in two phases:
353+
//
354+
// Phase 1: parse the input with all delimiters treated as inline, and block delimiters
355+
// taking precedence over inline delimiters.
356+
//
357+
// Phase 2: transform the parsed AST to split paragraphs at the point of
358+
// inline passthroughs with matching block delimiters.
359+
combinedDelimiters := make([]Delimiters, len(InlineDelimiters)+len(BlockDelimiters))
360+
copy(combinedDelimiters, BlockDelimiters)
361+
copy(combinedDelimiters[len(BlockDelimiters):], InlineDelimiters)
362+
return &passthrough{
363+
InlineDelimiters: combinedDelimiters,
364+
BlockDelimiters: BlockDelimiters,
365+
}
366+
}
367+
368+
func (e *passthrough) Extend(m goldmark.Markdown) {
369+
m.Parser().AddOptions(
370+
parser.WithInlineParsers(
371+
util.Prioritized(NewInlinePassthroughParser(e.InlineDelimiters), 201),
372+
),
373+
parser.WithASTTransformers(
374+
util.Prioritized(NewPassthroughInlineTransformer(e.BlockDelimiters), 0),
375+
),
376+
)
377+
378+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
379+
util.Prioritized(NewPassthroughInlineRenderer(), 101),
380+
util.Prioritized(NewPassthroughBlockRenderer(), 99),
381+
))
6382
}

0 commit comments

Comments
 (0)