Skip to content

Commit fca7b79

Browse files
committed
feat: support Quarto
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 286fc59 commit fca7b79

8 files changed

Lines changed: 486 additions & 1 deletion

File tree

‎internal/core/format.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ var FormatByExtension = map[string][]string{
7575
`\.(?:md|mdown|markdown|markdn|[Rr]md)$`: {".md", "markup"},
7676
`\.(?:mdx)$`: {".mdx", "markup"},
7777
`\.(?:myst)$`: {".myst", "markup"},
78+
`\.(?:qmd)$`: {".qmd", "markup"},
7879
`\.(?:org)$`: {".org", "markup"},
7980
`\.(?:php)$`: {".php", "code"},
8081
`\.(?:pl|pm|pod)$`: {".r", "code"},

‎internal/lint/html.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ var blockDelimiters = map[string]string{
3333
".md": "\n```\n$1\n```\n",
3434
".mdx": "\n```\n$1\n```\n",
3535
".myst": "\n```\n$1\n```\n",
36+
".qmd": "\n```\n$1\n```\n",
3637
".rst": "\n::\n\n%s\n",
3738
".org": orgExample,
3839
}
@@ -86,6 +87,7 @@ var inlineDelimiters = map[string]string{
8687
".md": "`$1`",
8788
".mdx": "`$1`",
8889
".myst": "`$1`",
90+
".qmd": "`$1`",
8991
".rst": "``$1``",
9092
".org": "=$1=",
9193
}

‎internal/lint/lint.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ func (l *Linter) lintFile(src string) lintResult {
244244
err = l.lintMDX(file)
245245
case ".myst":
246246
err = l.lintMyST(file)
247+
case ".qmd":
248+
err = l.lintQuarto(file)
247249
case ".rst":
248250
err = l.lintRST(file)
249251
case ".xml", ".xsd":

‎internal/lint/qmd.go‎

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package lint
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/yuin/goldmark"
8+
"github.com/yuin/goldmark/ast"
9+
"github.com/yuin/goldmark/extension"
10+
"github.com/yuin/goldmark/parser"
11+
"github.com/yuin/goldmark/renderer"
12+
grh "github.com/yuin/goldmark/renderer/html"
13+
"github.com/yuin/goldmark/text"
14+
"github.com/yuin/goldmark/util"
15+
16+
"github.com/errata-ai/vale/v3/internal/core"
17+
)
18+
19+
// Quarto is Pandoc Markdown plus knitr- and Jupyter-style code cells. The
20+
// cells already read as fenced code and code spans, so what needs parsing is
21+
// the Pandoc layer: fenced divs, attributes, and shortcodes. See #793.
22+
//
23+
// A fenced div's classes become class scopes (`class.callout-note`) for
24+
// everything inside it, and its fence lines are markup. Attributes and
25+
// shortcodes render as nothing at all.
26+
27+
// Quarto configuration: Markdown, plus the Pandoc constructs.
28+
var goldQmd = goldmark.New(
29+
goldmark.WithExtensions(
30+
extension.GFM,
31+
extension.Footnote,
32+
mathExtension{},
33+
quartoExtension{},
34+
),
35+
goldmark.WithRendererOptions(
36+
grh.WithUnsafe(),
37+
),
38+
)
39+
40+
type quartoExtension struct{}
41+
42+
func (quartoExtension) Extend(m goldmark.Markdown) {
43+
m.Parser().AddOptions(parser.WithBlockParsers(
44+
// Ahead of the paragraph parser (1000); ':' has no built-in owner.
45+
util.Prioritized(&quartoDivParser{}, 990),
46+
))
47+
m.Parser().AddOptions(parser.WithInlineParsers(
48+
// '{' has no built-in owner.
49+
util.Prioritized(&quartoInlineParser{}, 100),
50+
))
51+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
52+
util.Prioritized(quartoRenderer{}, 1),
53+
))
54+
}
55+
56+
var (
57+
// {{< shortcode ... >}}
58+
quartoShortcode = regexp.MustCompile(`^\{\{<[^\n]*?>\}\}`)
59+
// {#id .class key="val"} -- an identifier or class attribute.
60+
quartoAttrs = regexp.MustCompile(`^\{[#.][^{}\n]*\}`)
61+
// Any braced attributes directly after a `]`.
62+
quartoAttrsAfter = regexp.MustCompile(`^\{[^{}\n]*\}`)
63+
)
64+
65+
// quartoFence reads a fenced-div line: at least three colons, then -- for an
66+
// opener -- attributes or a bare name. It reports whether the line is a
67+
// fence at all, and what follows the colons.
68+
func quartoFence(line []byte, pos int) (string, bool) {
69+
if pos < 0 || pos >= len(line) || line[pos] != ':' {
70+
return "", false
71+
}
72+
i := pos
73+
for i < len(line) && line[i] == ':' {
74+
i++
75+
}
76+
if i-pos < 3 {
77+
return "", false
78+
}
79+
return strings.TrimSpace(string(line[i:])), true
80+
}
81+
82+
// quartoClasses reads the class names out of a div opener's attributes:
83+
// `{.callout-note title="x"}` and the bare-word form `warning` alike.
84+
func quartoClasses(rest string) []string {
85+
rest = strings.TrimSpace(rest)
86+
87+
var found []string
88+
if strings.HasPrefix(rest, "{") {
89+
for _, f := range strings.Fields(strings.Trim(rest, "{}")) {
90+
if name, ok := strings.CutPrefix(f, "."); ok {
91+
found = append(found, name)
92+
}
93+
}
94+
return found
95+
}
96+
97+
if f := strings.Fields(rest); len(f) > 0 {
98+
found = append(found, f[0])
99+
}
100+
return found
101+
}
102+
103+
// A quartoDiv is one fenced div.
104+
type quartoDiv struct {
105+
ast.BaseBlock
106+
107+
classes []string
108+
closed bool
109+
}
110+
111+
var kindQuartoDiv = ast.NewNodeKind("QuartoDiv")
112+
113+
func (n *quartoDiv) Kind() ast.NodeKind { return kindQuartoDiv }
114+
func (n *quartoDiv) Dump(source []byte, level int) {
115+
ast.DumpHelper(n, source, level, nil, nil)
116+
}
117+
118+
// openDescendant returns the deepest still-open div under n, or nil.
119+
func (n *quartoDiv) openDescendant() *quartoDiv {
120+
if child, ok := n.LastChild().(*quartoDiv); ok && !child.closed {
121+
if deeper := child.openDescendant(); deeper != nil {
122+
return deeper
123+
}
124+
return child
125+
}
126+
return nil
127+
}
128+
129+
type quartoDivParser struct{}
130+
131+
func (*quartoDivParser) Trigger() []byte {
132+
return []byte{':'}
133+
}
134+
135+
func (*quartoDivParser) Open(_ ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
136+
line, segment := reader.PeekLine()
137+
138+
rest, ok := quartoFence(line, pc.BlockOffset())
139+
if !ok {
140+
return nil, parser.NoChildren
141+
}
142+
if rest == "" {
143+
// A stray closer with no open div: markup, nothing more.
144+
reader.Advance(segment.Len() - 1)
145+
return &quartoDiv{closed: true}, parser.NoChildren
146+
}
147+
148+
node := &quartoDiv{classes: quartoClasses(rest)}
149+
reader.Advance(segment.Len() - 1)
150+
return node, parser.HasChildren
151+
}
152+
153+
func (*quartoDivParser) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
154+
n := node.(*quartoDiv) //nolint:errcheck // only quartoDiv is opened
155+
if n.closed {
156+
return parser.Close
157+
}
158+
159+
line, segment := reader.PeekLine()
160+
w, pos := util.IndentWidth(line, reader.LineOffset())
161+
rest, ok := quartoFence(line, pos)
162+
if w > 3 || !ok || rest != "" {
163+
// Not a closer; an opener is a child and parses on its own.
164+
return parser.Continue | parser.HasChildren
165+
}
166+
167+
// A bare fence closes the innermost open div. When that is a descendant,
168+
// the line is passed down the chain instead of taken here.
169+
if n.openDescendant() != nil {
170+
return parser.Continue | parser.HasChildren
171+
}
172+
173+
newline := 1
174+
if line[len(line)-1] != '\n' {
175+
newline = 0
176+
}
177+
reader.Advance(segment.Stop - segment.Start - newline + segment.Padding)
178+
return parser.Close
179+
}
180+
181+
func (*quartoDivParser) Close(node ast.Node, _ text.Reader, _ parser.Context) {
182+
if n, ok := node.(*quartoDiv); ok {
183+
n.closed = true
184+
}
185+
}
186+
187+
func (*quartoDivParser) CanInterruptParagraph() bool { return true }
188+
func (*quartoDivParser) CanAcceptIndentedLine() bool { return false }
189+
190+
// A quartoInline is inline Pandoc syntax with nothing to lint: a shortcode,
191+
// or an attribute set.
192+
type quartoInline struct {
193+
ast.BaseInline
194+
}
195+
196+
var kindQuartoInline = ast.NewNodeKind("QuartoInline")
197+
198+
func (n *quartoInline) Kind() ast.NodeKind { return kindQuartoInline }
199+
func (n *quartoInline) Dump(source []byte, level int) {
200+
ast.DumpHelper(n, source, level, nil, nil)
201+
}
202+
203+
type quartoInlineParser struct{}
204+
205+
func (*quartoInlineParser) Trigger() []byte {
206+
return []byte{'{'}
207+
}
208+
209+
func (*quartoInlineParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
210+
line, _ := block.PeekLine()
211+
212+
// {{< shortcode >}}
213+
if m := quartoShortcode.Find(line); m != nil {
214+
block.Advance(len(m))
215+
return &quartoInline{}
216+
}
217+
// {#id} / {.class} -- a heading's or span's attributes.
218+
if m := quartoAttrs.Find(line); m != nil {
219+
block.Advance(len(m))
220+
return &quartoInline{}
221+
}
222+
// [text]{lang="fr"} -- any attributes directly after a bracket.
223+
if block.PrecendingCharacter() == ']' {
224+
if m := quartoAttrsAfter.Find(line); m != nil {
225+
block.Advance(len(m))
226+
return &quartoInline{}
227+
}
228+
}
229+
230+
return nil
231+
}
232+
233+
type quartoRenderer struct{}
234+
235+
func (quartoRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
236+
reg.Register(kindQuartoDiv, renderQuartoDiv)
237+
reg.Register(kindQuartoInline, renderMystNothing)
238+
}
239+
240+
func renderQuartoDiv(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
241+
n, ok := node.(*quartoDiv)
242+
if !ok || len(n.classes) == 0 && n.ChildCount() == 0 {
243+
return ast.WalkContinue, nil
244+
}
245+
246+
if entering {
247+
_, _ = w.WriteString(`<div class="`)
248+
_, _ = w.Write(util.EscapeHTML([]byte(strings.Join(n.classes, " "))))
249+
_, _ = w.WriteString("\">\n")
250+
} else {
251+
_, _ = w.WriteString("</div>\n")
252+
}
253+
return ast.WalkContinue, nil
254+
}
255+
256+
// lintQuarto lints Quarto: Markdown, parsed with the Pandoc constructs.
257+
func (l *Linter) lintQuarto(f *core.File) error {
258+
return l.lintMarkdownWith(f, goldQmd)
259+
}

0 commit comments

Comments
 (0)