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>
This commit is contained in:
parent
a8f93582bf
commit
55375d2ff0
@ -1,5 +1,6 @@
|
|||||||
import { forwardRef, useEffect, useRef, useImperativeHandle } from 'react'
|
import { forwardRef, useEffect, useRef, useImperativeHandle, useState, useCallback } from 'react'
|
||||||
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
|
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import { Mark, mergeAttributes, getMarkRange } from '@tiptap/core'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
@ -9,7 +10,9 @@ import TextAlign from '@tiptap/extension-text-align'
|
|||||||
import Highlight from '@tiptap/extension-highlight'
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
import Toolbar from './Toolbar'
|
import Toolbar from './Toolbar'
|
||||||
import ImageView from './ImageView'
|
import ImageView from './ImageView'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
// ── Custom image extension ─────────────────────────────────────────────────
|
||||||
const CustomImage = Image.extend({
|
const CustomImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@ -23,14 +26,98 @@ const CustomImage = Image.extend({
|
|||||||
},
|
},
|
||||||
}).configure({ allowBase64: false, inline: false })
|
}).configure({ allowBase64: false, inline: false })
|
||||||
|
|
||||||
const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fontSize, onFontSizeChange }, ref) {
|
// ── LintMark — transient mark for spelling / grammar underlines ────────────
|
||||||
|
const LintMark = Mark.create({
|
||||||
|
name: 'lintError',
|
||||||
|
inclusive: false, // don't extend when typing at a boundary
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
kind: { default: 'grammar' }, // 'spelling' | 'grammar'
|
||||||
|
message: { default: '' },
|
||||||
|
replacements: {
|
||||||
|
default: [],
|
||||||
|
parseHTML: el => JSON.parse(el.getAttribute('data-r') || '[]'),
|
||||||
|
renderHTML: att => ({ 'data-r': JSON.stringify(att.replacements ?? []) }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() { return [] }, // never parse from saved HTML — marks are transient
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes(
|
||||||
|
{ 'data-lint': '', class: `lint-mark lint-mark--${HTMLAttributes.kind}` },
|
||||||
|
HTMLAttributes,
|
||||||
|
), 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Lint helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Build plain text + ProseMirror position map from the doc
|
||||||
|
function buildTextMap(doc) {
|
||||||
|
const text = [], posMap = []
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.isText) {
|
||||||
|
for (let i = 0; i < node.text.length; i++) {
|
||||||
|
text.push(node.text[i])
|
||||||
|
posMap.push(pos + i)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Paragraph / heading separators → '\n' (no PM position — null in map)
|
||||||
|
if (node.isBlock && text.length > 0 && text[text.length - 1] !== '\n') {
|
||||||
|
text.push('\n')
|
||||||
|
posMap.push(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { text: text.join(''), posMap }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all lint marks in one transaction
|
||||||
|
function clearLintMarks(editor) {
|
||||||
|
const { tr, doc, schema } = editor.state
|
||||||
|
const mt = schema.marks.lintError
|
||||||
|
if (!mt) return
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText) return
|
||||||
|
node.marks.filter(m => m.type === mt)
|
||||||
|
.forEach(() => tr.removeMark(pos, pos + node.nodeSize, mt))
|
||||||
|
})
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip lint marks from the JSON before saving (they're UI-only)
|
||||||
|
function stripLintMarks(node) {
|
||||||
|
if (!node) return node
|
||||||
|
const n = { ...node }
|
||||||
|
if (n.marks) {
|
||||||
|
n.marks = n.marks.filter(m => m.type !== 'lintError')
|
||||||
|
if (!n.marks.length) delete n.marks
|
||||||
|
}
|
||||||
|
if (n.content) n.content = n.content.map(stripLintMarks)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor component ───────────────────────────────────────────────────────
|
||||||
|
const Editor = forwardRef(function Editor(
|
||||||
|
{ content, onChange, onImageUpload, fontSize, onFontSizeChange }, ref,
|
||||||
|
) {
|
||||||
const synced = useRef(false)
|
const synced = useRef(false)
|
||||||
|
|
||||||
|
// ── Lint state ───────────────────────────────────────────────────────────
|
||||||
|
const [lintStatus, setLintStatus] = useState('idle') // idle | checking | done | stale
|
||||||
|
const [lintCount, setLintCount] = useState(0)
|
||||||
|
const [lintPopover, setLintPopover] = useState(null) // { top, left, message, replacements, from, to }
|
||||||
|
const lintStatusRef = useRef('idle')
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
Underline,
|
Underline,
|
||||||
CustomImage,
|
CustomImage,
|
||||||
|
LintMark,
|
||||||
Placeholder.configure({ placeholder: 'Begin your story here…' }),
|
Placeholder.configure({ placeholder: 'Begin your story here…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
@ -38,7 +125,12 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo
|
|||||||
],
|
],
|
||||||
content: '',
|
content: '',
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
onChange(editor.getJSON())
|
onChange(stripLintMarks(editor.getJSON()))
|
||||||
|
// Mark results as stale when the user edits so they know to re-check
|
||||||
|
if (lintStatusRef.current === 'done') {
|
||||||
|
lintStatusRef.current = 'stale'
|
||||||
|
setLintStatus('stale')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -60,6 +152,105 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo
|
|||||||
}
|
}
|
||||||
}, [editor, content])
|
}, [editor, content])
|
||||||
|
|
||||||
|
// ── Lint check ───────────────────────────────────────────────────────────
|
||||||
|
const runLint = useCallback(async () => {
|
||||||
|
if (!editor || lintStatusRef.current === 'checking') return
|
||||||
|
lintStatusRef.current = 'checking'
|
||||||
|
setLintStatus('checking')
|
||||||
|
setLintPopover(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text, posMap } = buildTextMap(editor.state.doc)
|
||||||
|
if (!text.trim()) {
|
||||||
|
clearLintMarks(editor)
|
||||||
|
setLintCount(0)
|
||||||
|
setLintStatus('done')
|
||||||
|
lintStatusRef.current = 'done'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await api.lintCheck(text)
|
||||||
|
|
||||||
|
// Clear old marks, then apply all new ones in one transaction
|
||||||
|
clearLintMarks(editor)
|
||||||
|
const { tr, schema } = editor.state
|
||||||
|
const lintType = schema.marks.lintError
|
||||||
|
|
||||||
|
for (const match of data.matches) {
|
||||||
|
let pmFrom = null, pmTo = null
|
||||||
|
for (let i = match.offset; i < match.offset + match.length && i < posMap.length; i++) {
|
||||||
|
if (posMap[i] !== null) {
|
||||||
|
if (pmFrom === null) pmFrom = posMap[i]
|
||||||
|
pmTo = posMap[i] + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pmFrom === null) continue
|
||||||
|
tr.addMark(pmFrom, pmTo, lintType.create({
|
||||||
|
kind: match.kind,
|
||||||
|
message: match.message,
|
||||||
|
replacements: match.replacements,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
|
||||||
|
setLintCount(data.matches.length)
|
||||||
|
setLintStatus('done')
|
||||||
|
lintStatusRef.current = 'done'
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[lint] Check failed:', err)
|
||||||
|
setLintStatus('idle')
|
||||||
|
lintStatusRef.current = 'idle'
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// ── Popover on clicking a lint mark ──────────────────────────────────────
|
||||||
|
function handleEditorClick(e) {
|
||||||
|
if (!editor) return
|
||||||
|
const target = e.target.closest('[data-lint]')
|
||||||
|
if (!target) { setLintPopover(null); return }
|
||||||
|
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
|
||||||
|
const $pos = editor.state.doc.resolve(coords.pos)
|
||||||
|
const lintMT = editor.state.schema.marks.lintError
|
||||||
|
const mark = $pos.marks().find(m => m.type === lintMT)
|
||||||
|
if (!mark) return
|
||||||
|
|
||||||
|
const range = getMarkRange($pos, lintMT)
|
||||||
|
if (!range) return
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
setLintPopover({
|
||||||
|
// Fixed position so we don't have to fight scroll offsets
|
||||||
|
top: rect.bottom + 8,
|
||||||
|
left: Math.max(8, Math.min(rect.left, window.innerWidth - 270)),
|
||||||
|
message: mark.attrs.message,
|
||||||
|
replacements: mark.attrs.replacements || [],
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReplacement(replacement) {
|
||||||
|
if (!lintPopover || !editor) return
|
||||||
|
const { from, to } = lintPopover
|
||||||
|
editor.chain().setTextSelection({ from, to }).insertContent(replacement).focus().run()
|
||||||
|
setLintPopover(null)
|
||||||
|
// Re-run lint after a short pause so the new text gets checked
|
||||||
|
setTimeout(runLint, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissLintMark() {
|
||||||
|
if (!lintPopover || !editor) return
|
||||||
|
const { from, to } = lintPopover
|
||||||
|
const { tr, schema } = editor.state
|
||||||
|
tr.removeMark(from, to, schema.marks.lintError)
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
setLintPopover(null)
|
||||||
|
setLintCount(c => Math.max(0, c - 1))
|
||||||
|
}
|
||||||
|
|
||||||
const wordCount = editor?.storage.characterCount.words() ?? 0
|
const wordCount = editor?.storage.characterCount.words() ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -69,11 +260,36 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo
|
|||||||
onImageUpload={onImageUpload}
|
onImageUpload={onImageUpload}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onFontSizeChange={onFontSizeChange}
|
onFontSizeChange={onFontSizeChange}
|
||||||
|
onLint={runLint}
|
||||||
|
lintStatus={lintStatus}
|
||||||
|
lintCount={lintCount}
|
||||||
/>
|
/>
|
||||||
<div className="editor-wrap">
|
<div className="editor-wrap" onClick={handleEditorClick}>
|
||||||
<EditorContent editor={editor} className="editor-body" />
|
<EditorContent editor={editor} className="editor-body" />
|
||||||
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lintPopover && (
|
||||||
|
<div
|
||||||
|
className="lint-popover"
|
||||||
|
style={{ top: lintPopover.top, left: lintPopover.left }}
|
||||||
|
// Stop clicks inside the popover from bubbling to the editor click handler
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="lint-popover-msg">{lintPopover.message}</p>
|
||||||
|
{lintPopover.replacements.length > 0 && (
|
||||||
|
<div className="lint-suggestions">
|
||||||
|
{lintPopover.replacements.map((r, i) => (
|
||||||
|
<button key={i} className="lint-suggestion" onClick={() => applyReplacement(r)}>{r}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="lint-popover-actions">
|
||||||
|
<button className="btn btn-ghost lint-ignore" onClick={dismissLintMark}>Ignore</button>
|
||||||
|
<button className="btn btn-ghost lint-close" onClick={() => setLintPopover(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange, onLint, lintStatus, lintCount }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [isReading, setIsReading] = useState(false)
|
const [isReading, setIsReading] = useState(false)
|
||||||
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
||||||
@ -247,6 +247,31 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
⌨
|
⌨
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onLint && (
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn lint-btn${
|
||||||
|
lintStatus === 'done' && lintCount === 0 ? ' lint-clean' :
|
||||||
|
lintStatus === 'done' && lintCount > 0 ? ' lint-errors' :
|
||||||
|
lintStatus === 'stale' ? ' lint-stale' : ''
|
||||||
|
}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); onLint() }}
|
||||||
|
disabled={lintStatus === 'checking'}
|
||||||
|
title={
|
||||||
|
lintStatus === 'checking' ? 'Checking…' :
|
||||||
|
lintStatus === 'done' && lintCount === 0 ? 'No issues found — click to re-check' :
|
||||||
|
lintStatus === 'done' ? `${lintCount} issue${lintCount !== 1 ? 's' : ''} — click to re-check` :
|
||||||
|
lintStatus === 'stale' ? 'Text changed — click to re-check' :
|
||||||
|
'Check spelling & grammar'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lintStatus === 'checking' ? '…' :
|
||||||
|
lintStatus === 'done' && lintCount === 0 ? '✓' :
|
||||||
|
lintStatus === 'done' ? `${lintCount}` :
|
||||||
|
lintStatus === 'stale' ? 'ABC*' :
|
||||||
|
'ABC'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="tts-settings-wrap">
|
<div className="tts-settings-wrap">
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn${showTtsMenu ? ' active' : ''}`}
|
className={`toolbar-btn${showTtsMenu ? ' active' : ''}`}
|
||||||
|
|||||||
@ -78,6 +78,8 @@ export const api = {
|
|||||||
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
|
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
|
||||||
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
|
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
|
||||||
|
|
||||||
|
lintCheck: (text, language = 'en-US') => req('POST', '/api/lint/check', { text, language }),
|
||||||
|
|
||||||
getPrompt: () => req('GET', '/api/prompts'),
|
getPrompt: () => req('GET', '/api/prompts'),
|
||||||
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
||||||
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
||||||
|
|||||||
@ -1479,6 +1479,77 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
50% { opacity: 0.45; }
|
50% { opacity: 0.45; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Spell / Grammar Check ────────────────────────────── */
|
||||||
|
|
||||||
|
/* Wavy underlines on lint-marked text */
|
||||||
|
.lint-mark {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-style: wavy;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lint-mark--spelling { text-decoration-color: #f87171; } /* red */
|
||||||
|
.lint-mark--grammar { text-decoration-color: #60a5fa; } /* blue */
|
||||||
|
|
||||||
|
/* Toolbar lint button states */
|
||||||
|
.lint-btn { font-family: var(--font-head); font-size: 0.75rem; min-width: 36px; }
|
||||||
|
.lint-btn.lint-clean { color: #4ade80; border-color: #4ade8040; }
|
||||||
|
.lint-btn.lint-errors { color: #f87171; border-color: #f8717140; }
|
||||||
|
.lint-btn.lint-stale { color: var(--text-muted); opacity: 0.7; }
|
||||||
|
.lint-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
.lint-popover {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 600;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) * 2);
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lint-popover-msg {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lint-suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lint-suggestion {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 1px);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.lint-suggestion:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lint-popover-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.lint-ignore { font-size: 0.75rem; padding: 0.2rem 0.5rem; }
|
||||||
|
.lint-close { font-size: 0.85rem; padding: 0.2rem 0.45rem; }
|
||||||
|
|
||||||
/* ── Typewriter Mode ──────────────────────────────────── */
|
/* ── Typewriter Mode ──────────────────────────────────── */
|
||||||
|
|
||||||
/* Extra bottom padding so the last line can scroll to screen centre */
|
/* Extra bottom padding so the last line can scroll to screen centre */
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import imagesRoutes from './routes/images.js'
|
|||||||
import adminRoutes from './routes/admin.js'
|
import adminRoutes from './routes/admin.js'
|
||||||
import promptsRoutes from './routes/prompts.js'
|
import promptsRoutes from './routes/prompts.js'
|
||||||
import notesRoutes from './routes/notes.js'
|
import notesRoutes from './routes/notes.js'
|
||||||
|
import lintRoutes from './routes/lint.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const app = express()
|
const app = express()
|
||||||
@ -20,5 +21,6 @@ app.use('/api/images', imagesRoutes)
|
|||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
app.use('/api/prompts', promptsRoutes)
|
app.use('/api/prompts', promptsRoutes)
|
||||||
app.use('/api/stories/:storyId/notes', notesRoutes)
|
app.use('/api/stories/:storyId/notes', notesRoutes)
|
||||||
|
app.use('/api/lint', lintRoutes)
|
||||||
|
|
||||||
app.listen(3000, () => console.log('Server ready on :3000'))
|
app.listen(3000, () => console.log('Server ready on :3000'))
|
||||||
|
|||||||
55
server/routes/lint.js
Normal file
55
server/routes/lint.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
Loading…
x
Reference in New Issue
Block a user