Skip to content
Open
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
1 change: 1 addition & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,5 @@ footer: |
| 2606260211 | ✅ | sonnet | [Add dedicated unit tests for layer0_html.go helpers](plan/2606260211_arch-fix-layer0-html-helper-tests.md) |
| 2606260614 | ✅ | sonnet | [arch-fix: add dedicated unit tests for lineclass_scan.go HTML-scanning helpers](plan/2606260614_arch-fix-lineclass-scan-helper-tests.md) |
| 2606260615 | ✅ | sonnet | [Add dedicated unit tests for unexported helpers in cue/cuelite/engine.go](plan/2606260615_arch-fix-cuelite-engine-helper-tests.md) |
| 2606270013 | ✅ | sonnet | [Add built-in Slidev convention](plan/2606270013_slidev-convention.md) |
<?/catalog?>
14 changes: 7 additions & 7 deletions docs/background/concepts/flavor-rule-convention-kind.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ conventions or kinds.

### Convention

A convention is a named bundle that pairs a flavor
with a set of rule presets. Selecting a convention
applies both: MDS034 runs against the named flavor,
and the named rule presets are applied as a base
layer beneath the user's own rule config. The
A convention is a named bundle of rule presets that
optionally pins a flavor. Selecting a convention
applies both: when a flavor is pinned, MDS034 runs
against it; the named rule presets are applied as a
base layer beneath the user's own rule config. The
built-in conventions are `portable`, `github`,
`obsidian`, `plain`, `no-llm-tells`, and four
`<linter>-parity` conventions. Each one runs the
`obsidian`, `plain`, `no-llm-tells`, `slidev`, and
four `<linter>-parity` conventions. Each one runs the
rule set a peer linter (gomarklint, mado, rumdl,
markdownlint) enables by default, so `mdsmith check`
matches that peer's work for a like-for-like
Expand Down
110 changes: 55 additions & 55 deletions docs/reference/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@ config key, sibling to `rules:`, `kinds:`, and
`overrides:`. An unknown name is a config error.

Built-in values: `portable`, `github`, `obsidian`,
`plain`, `no-llm-tells`, and the four
`plain`, `no-llm-tells`, `slidev`, and the four
`<linter>-parity` conventions below. The key is
optional; omit it for no convention.

You may also set `flavor:` inside `markdown-flavor`
alongside `convention:`. If both are set, they must
agree — a convention that requires `commonmark`
rejects `flavor: gfm` at config load.
agree — a convention that pins a flavor (e.g.
`portable` requires `commonmark`) rejects a
conflicting `flavor:` at config load. Conventions
that pin no flavor (`slidev`, `no-llm-tells`) do not
enforce this check.

## Built-in conventions

Expand Down Expand Up @@ -113,13 +116,10 @@ plus `allow-comments: false` on `no-inline-html` so
HTML comments do not leak through as literal
`<!-- ... -->` text.

A truly plaintext-faithful convention needs three
more rules. One forbids `*` and `_` runs. One
requires indented code blocks. One inverts
`no-bare-urls` so bare URLs are preferred over
Markdown links. Those rules don't exist yet. When
they ship, the `plain` convention gains them and
diverges from `portable`.
Three additional rules (forbid `*`/`_` runs,
require indented code blocks, prefer bare URLs) do
not exist yet. When they ship, `plain` gains them
and diverges from `portable`.

### `<linter>-parity`

Expand Down Expand Up @@ -162,6 +162,26 @@ convention's list instead of replacing it.

[slop]: ../../.claude/skills/docs-author/slop-patterns.md

### `slidev`

