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>
56 lines
1.9 KiB
JavaScript
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
|