Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ footer: |
| 84 | 🔲 | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) |
| 85 | 🔳 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
| 86 | 🔳 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
| 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) |
| 87 | | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) |
| 88 | ✅ | [TOC directive migration aid](plan/88_toc-directive-migration.md) |
| 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
<?/catalog?>
12 changes: 10 additions & 2 deletions internal/rules/MDS034-markdown-flavor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ flavor does not render.
- **Name**: `markdown-flavor`
- **Status**: ready
- **Default**: disabled
- **Fixable**: no (fix pipeline lands in a follow-up)
- **Fixable**: yes
Comment thread
jeduden marked this conversation as resolved.
Outdated
- **Implementation**:
[source](./)
- **Category**: meta
Expand Down Expand Up @@ -73,7 +73,7 @@ rules:

## Detected features

MDS034 tracks twelve syntax features whose
MDS034 tracks thirteen syntax features whose
support varies across Markdown flavors.

Eleven features are detected from the goldmark AST
Expand All @@ -89,6 +89,13 @@ detector scans text nodes from the main parse for
URL-shaped text. It skips links, autolinks, code
spans, and code blocks.

GitHub Alerts are detected from the CommonMark
AST. The detector matches the five GFM tokens
(`NOTE`, `TIP`, `IMPORTANT`, `WARNING`,
`CAUTION`) on the first line of a blockquote
paragraph. Matching is case-sensitive per the
GFM spec.

`flavor: any` accepts every feature and is omitted
from the table below.

Expand All @@ -106,6 +113,7 @@ from the table below.
| math blocks | no | no | no | yes | no | yes | yes |
| inline math | no | no | no | yes | no | yes | yes |
| abbreviations | no | no | no | no | yes | yes | no |
| github alerts | no | yes | no | no | no | no | no |

## Examples

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
settings:
flavor: commonmark
diagnostics:
- line: 3
column: 1
message: "github alerts are not supported by commonmark"
---
# Alerts

> [!NOTE]
> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
settings:
flavor: goldmark
diagnostics:
- line: 3
column: 1
message: "github alerts are not supported by goldmark"
---
# Alerts

> [!WARNING]
> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Alerts

> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Alerts

> Something to remember.
45 changes: 45 additions & 0 deletions internal/rules/markdownflavor/detect.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package markdownflavor

import (
"bytes"
"regexp"
"sort"

Expand Down Expand Up @@ -45,6 +46,10 @@ type HeadingIDExtra struct {
AttrEnd int // byte offset one past '}'
}

// alertTokenRe matches the exact content of a GitHub Alert marker line
// inside a blockquote (case-sensitive per GFM spec).
var alertTokenRe = regexp.MustCompile(`^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$`)

// bareURLPattern mirrors goldmark's linkify http/https/ftp URL regex
// closely enough to catch bare URLs in text. Anchors removed so it can
// match anywhere inside a Text segment. The TLD class accepts both
Expand Down Expand Up @@ -92,6 +97,10 @@ func DetectFiltered(f *lint.File, accept func(Feature) bool) []Finding {
out = append(out, detectBareURLs(f)...)
}

if keep(FeatureGitHubAlerts) {
out = append(out, detectGitHubAlerts(f)...)
}

sort.SliceStable(out, func(i, j int) bool {
return out[i].Start < out[j].Start
})
Expand Down Expand Up @@ -488,3 +497,39 @@ func insideNonBareContext(n ast.Node) bool {
}
return false
}

// detectGitHubAlerts walks f.AST for Blockquote nodes whose first paragraph
// child starts with a GFM alert token (e.g. [!NOTE]).
func detectGitHubAlerts(f *lint.File) []Finding {
var findings []Finding
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
bq, ok := n.(*ast.Blockquote)
if !ok {
return ast.WalkContinue, nil
}
if isGitHubAlert(bq, f.Source) {
findings = append(findings, blockFinding(f, bq, FeatureGitHubAlerts))
}
return ast.WalkContinue, nil
})
return findings
}

// isGitHubAlert reports whether bq is a GitHub Alert blockquote: its first
// paragraph child's first line matches one of the five GFM alert tokens.
func isGitHubAlert(bq *ast.Blockquote, source []byte) bool {
para, ok := bq.FirstChild().(*ast.Paragraph)
if !ok {
return false
}
lines := para.Lines()
if lines == nil || lines.Len() == 0 {
return false
}
seg := lines.At(0)
firstLine := bytes.TrimRight(source[seg.Start:seg.Stop], "\r\n")
return alertTokenRe.Match(firstLine)
}
30 changes: 30 additions & 0 deletions internal/rules/markdownflavor/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,33 @@ func TestDetectFindingsAreSortedByStart(t *testing.T) {
i-1, fs[i-1], i, fs[i])
}
}

func TestDetectGitHubAlerts(t *testing.T) {
tokens := []string{"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
for _, tok := range tokens {
t.Run(tok, func(t *testing.T) {
fs := findings(t, "> [!"+tok+"]\n> Something.\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
})
}
}

func TestDetectGitHubAlertsLowercaseNoMatch(t *testing.T) {
for _, src := range []string{
"> [!note]\n> text.\n",
"> [!INFO]\n> text.\n",
} {
fs := findings(t, src)
assert.False(t, hasFeature(fs, FeatureGitHubAlerts), "should not match: %q", src)
}
}

func TestDetectGitHubAlertsMixedContent(t *testing.T) {
fs := findings(t, "> [!NOTE]\n> Line one.\n> Line two.\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
}

func TestDetectGitHubAlertsOnlyLine(t *testing.T) {
fs := findings(t, "> [!WARNING]\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
}
7 changes: 7 additions & 0 deletions internal/rules/markdownflavor/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const (
FeatureMathBlock
FeatureMathInline
FeatureAbbreviations
FeatureGitHubAlerts
)

// AllFeatures returns every tracked feature in declaration order.
Expand All @@ -125,6 +126,7 @@ func AllFeatures() []Feature {
FeatureMathBlock,
FeatureMathInline,
FeatureAbbreviations,
FeatureGitHubAlerts,
}
}

Expand All @@ -136,6 +138,8 @@ func (f Feature) Verb() string {
case FeatureStrikethrough, FeatureSuperscript, FeatureSubscript,
FeatureMathInline:
return "is"
case FeatureGitHubAlerts:
return "are"
}
return "are"
}
Expand Down Expand Up @@ -167,6 +171,8 @@ func (f Feature) Name() string {
return "inline math"
case FeatureAbbreviations:
return "abbreviations"
case FeatureGitHubAlerts:
return "github alerts"
}
return ""
}
Expand All @@ -183,6 +189,7 @@ var support = map[Flavor]map[Feature]bool{
FeatureTaskLists: true,
FeatureStrikethrough: true,
FeatureBareURLAutolinks: true,
FeatureGitHubAlerts: true,
},
FlavorGoldmark: {
FeatureTables: true,
Expand Down
7 changes: 4 additions & 3 deletions internal/rules/markdownflavor/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestFeatureSupportCommonMark(t *testing.T) {
func TestFeatureSupportGFM(t *testing.T) {
assertSupports(t, FlavorGFM,
FeatureTables, FeatureTaskLists, FeatureStrikethrough,
FeatureBareURLAutolinks)
FeatureBareURLAutolinks, FeatureGitHubAlerts)
}

func TestFeatureSupportGoldmark(t *testing.T) {
Expand Down Expand Up @@ -122,8 +122,8 @@ func TestFeatureSupportMyST(t *testing.T) {
}

func TestAllFeaturesComplete(t *testing.T) {
// Ensure AllFeatures enumerates exactly the 12 features we track.
require.Len(t, AllFeatures(), 12)
// Ensure AllFeatures enumerates exactly the 13 features we track.
require.Len(t, AllFeatures(), 13)
}

func TestFeatureName(t *testing.T) {
Expand All @@ -139,4 +139,5 @@ func TestFeatureName(t *testing.T) {
assert.Equal(t, "math blocks", FeatureMathBlock.Name())
assert.Equal(t, "inline math", FeatureMathInline.Name())
assert.Equal(t, "abbreviations", FeatureAbbreviations.Name())
assert.Equal(t, "github alerts", FeatureGitHubAlerts.Name())
}
69 changes: 67 additions & 2 deletions internal/rules/markdownflavor/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package markdownflavor

import (
"fmt"
"strings"

"github.com/yuin/goldmark/ast"

"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/rule"
Expand Down Expand Up @@ -39,7 +42,7 @@ func (r *Rule) ApplySettings(settings map[string]any) error {
return fmt.Errorf("markdown-flavor: flavor must be a string, got %T", v)
}
if s == "" {
r.Flavor = 0
r.Flavor = flavorInvalid
continue
}
fl, ok := ParseFlavor(s)
Expand Down Expand Up @@ -67,7 +70,7 @@ func (r *Rule) DefaultSettings() map[string]any {

// Check implements rule.Rule.
func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
if r.Flavor == 0 {
if r.Flavor == flavorInvalid {
return nil
}
// Only ask detectors about features this flavor rejects. Detectors
Expand All @@ -92,7 +95,69 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
return diags
}

// Fix implements rule.FixableRule. It removes the [!TOKEN] marker line from
// GitHub Alert blockquotes when the configured flavor does not support them.
// If the marker is the only line in the blockquote, the whole blockquote is
// removed.
func (r *Rule) Fix(f *lint.File) []byte {
if r.Flavor == flavorInvalid || r.Flavor.Supports(FeatureGitHubAlerts) {
return f.Source
}

skip := map[int]bool{}
addPrefix := map[int]bool{} // lazy-continuation lines that lose blockquote context
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
bq, ok := n.(*ast.Blockquote)
if !ok {
return ast.WalkContinue, nil
}
if !isGitHubAlert(bq, f.Source) {
return ast.WalkContinue, nil
}
para := bq.FirstChild().(*ast.Paragraph)
lines := para.Lines()
seg := lines.At(0)
markerLine, _ := lineCol(f.Source, seg.Start)
skip[markerLine] = true

// Remaining lines of the first paragraph may use lazy continuation
// (no "> " prefix in the raw source). After removing the marker they
// would no longer be inside a blockquote, so re-add the prefix.
for i := 1; i < lines.Len(); i++ {
contSeg := lines.At(i)
contLine, _ := lineCol(f.Source, contSeg.Start)
raw := strings.TrimLeft(string(f.Lines[contLine-1]), " \t")
if !strings.HasPrefix(raw, ">") {
addPrefix[contLine] = true
}
}
return ast.WalkContinue, nil
})

if len(skip) == 0 {
return f.Source
}

var out []string
for i, line := range f.Lines {
lineNum := i + 1
if skip[lineNum] {
continue
Comment thread
jeduden marked this conversation as resolved.
}
s := string(line)
if addPrefix[lineNum] {
s = "> " + s
}
Comment thread
jeduden marked this conversation as resolved.
out = append(out, s)
}
return []byte(strings.Join(out, "\n"))
Comment thread
jeduden marked this conversation as resolved.
}

var (
_ rule.Configurable = (*Rule)(nil)
_ rule.Defaultable = (*Rule)(nil)
_ rule.FixableRule = (*Rule)(nil)
)
Loading
Loading