Disables eight default-on rules that produce false
positives on [Slidev](https://sli.dev) files. Slidev
uses `---` as a slide separator: the separator is
parsed as a setext underline, headings restart per
slide, and layout-only slides carry no body. Pins no
flavor; the parser-level `---` issue is out of scope.

| Rule | Why disabled |
| --------------------------------------------- | ------------------------------------------ |
| `heading-style` (MDS002) | `---` separator parsed as setext underline |
| `heading-increment` (MDS003) | Each slide restarts at H1 |
| `first-line-heading` (MDS004) | Front matter before first heading |
| `no-duplicate-headings` (MDS005) | Same title on multiple slides |
| `blank-line-around-headings` (MDS013) | Layout blocks interfere |
| `no-trailing-punctuation-in-heading` (MDS017) | Stylistic slide titles |
| `no-emphasis-as-heading` (MDS018) | Bold used for slide emphasis |
| `empty-section-body` (MDS030) | Layout-only slides have no body |

## How presets layer with user config

Convention presets sit between built-in defaults
Expand All @@ -177,19 +197,13 @@ oldest → newest, is:
effective list
5. `overrides[i]` — each matching override entry

Each layer deep-merges onto the previous one.
Scalars at a leaf are replaced by the later layer;
maps recurse key by key; lists replace by default.
A convention preset provides the floor; your
explicit `rules:` block overrides on top.

The `default` and `user` layers come from the same
`cfg.Rules` map. mdsmith splits them around the
convention so a convention can enable a rule that
is opt-in by default (e.g. `convention: portable`
turns on MDS034). Without the split, the default's
`Enabled: false` would override the convention's
`Enabled: true`.
Each layer deep-merges: scalar leaves replace, maps
recurse key by key, lists replace by default. The
convention provides the floor; your `rules:` block
overrides on top. mdsmith splits `default` and
`user` around the convention so a convention can
enable an opt-in rule without being overridden by
the default's `Enabled: false`.

For example, the `github` convention sets
`no-inline-html.allow: [details, summary]`. To
Expand All @@ -210,20 +224,11 @@ preset's entries, list them explicitly:

## Disabling MDS034

A convention applies its rule presets at config
load time. Disabling `markdown-flavor` itself does
not disable the rules a convention turned on.

```yaml
convention: portable
rules:
markdown-flavor: false
```

A bool-only `markdown-flavor: false` entry toggles
`enabled` without erasing the preset's settings. The
rule stays configured but its `Check()` is gated
off. The other rules in the preset are untouched.
Convention presets apply at config load. A bool-only
`markdown-flavor: false` entry in `rules:` toggles
`enabled` without erasing preset settings — the rule
is gated off but the other convention rules are
untouched.

## User-defined conventions

Expand Down Expand Up @@ -272,32 +277,27 @@ convention "our-team" rule "no-inline-html": no-inline-html: unknown setting "al
### Reserved names

The built-in names `portable`, `github`,
`obsidian`, `plain`, `no-llm-tells`, and the four
`<linter>-parity` conventions are reserved. Defining
a `conventions.portable` entry is a config error.
This keeps the built-in names stable across docs and
tutorials.
`obsidian`, `plain`, `no-llm-tells`, `slidev`, and
the four `<linter>-parity` conventions are reserved.
Defining a `conventions.portable` entry is a config
error. This keeps the built-in names stable across
docs and tutorials.

### Resolution order
### Resolution order and layering

The lookup checks user-defined conventions first,
then falls back to the built-in table. Collisions with
reserved names are rejected at load time, so shadowing
is impossible. When neither table matches, the error
lists both sets:
then the built-in table. A collision with a reserved
name is a config error — shadowing is impossible.
When neither matches, the error lists both sets:

```text
unknown convention "bogus" (valid: github, gomarklint-parity, mado-parity, markdownlint-parity, no-llm-tells, obsidian, our-team, plain, portable, rumdl-parity)
unknown convention "bogus" (valid: github, gomarklint-parity, mado-parity, markdownlint-parity, no-llm-tells, obsidian, our-team, plain, portable, rumdl-parity, slidev)
```

### Interaction with top-level rules

User-defined conventions apply as a base layer, like
the built-in conventions. A top-level `rules:` entry
overrides the convention preset for that rule; the
rest of the preset remains. `mdsmith kinds resolve
<file>` labels user-convention layers with a
`(user)` suffix.
the built-ins. A top-level `rules:` entry overrides
the convention preset; `mdsmith kinds resolve <file>`
labels user-convention layers with a `(user)` suffix.

## Inspecting an effective convention

Expand Down
36 changes: 36 additions & 0 deletions internal/config/convention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,42 @@ func TestApplyConvention_NoLLMTells_EnablesRulesWithSettings(t *testing.T) {
assert.True(t, dlt.Enabled)
}

func TestApplyConvention_Slidev_FlavorAnyAllowsUserFlavor(t *testing.T) {
// slidev uses FlavorAny — it imposes no renderer requirement and
// must not reject a user's markdown-flavor setting.
cfg := &Config{
Convention: "slidev",
Rules: map[string]RuleCfg{
"markdown-flavor": {Enabled: true, Settings: map[string]any{"flavor": "gfm"}},
},
}
require.NoError(t, applyConvention(cfg))
require.NotNil(t, cfg.ConventionPreset)
}

func TestApplyConvention_Slidev_DisablesEightRulesInPreset(t *testing.T) {
cfg := &Config{Convention: "slidev"}
require.NoError(t, applyConvention(cfg))
require.NotNil(t, cfg.ConventionPreset)

disabledRules := []string{
"heading-style",
"heading-increment",
"first-line-heading",
"no-duplicate-headings",
"blank-line-around-headings",
"no-trailing-punctuation-in-heading",
"no-emphasis-as-heading",
"empty-section-body",
}
for _, rule := range disabledRules {
p, ok := cfg.ConventionPreset[rule]
if assert.True(t, ok, "slidev preset must contain rule %q", rule) {
assert.False(t, p.Enabled, "slidev preset must disable rule %q", rule)
}
}
}

func TestApplyConvention_FlavorAgreeAccepted(t *testing.T) {
cfg := &Config{
Convention: "github",
Expand Down
36 changes: 36 additions & 0 deletions internal/convention/convention.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,42 @@ var conventions = map[string]Convention{
},
},
},
// slidev disables the eight default-on rules that produce false
// positives on Slidev presentation Markdown files. Slidev uses
// `---` as a slide separator, which causes heading rules to fire
// across slide boundaries: heading level restarts per slide,
// the same H1 title may appear on multiple slides, layout blocks
// are parsed as setext headings, emphasis stands in for headings,
// and layout-only slides have no body text. The eight disabled
// rules are: heading-style (MDS002), heading-increment (MDS003),
// first-line-heading (MDS004), no-duplicate-headings (MDS005),
// blank-line-around-headings (MDS013),
// no-trailing-punctuation-in-heading (MDS017),
// no-emphasis-as-heading (MDS018), and empty-section-body (MDS030).
//
// Flavor is FlavorAny: Slidev authors may render with GFM,
// CommonMark, or any other flavor, and the convention must not
// force a flavor change. The convention does not enable
// markdown-flavor (MDS034); the loader skips the flavor-conflict
// guard for a convention whose flavor is FlavorAny.
//
// Parser-level handling of `---` as a page separator is out of
// scope. This convention silences the rule-level false positives
// only.
"slidev": {
Name: "slidev",
Flavor: FlavorAny,
Rules: map[string]RulePreset{
"heading-style": {Enabled: false},
"heading-increment": {Enabled: false},
"first-line-heading": {Enabled: false},
"no-duplicate-headings": {Enabled: false},
"blank-line-around-headings": {Enabled: false},
"no-trailing-punctuation-in-heading": {Enabled: false},
"no-emphasis-as-heading": {Enabled: false},
"empty-section-body": {Enabled: false},
},
},
// no-llm-tells ships the mechanical layer of the docs-author
// anti-slop catalog as a one-key convention. It enables MDS056
// (forbidden-text) with a curated list of LLM vocabulary and phrase
Expand Down
27 changes: 27 additions & 0 deletions internal/convention/convention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,40 @@ func TestLookup_UnknownListsUserAndBuiltin(t *testing.T) {
assert.Contains(t, err.Error(), "portable", "error must list built-in name")
}

func TestLookup_Slidev(t *testing.T) {
c, err := Lookup("slidev", nil)
require.NoError(t, err)
assert.Equal(t, "slidev", c.Name)
assert.Equal(t, FlavorAny, c.Flavor)

disabledRules := []string{
"heading-style",
"heading-increment",
"first-line-heading",
"no-duplicate-headings",
"blank-line-around-headings",
"no-trailing-punctuation-in-heading",
"no-emphasis-as-heading",
"empty-section-body",
}
for _, rule := range disabledRules {
p, ok := c.Rules[rule]
if assert.True(t, ok, "slidev convention must mention rule %q", rule) {
assert.False(t, p.Enabled, "slidev convention must disable rule %q", rule)
}
}
require.Len(t, c.Rules, len(disabledRules),
"slidev convention rule count drifted")
}

func TestNamesSorted(t *testing.T) {
names := Names()
assert.True(t, sort.StringsAreSorted(names),
"Names should return a sorted slice; got %v", names)
assert.ElementsMatch(t, []string{
"github", "gomarklint-parity", "mado-parity", "markdownlint-parity",
"no-llm-tells", "obsidian", "plain", "portable", "rumdl-parity",
"slidev",
}, names)
}

Expand Down
Loading
Loading