Overview
CodeInk includes remark-lint integration for real-time markdown validation. The linter helps maintain consistent, high-quality markdown with automatic fixes for common issues.
Features
Real-Time Linting
Linting occurs as you type with a configurable delay:
import { linter } from "@codemirror/lint"
import { markdownLint } from "@/scripts/markdown-linter"
const state = EditorState . create ({
extensions: [
linter ( markdownLint , { delay: 500 }),
// Other extensions...
],
})
The 500ms delay prevents excessive linting while typing, improving performance without sacrificing responsiveness.
Visual Indicators
Lint issues are shown directly in the editor:
Warnings highlighted with underlines
Hover tooltips with issue descriptions
Error count in the status bar
One-Click Auto-Fix
Many issues can be automatically corrected:
import { fixMarkdown } from "@/scripts/markdown-linter"
fixBtn . addEventListener ( "click" , () => {
const content = getEditorContent ()
const fixed = fixMarkdown ( content )
if ( fixed !== content ) setEditorContent ( fixed )
})
Lint Rules
Recommended Preset
CodeInk uses the remark recommended preset:
import remarkPresetLintRecommended from "remark-preset-lint-recommended"
const processor = unified ()
. use ( remarkParse )
. use ( remarkPresetLintRecommended )
This includes rules for:
Consistent list markers
Proper heading structure
Link reference validation
Code block formatting
And more…
Custom Rules
Undefined References
Detects broken link references with custom allowances:
import remarkLintNoUndefinedReferences from "remark-lint-no-undefined-references"
. use ( remarkLintNoUndefinedReferences , {
allow: [ / ^ !/ , "x" , "X" , " " ],
})
This allows:
Alert syntax (!NOTE, !WARNING, etc.)
Task list checkboxes ([x], [X], [ ])
Smart Heading Corrections
Custom plugin to fix headings without spaces:
// Transforms:
// #Heading -> # Heading
// ##BadHeading -> ## BadHeading
function remarkSmartHeadings () {
return ( tree : any ) => {
visit ( tree , "paragraph" , ( node , index , parent ) => {
const firstChild = node . children [ 0 ]
if ( firstChild . type === "text" && firstChild . value ) {
const textValue = firstChild . value
if ( ! textValue . startsWith ( "#" )) return
// Count hashes
let hashCount = 0
while ( hashCount < textValue . length && textValue [ hashCount ] === "#" ) {
hashCount ++
}
if ( hashCount > 6 || hashCount === 0 ) return
const charAfter = textValue [ hashCount ]
if ( ! charAfter || / \s / . test ( charAfter )) return
// Fix: Convert paragraph to heading
const headingText = textValue . slice ( hashCount )
const headingNode = {
type: "heading" ,
depth: hashCount ,
children: [{ type: "text" , value: headingText }],
}
parent . children [ index ] = headingNode
}
})
}
}
This plugin runs before other lint rules, so the fixed structure is validated by subsequent rules.
Auto-Fix Configuration
Stringify Options
Fixed markdown uses consistent formatting:
import remarkStringify from "remark-stringify"
. use ( remarkStringify , {
bullet: "-" , // Use - for unordered lists
emphasis: "_" , // Use _ for emphasis
strong: "*" , // Use * for strong
listItemIndent: "one" , // Single space indent
rule: "-" , // Use - for horizontal rules
})
What Gets Fixed
Lists
Headings
Links
Code Blocks
Inconsistent bullet markers → unified -
Irregular indentation → standardized spacing
Missing blank lines → added where needed
Missing spaces after # → spaces added
Inconsistent ATX style → normalized
Invalid heading levels → corrected
Broken reference definitions → fixed or removed
Inconsistent formatting → standardized
Inconsistent fence markers → unified
Missing language identifiers → preserved
Integration with CodeMirror
Diagnostic Conversion
Remark messages are converted to CodeMirror diagnostics:
export function markdownLint ( view : EditorView ) : Diagnostic [] {
const doc = view . state . doc
const text = doc . toString ()
const diagnostics : Diagnostic [] = []
try {
const file = processor . processSync ( text )
for ( const msg of file . messages ) {
let from = 0
let to = 0
if ( msg . place && "start" in msg . place && "end" in msg . place ) {
// Use offset if available
if ( typeof msg . place . start . offset === 'number' ) {
from = msg . place . start . offset
to = msg . place . end . offset
} else {
// Calculate from line/column
const startLineObj = doc . line ( msg . place . start . line )
from = startLineObj . from + ( msg . place . start . column - 1 )
const endLineObj = doc . line ( msg . place . end . line )
to = endLineObj . from + ( msg . place . end . column - 1 )
}
}
diagnostics . push ({
from ,
to ,
severity: msg . fatal === true ? "error" : "warning" ,
message: msg . message ,
source: "remark-lint" ,
})
}
} catch ( err ) {
console . error ( "Markdown linting failed" , err )
}
return diagnostics
}
Position Calculation
Remark uses 1-indexed line/column numbers, CodeMirror uses 0-indexed offsets:
// Convert 1-indexed column to 0-indexed offset
const startLineObj = doc . line ( startLine )
from = startLineObj . from + ( startCol - 1 )
Error Handling
Position calculation is wrapped in try-catch to handle edge cases:
try {
// Calculate positions
} catch ( e ) {
console . error ( "Error calculating position for lint message" , e )
continue // Skip this diagnostic
}
Status Bar Integration
Lint Counter
The status bar shows the current error count:
EditorView . updateListener . of (( update ) => {
const prevCount = diagnosticCount ( update . startState )
const currCount = diagnosticCount ( update . state )
if ( prevCount !== currCount ) {
window . dispatchEvent (
new CustomEvent ( "lint-update" , {
detail: { count: currCount }
})
)
}
})
The fix button is only shown when there are issues:
function setupLintStatusManager ( lintEl , fixBtn ) {
return ( count : number ) => {
if ( count > 0 ) {
lintEl . classList . add ( "has-issues" )
fixBtn . style . display = "block"
} else {
lintEl . classList . remove ( "has-issues" )
fixBtn . style . display = "none"
}
}
}
Unified Pipeline
Processor Architecture
A single unified processor handles both linting and fixing:
const processor = unified ()
. use ( remarkParse ) // Parse markdown to AST
. use ( remarkSmartHeadings ) // Custom transformations
. use ( remarkPresetLintRecommended ) // Apply lint rules
. use ( remarkLintNoUndefinedReferences ) // Additional rules
. use ( remarkStringify ) // Serialize AST back to markdown
Synchronous Processing
Linting is synchronous for immediate feedback:
const file = processor . processSync ( text )
Synchronous processing works because remark plugins are synchronous. The markdown parser is fast enough for real-time linting.
Debounced Linting
The linter only runs after you stop typing:
linter ( markdownLint , { delay: 500 })
Efficient Position Lookups
CodeMirror’s doc.line() method is O(log n):
const lineObj = doc . line ( lineNumber ) // Fast binary search
const offset = lineObj . from + columnOffset
Minimal Re-processing
Only the changed document is linted, not the entire project.
Example Lint Messages
Common Warnings
Warning: List markers
Warning: Heading style
Warning: Link references
Warning: Empty links
Use a consistent list marker (remark-lint:list-item-bullet-indent)
Source Code Reference
Implementation details can be found in:
/src/scripts/markdown-linter.ts - Complete linting and auto-fix logic
/src/scripts/codemirror-setup.ts - Linter integration with CodeMirror
/src/scripts/editor.ts - Status bar and fix button handling
Learn More
For complete rule documentation: