diff --git a/PLAN.md b/PLAN.md index 06cfaad9c..948fcb4d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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) | diff --git a/docs/background/concepts/flavor-rule-convention-kind.md b/docs/background/concepts/flavor-rule-convention-kind.md index 3c6579bd3..3a2ee6f73 100644 --- a/docs/background/concepts/flavor-rule-convention-kind.md +++ b/docs/background/concepts/flavor-rule-convention-kind.md @@ -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 -`-parity` conventions. Each one runs the +`obsidian`, `plain`, `no-llm-tells`, `slidev`, and +four `-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 diff --git a/docs/reference/conventions.md b/docs/reference/conventions.md index 146a3ddee..575488b5c 100644 --- a/docs/reference/conventions.md +++ b/docs/reference/conventions.md @@ -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 `-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 @@ -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`. ### `-parity` @@ -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 @@ -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 @@ -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 @@ -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 -`-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 `-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 -` labels user-convention layers with a -`(user)` suffix. +the built-ins. A top-level `rules:` entry overrides +the convention preset; `mdsmith kinds resolve ` +labels user-convention layers with a `(user)` suffix. ## Inspecting an effective convention diff --git a/internal/config/convention_test.go b/internal/config/convention_test.go index 7f02da936..86a84ed64 100644 --- a/internal/config/convention_test.go +++ b/internal/config/convention_test.go @@ -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", diff --git a/internal/convention/convention.go b/internal/convention/convention.go index afd38fdbe..65fea0fd7 100644 --- a/internal/convention/convention.go +++ b/internal/convention/convention.go @@ -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 diff --git a/internal/convention/convention_test.go b/internal/convention/convention_test.go index dbff4ded7..d5b9a710e 100644 --- a/internal/convention/convention_test.go +++ b/internal/convention/convention_test.go @@ -195,6 +195,32 @@ 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), @@ -202,6 +228,7 @@ func TestNamesSorted(t *testing.T) { assert.ElementsMatch(t, []string{ "github", "gomarklint-parity", "mado-parity", "markdownlint-parity", "no-llm-tells", "obsidian", "plain", "portable", "rumdl-parity", + "slidev", }, names) } diff --git a/plan/2606270013_slidev-convention.md b/plan/2606270013_slidev-convention.md new file mode 100644 index 000000000..eb61b5c27 --- /dev/null +++ b/plan/2606270013_slidev-convention.md @@ -0,0 +1,84 @@ +--- +id: 2606270013 +title: Add built-in Slidev convention +status: "✅" +summary: >- + Add a slidev built-in convention that disables the + eight rules that produce false positives on Slidev + presentation files, giving users a one-key preset + instead of a manual eight-rule override block. +model: sonnet +--- +# Add built-in Slidev convention + +## Goal + +Add a `slidev` built-in convention that disables +the eight default-on rules that fire false positives +on Slidev presentation Markdown, so users can apply +a single `convention: slidev` instead of a manual +eight-rule override block. Closes issue #41. + +## Context + +Slidev separates slides with `---`. This confuses +the parser. Headings jump per slide, slides can share +H1 titles, emphasis stands in for headings, and layout +front-matter blocks look like setext headings. The +current workaround is a manual eight-rule override +block. + +The parser-level `---`-as-page-separator issue is +out of scope for this plan. The convention addresses +the rule-configuration false positives only. + +Eight default-on rules must be disabled: + +| Rule ID | Name | Why it fires | +| ------- | ------------------------------------ | --------------------------------------- | +| MDS002 | `heading-style` | Layout blocks parsed as setext headings | +| MDS003 | `heading-increment` | Each slide restarts at H1 | +| MDS004 | `first-line-heading` | Front matter before first heading | +| MDS005 | `no-duplicate-headings` | Same heading on multiple slides | +| MDS013 | `blank-line-around-headings` | Layout blocks interfere | +| MDS017 | `no-trailing-punctuation-in-heading` | Stylistic slide titles | +| MDS018 | `no-emphasis-as-heading` | Bold used for slide-level emphasis | +| MDS030 | `empty-section-body` | Layout-only slides have no body | + +## Tasks + +1. [x] Add a `slidev` entry to the `conventions` + map in + [`internal/convention/convention.go`](../internal/convention/convention.go). + Use `FlavorAny` (no flavor enforcement — users + may use GFM, CommonMark, or Obsidian flavors). + Disable the eight rules listed above. +2. [x] Add `TestLookup_Slidev` in + `internal/convention/convention_test.go`. + Assert that `Lookup("slidev", nil)` resolves + without error and that all eight rules are + present with `Enabled: false`. +3. [x] Update + [`docs/reference/conventions.md`](../docs/reference/conventions.md) + to document the new convention: purpose, + the eight-rule list, example config, and the + known limitation (parser-level `---` handling + is not addressed). +4. [x] Run `go run ./cmd/mdsmith fix .` and + `go run ./cmd/mdsmith check .` — zero failures. +5. [x] Run `go test ./internal/convention/...` and + `go test ./...` — all green. + +## Acceptance Criteria + +- [x] `Lookup("slidev", nil)` returns a convention + with eight rules, all `Enabled: false`. +- [x] `internal/convention/convention_test.go` + has a `TestLookup_Slidev` that covers all + eight disabled rules. +- [x] `docs/reference/conventions.md` documents + `slidev` with purpose, rule list, and limitation + note. +- [x] `go test ./...` green. +- [x] `go tool golangci-lint run` reports no issues. +- [x] `go run ./cmd/mdsmith check .` — 0 failures.