write/server/routes/lint.js
chris 55375d2ff0 Built-in spell & grammar checker (LanguageTool)
Server:
- New POST /api/lint/check proxies to LanguageTool API (public free
  endpoint by default; override with LANGUAGETOOL_URL env var for
  self-hosted instance)
- Returns trimmed match list: message, offset, length, replacements,
  kind (spelling|grammar), ruleId
- Disables cosmetic rules that don't suit creative writing
- 20-second timeout; falls back with 502 on error

Frontend:
- LintMark: transient TipTap Mark extension (never saved to DB —
  stripLintMarks() removes them from getJSON() before onChange fires)
- buildTextMap(): walks ProseMirror doc tree, builds parallel plain-
  text string + position array so LanguageTool char offsets map back
  to exact PM positions even across paragraphs / headings
- clearLintMarks() + runLint(): check → clear old marks → apply new
  marks in one transaction, no flicker
- Click on underlined text → fixed-position popover showing the error
  message + up to 6 one-click replacement buttons + Ignore / ✕
- Applying a replacement triggers an automatic re-check after 600 ms
- lintStatus: idle → checking → done → stale (when user edits)
- ABC toolbar button shows count badge when errors found, ✓ when clean
- Wavy red underline = spelling, wavy blue = grammar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:35:38 -04:00

56 lines
1.9 KiB
JavaScript

import { Router } from 'express'
import { auth } from '../middleware/auth.js'
// Point at a self-hosted LanguageTool instance by setting LANGUAGETOOL_URL.
// Falls back to the free public API (rate-limited but good for personal use).
const LT_URL = (process.env.LANGUAGETOOL_URL || 'https://api.languagetoolplus.com/v2').replace(/\/$/, '')
const router = Router()
router.use(auth)
router.post('/check', async (req, res) => {
const { text, language = 'en-US' } = req.body || {}
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' })
if (text.length > 40000) return res.status(400).json({ error: 'text too long (max 40 000 chars)' })
try {
const body = new URLSearchParams({
text,
language,
// Suppress cosmetic rules that aren't useful in a creative-writing context
disabledRules: 'WHITESPACE_RULE,WORD_CONTAINS_UPPERCASE,EN_UNPAIRED_BRACKETS,DASH_RULE',
})
const r = await fetch(`${LT_URL}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
signal: AbortSignal.timeout(20000),
})
if (!r.ok) {
const msg = await r.text().catch(() => '')
throw new Error(`LanguageTool ${r.status}: ${msg.slice(0, 200)}`)
}
const data = await r.json()
// Send only what the client needs — keep the payload small
res.json({
matches: (data.matches || []).map(m => ({
message: m.message,
offset: m.offset,
length: m.length,
replacements: (m.replacements || []).slice(0, 6).map(r => r.value),
kind: m.rule?.category?.id === 'TYPOS' ? 'spelling' : 'grammar',
ruleId: m.rule?.id ?? '',
})),
})
} catch (err) {
console.error('[lint] LanguageTool error:', err.message)
res.status(502).json({ error: err.message })
}
})
export default router