Skip to content

Commit bfd0deb

Browse files
author
merge-queue-bot
committed
Merge PR #706: feat(convention): add built-in slidev convention (plan 2606270013)
2 parents 4b77cd3 + db173e6 commit bfd0deb

25 files changed

Lines changed: 1504 additions & 142 deletions

PLAN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ footer: |
234234
| 2606260211 || sonnet | [Add dedicated unit tests for layer0_html.go helpers](plan/2606260211_arch-fix-layer0-html-helper-tests.md) |
235235
| 2606260614 || sonnet | [arch-fix: add dedicated unit tests for lineclass_scan.go HTML-scanning helpers](plan/2606260614_arch-fix-lineclass-scan-helper-tests.md) |
236236
| 2606260615 || sonnet | [Add dedicated unit tests for unexported helpers in cue/cuelite/engine.go](plan/2606260615_arch-fix-cuelite-engine-helper-tests.md) |
237+
| 2606270013 || sonnet | [Add built-in Slidev convention](plan/2606270013_slidev-convention.md) |
237238
| 2606280208 || opus | [External URL link checking rule (MDS072)](plan/2606280208_external-link-check.md) |
238239
| 2606292015 | 🔲 | opus | [Scope the LSP workspace singleton per client so instances coexist](plan/2606292015_lsp-multi-instance-coexistence.md) |
239240
| 2607022118 | 🔲 | sonnet | [General occurrence rule — bound how often a pattern appears per scope](plan/2607022118_occurrence-rule.md) |
@@ -253,6 +254,7 @@ footer: |
253254
| 2607082052 || sonnet | [SARIF output format for `mdsmith check`](plan/2607082052_check-sarif-output.md) |
254255
| 2607121915 || sonnet | [Resolve the internal/linkgraph purity-contract mismatch for wikilink resolution](plan/2607121915_arch-fix-linkgraph-wikilink-purity.md) |
255256
| 2607170527 | 🔳 | opus | [External link checking on WASM hosts (MDS072)](plan/2607170527_wasm-external-link-check.md) |
257+
| 2607171900 | 🔳 | opus | [Slidev structure rule (MDS073) — validate layouts, slots, fields, and frontmatter keys per slide](plan/2607171900_slidev-structure-rule.md) |
256258
| 2607191917 | 🔲 | haiku | [Add dedicated unit tests for printInitCatalog and setInitUsage](plan/2607191917_arch-fix-printinitcatalog-unit-test.md) |
257259
| 2607191918 | 🔲 | haiku | [Deduplicate isClaimed between internal/schema and requiredstructure](plan/2607191918_arch-fix-isclaimed-dedup.md) |
258260
<?/catalog?>

docs/background/concepts/flavor-rule-convention-kind.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,14 @@ conventions or kinds.
4545

4646
### Convention
4747

48-
A convention is a named bundle that pairs a flavor
49-
with a set of rule presets. Selecting a convention
50-
applies both: MDS034 runs against the named flavor,
51-
and the named rule presets are applied as a base
52-
layer beneath the user's own rule config. The
48+
A convention is a named bundle of rule presets that
49+
optionally pins a flavor. Selecting a convention
50+
applies both: when a flavor is pinned, MDS034 runs
51+
against it; the named rule presets are applied as a
52+
base layer beneath the user's own rule config. The
5353
built-in conventions are `portable`, `github`,
54-
`obsidian`, `plain`, `no-llm-tells`, and four
55-
`<linter>-parity` conventions. Each one runs the
54+
`obsidian`, `plain`, `no-llm-tells`, `slidev`, and
55+
four `<linter>-parity` conventions. Each one runs the
5656
rule set a peer linter (gomarklint, mado, rumdl,
5757
markdownlint) enables by default, so `mdsmith check`
5858
matches that peer's work for a like-for-like

docs/reference/conventions.md

Lines changed: 55 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,17 @@ config key, sibling to `rules:`, `kinds:`, and
3737
`overrides:`. An unknown name is a config error.
3838

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

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

4952
## Built-in conventions
5053

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

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

124124
### `<linter>-parity`
125125

@@ -162,6 +162,26 @@ project's terms join rather than replace them.
162162

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

165+
### `slidev`
166+
167+
Disables eight default-on rules that produce false
168+
positives on [Slidev](https://sli.dev) files, and
169+
enables `slide-structure` (MDS073) to validate
170+
per-slide layouts, slots, fields, and keys. Slidev
171+
uses `---` as a slide separator (parsed as a setext
172+
underline); headings restart per slide. Pins no flavor.
173+
174+
| Rule | Why disabled |
175+
| --------------------------------------------- | ------------------------------------------ |
176+
| `heading-style` (MDS002) | `---` separator parsed as setext underline |
177+
| `heading-increment` (MDS003) | Each slide restarts at H1 |
178+
| `first-line-heading` (MDS004) | Front matter before first heading |
179+
| `no-duplicate-headings` (MDS005) | Same title on multiple slides |
180+
| `blank-line-around-headings` (MDS013) | Layout blocks interfere |
181+
| `no-trailing-punctuation-in-heading` (MDS017) | Stylistic slide titles |
182+
| `no-emphasis-as-heading` (MDS018) | Bold used for slide emphasis |
183+
| `empty-section-body` (MDS030) | Layout-only slides have no body |
184+
165185
## How presets layer with user config
166186

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

180-
Each layer deep-merges onto the previous one.
181-
Scalars at a leaf are replaced by the later layer;
182-
maps recurse key by key; lists replace by default.
183-
A convention preset provides the floor; your
184-
explicit `rules:` block overrides on top.
185-
186-
The `default` and `user` layers come from the same
187-
`cfg.Rules` map. mdsmith splits them around the
188-
convention so a convention can enable a rule that
189-
is opt-in by default (e.g. `convention: portable`
190-
turns on MDS034). Without the split, the default's
191-
`Enabled: false` would override the convention's
192-
`Enabled: true`.
200+
Each layer deep-merges: scalar leaves replace, maps
201+
recurse key by key, lists replace by default. The
202+
convention provides the floor; your `rules:` block
203+
overrides on top. mdsmith splits `default` and
204+
`user` around the convention so a convention can
205+
enable an opt-in rule without being overridden by
206+
the default's `Enabled: false`.
193207

194208
For example, the `github` convention sets
195209
`no-inline-html.allow: [details, summary]`. To
@@ -210,20 +224,11 @@ and always appends — see [word-list files](wordlist-files.md).
210224

211225
## Disabling MDS034
212226

213-
A convention applies its rule presets at config
214-
load time. Disabling `markdown-flavor` itself does
215-
not disable the rules a convention turned on.
216-
217-
```yaml
218-
convention: portable
219-
rules:
220-
markdown-flavor: false
221-
```
222-
223-
A bool-only `markdown-flavor: false` entry toggles
224-
`enabled` without erasing the preset's settings. The
225-
rule stays configured but its `Check()` is gated
226-
off. The other rules in the preset are untouched.
227+
Convention presets apply at config load. A bool-only
228+
`markdown-flavor: false` entry in `rules:` toggles
229+
`enabled` without erasing preset settings — the rule
230+
is gated off but the other convention rules are
231+
untouched.
227232

228233
## User-defined conventions
229234

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

274279
The built-in names `portable`, `github`,
275-
`obsidian`, `plain`, `no-llm-tells`, and the four
276-
`<linter>-parity` conventions are reserved. Defining
277-
a `conventions.portable` entry is a config error.
278-
This keeps the built-in names stable across docs and
279-
tutorials.
280+
`obsidian`, `plain`, `no-llm-tells`, `slidev`, and
281+
the four `<linter>-parity` conventions are reserved.
282+
Defining a `conventions.portable` entry is a config
283+
error. This keeps the built-in names stable across
284+
docs and tutorials.
280285

281-
### Resolution order
286+
### Resolution order and layering
282287

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

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

293-
### Interaction with top-level rules
294-
295297
User-defined conventions apply as a base layer, like
296-
the built-in conventions. A top-level `rules:` entry
297-
overrides the convention preset for that rule; the
298-
rest of the preset remains. `mdsmith kinds resolve
299-
<file>` labels user-convention layers with a
300-
`(user)` suffix.
298+
the built-ins. A top-level `rules:` entry overrides
299+
the convention preset; `mdsmith kinds resolve <file>`
300+
labels user-convention layers with a `(user)` suffix.
301301

302302
## Inspecting an effective convention
303303

docs/research/markdownlint-coverage/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,7 @@ row-expr: |
826826
| [MDS067](../../../internal/rules/MDS067-callout-type/README.md) callout-type |||||||
827827
| [MDS069](../../../internal/rules/MDS069-unique-frontmatter/README.md) unique-frontmatter |||||||
828828
| [MDS071](../../../internal/rules/MDS071-required-frontmatter/README.md) required-frontmatter |||||||
829+
| [MDS073](../../../internal/rules/MDS073-slide-structure/README.md) slide-structure |||||||
829830
<?/catalog?>
830831

831832
## Generated sections (directives) (mdsmith-only)

internal/config/convention_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,48 @@ func TestApplyConvention_NoLLMTells_EnablesRulesWithSettings(t *testing.T) {
141141
assert.True(t, dlt.Enabled)
142142
}
143143

144+
func TestApplyConvention_Slidev_FlavorAnyAllowsUserFlavor(t *testing.T) {
145+
// slidev uses FlavorAny — it imposes no renderer requirement and
146+
// must not reject a user's markdown-flavor setting.
147+
cfg := &Config{
148+
Convention: "slidev",
149+
Rules: map[string]RuleCfg{
150+
"markdown-flavor": {Enabled: true, Settings: map[string]any{"flavor": "gfm"}},
151+
},
152+
}
153+
require.NoError(t, applyConvention(cfg))
154+
require.NotNil(t, cfg.ConventionPreset)
155+
}
156+
157+
func TestApplyConvention_Slidev_DisablesEightRulesInPreset(t *testing.T) {
158+
cfg := &Config{Convention: "slidev"}
159+
require.NoError(t, applyConvention(cfg))
160+
require.NotNil(t, cfg.ConventionPreset)
161+
162+
disabledRules := []string{
163+
"heading-style",
164+
"heading-increment",
165+
"first-line-heading",
166+
"no-duplicate-headings",
167+
"blank-line-around-headings",
168+
"no-trailing-punctuation-in-heading",
169+
"no-emphasis-as-heading",
170+
"empty-section-body",
171+
}
172+
for _, rule := range disabledRules {
173+
p, ok := cfg.ConventionPreset[rule]
174+
if assert.True(t, ok, "slidev preset must contain rule %q", rule) {
175+
assert.False(t, p.Enabled, "slidev preset must disable rule %q", rule)
176+
}
177+
}
178+
179+
// slidev also enables the opt-in slide-structure rule.
180+
ss, ok := cfg.ConventionPreset["slide-structure"]
181+
if assert.True(t, ok, "slidev preset must contain slide-structure") {
182+
assert.True(t, ss.Enabled, "slidev preset must enable slide-structure")
183+
}
184+
}
185+
144186
func TestApplyConvention_FlavorAgreeAccepted(t *testing.T) {
145187
cfg := &Config{
146188
Convention: "github",

internal/convention/convention.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,49 @@ var conventions = map[string]Convention{
181181
},
182182
},
183183
},
184+
// slidev disables the eight default-on rules that produce false
185+
// positives on Slidev presentation Markdown files. Slidev uses
186+
// `---` as a slide separator, which causes heading rules to fire
187+
// across slide boundaries: heading level restarts per slide,
188+
// the same H1 title may appear on multiple slides, layout blocks
189+
// are parsed as setext headings, emphasis stands in for headings,
190+
// and layout-only slides have no body text. The eight disabled
191+
// rules are: heading-style (MDS002), heading-increment (MDS003),
192+
// first-line-heading (MDS004), no-duplicate-headings (MDS005),
193+
// blank-line-around-headings (MDS013),
194+
// no-trailing-punctuation-in-heading (MDS017),
195+
// no-emphasis-as-heading (MDS018), and empty-section-body (MDS030).
196+
//
197+
// Flavor is FlavorAny: Slidev authors may render with GFM,
198+
// CommonMark, or any other flavor, and the convention must not
199+
// force a flavor change. The convention does not enable
200+
// markdown-flavor (MDS034); the loader skips the flavor-conflict
201+
// guard for a convention whose flavor is FlavorAny.
202+
//
203+
// Parser-level handling of `---` as a page separator is out of
204+
// scope for the disabled rules above.
205+
//
206+
// Beyond silencing false positives, the convention enables
207+
// slide-structure (MDS073): a Slidev-aware rule that adds checks
208+
// the built-in rules cannot express — unmatched ::slot:: markers,
209+
// unknown layouts, missing layout-required fields, and typo'd
210+
// per-slide frontmatter keys. MDS073 is opt-in (off by default),
211+
// so the convention is what turns it on for decks.
212+
"slidev": {
213+
Name: "slidev",
214+
Flavor: FlavorAny,
215+
Rules: map[string]RulePreset{
216+
"heading-style": {Enabled: false},
217+
"heading-increment": {Enabled: false},
218+
"first-line-heading": {Enabled: false},
219+
"no-duplicate-headings": {Enabled: false},
220+
"blank-line-around-headings": {Enabled: false},
221+
"no-trailing-punctuation-in-heading": {Enabled: false},
222+
"no-emphasis-as-heading": {Enabled: false},
223+
"empty-section-body": {Enabled: false},
224+
"slide-structure": {Enabled: true},
225+
},
226+
},
184227
// no-llm-tells ships the mechanical layer of the docs-author
185228
// anti-slop catalog as a one-key convention. It enables MDS056
186229
// (forbidden-text) with a curated list of LLM vocabulary and phrase

internal/convention/convention_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,48 @@ func TestLookup_UnknownListsUserAndBuiltin(t *testing.T) {
244244
assert.Contains(t, err.Error(), "portable", "error must list built-in name")
245245
}
246246

247+
func TestLookup_Slidev(t *testing.T) {
248+
c, err := Lookup("slidev", nil)
249+
require.NoError(t, err)
250+
assert.Equal(t, "slidev", c.Name)
251+
assert.Equal(t, FlavorAny, c.Flavor)
252+
253+
disabledRules := []string{
254+
"heading-style",
255+
"heading-increment",
256+
"first-line-heading",
257+
"no-duplicate-headings",
258+
"blank-line-around-headings",
259+
"no-trailing-punctuation-in-heading",
260+
"no-emphasis-as-heading",
261+
"empty-section-body",
262+
}
263+
for _, rule := range disabledRules {
264+
p, ok := c.Rules[rule]
265+
if assert.True(t, ok, "slidev convention must mention rule %q", rule) {
266+
assert.False(t, p.Enabled, "slidev convention must disable rule %q", rule)
267+
}
268+
}
269+
270+
// Beyond the eight disabled rules, slidev enables the opt-in
271+
// slide-structure rule (MDS073) — the additive Slidev check.
272+
ss, ok := c.Rules["slide-structure"]
273+
if assert.True(t, ok, "slidev convention must mention slide-structure") {
274+
assert.True(t, ss.Enabled, "slidev convention must enable slide-structure")
275+
}
276+
277+
require.Len(t, c.Rules, len(disabledRules)+1,
278+
"slidev convention rule count drifted")
279+
}
280+
247281
func TestNamesSorted(t *testing.T) {
248282
names := Names()
249283
assert.True(t, sort.StringsAreSorted(names),
250284
"Names should return a sorted slice; got %v", names)
251285
assert.ElementsMatch(t, []string{
252286
"github", "gomarklint-parity", "mado-parity", "markdownlint-parity",
253287
"no-llm-tells", "obsidian", "plain", "portable", "rumdl-parity",
288+
"slidev",
254289
}, names)
255290
}
256291

internal/integration/perrule_bench_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ var perRuleAllocCeiling = map[string]float64{
315315
"MDS071": 4, // required-frontmatter: 0 allocs (inert without fields)
316316
// MDS072 (external-link-check) is network-bound and excluded from
317317
// this gate via isNetworkBound; it has no alloc ceiling here.
318+
"MDS073": 4, // slide-structure: 0 allocs (inert without slide markers)
318319
}
319320

320321
// init pins MDS043's allocs ceiling from the build-tagged

internal/integration/rules_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import (
7676
_ "github.com/jeduden/mdsmith/internal/rules/samefileanchor"
7777
_ "github.com/jeduden/mdsmith/internal/rules/singleh1"
7878
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
79+
_ "github.com/jeduden/mdsmith/internal/rules/slidevstructure"
7980
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
8081
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
8182
_ "github.com/jeduden/mdsmith/internal/rules/toc"

internal/integration/testdata/rule_walk_audit.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,5 +779,16 @@
779779
"is_node_checker": false,
780780
"uses_ast_walk": true,
781781
"reads_file_ast": true
782+
},
783+
{
784+
"id": "MDS073",
785+
"name": "slide-structure",
786+
"category": "A-no-skipping",
787+
"nil_ast_safe": true,
788+
"code_block_sensitive": false,
789+
"fired": true,
790+
"is_node_checker": false,
791+
"uses_ast_walk": false,
792+
"reads_file_ast": false
782793
}
783794
]

0 commit comments

Comments
 (0)