mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Language Tool: Initial commit
changes to the initial commit (yu-i-i): - rename env vars LANGUAGETOOL_* -> LANGUAGE_TOOL_* - introduce LANGUAGE_TOOL_HOST and LANGUAGE_TOOL_PORT - use languageToolAvailable exposed setting instead of /languagetool/status endpoint - register the LanguageTool setting in the Settings modal through the `settingsModalSpellcheckSections` slot
This commit is contained in:
@@ -196,3 +196,24 @@ services:
|
||||
LOG_LEVEL: "DEBUG"
|
||||
volumes:
|
||||
- ./data/git-bridge:/data/git-bridge
|
||||
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- Java_Xms=512m
|
||||
- Java_Xmx=2g
|
||||
- langtool_languageModel=/ngrams
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
- ./data/languagetool/ngrams:/ngrams:ro
|
||||
|
||||
@@ -419,6 +419,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
languageToolAvailable: !!Settings.languageToolURL,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
|
||||
|
||||
@@ -1063,7 +1063,12 @@ module.exports = {
|
||||
),
|
||||
],
|
||||
contactUsModal: [],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
),
|
||||
],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
pdfLogEntryHeaderActionComponents: [],
|
||||
@@ -1189,7 +1194,12 @@ module.exports = {
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [],
|
||||
settingsModalSpellcheckSections: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-section'
|
||||
),
|
||||
],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [
|
||||
@@ -1212,6 +1222,7 @@ module.exports = {
|
||||
'symbol-palette',
|
||||
'reference-picker',
|
||||
'track-changes',
|
||||
'languagetool',
|
||||
'authentication/ldap',
|
||||
'authentication/saml',
|
||||
'authentication/oidc',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
const MAX_TEXT_SIZE = 100_000 // 100 KB limit for safety
|
||||
|
||||
const LanguageToolController = {
|
||||
|
||||
/**
|
||||
* GET /languagetool/languages
|
||||
* Returns the list of supported languages from the LT service
|
||||
*/
|
||||
async getLanguages(req, res, next) {
|
||||
try {
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/languages`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
{ status: response.status },
|
||||
'LanguageTool languages request failed'
|
||||
)
|
||||
return res.status(502).json({ error: 'LanguageTool request failed' })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error fetching LanguageTool languages')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /languagetool/check
|
||||
* Proxies text/annotation data to the LanguageTool service and returns matches.
|
||||
* Accepts JSON body: { language, text?, data? }
|
||||
*/
|
||||
async check(req, res, next) {
|
||||
const { language = 'auto', text, data } = req.body
|
||||
|
||||
if (!text && !data) {
|
||||
return res.status(400).json({ error: 'text or data is required' })
|
||||
}
|
||||
|
||||
// Sanitize: reject oversized requests
|
||||
const contentStr =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: typeof data === 'object'
|
||||
? JSON.stringify(data)
|
||||
: text || ''
|
||||
|
||||
if (contentStr.length > MAX_TEXT_SIZE) {
|
||||
logger.warn(
|
||||
{ size: contentStr.length },
|
||||
'LanguageTool request exceeds size limit, truncating'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('language', language)
|
||||
|
||||
if (data) {
|
||||
const dataStr =
|
||||
typeof data === 'string' ? data : JSON.stringify(data)
|
||||
params.set('data', dataStr.slice(0, MAX_TEXT_SIZE))
|
||||
} else {
|
||||
params.set('text', (text || '').slice(0, MAX_TEXT_SIZE))
|
||||
}
|
||||
|
||||
// Disable rules that produce excessive false positives in LaTeX documents
|
||||
params.set(
|
||||
'disabledRules',
|
||||
'WHITESPACE_RULE,COMMA_PARENTHESIS_WHITESPACE,CONSECUTIVE_SPACES,DASH_RULE,UPPERCASE_SENTENCE_START'
|
||||
)
|
||||
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
logger.error(
|
||||
{ status: response.status, body },
|
||||
'LanguageTool check failed'
|
||||
)
|
||||
return res.json({ matches: [] })
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
// On timeout or network error return empty results so the linter stays silent
|
||||
if (err.type === 'request-timeout' || err.name === 'FetchError') {
|
||||
logger.warn({ err }, 'LanguageTool check timed out or failed, returning empty results')
|
||||
return res.json({ matches: [] })
|
||||
}
|
||||
logger.error({ err }, 'Error checking text with LanguageTool')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default LanguageToolController
|
||||
@@ -0,0 +1,22 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import LanguageToolController from './LanguageToolController.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init languagetool router')
|
||||
|
||||
// Authenticated endpoints
|
||||
webRouter.get(
|
||||
'/languagetool/languages',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.getLanguages
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/languagetool/check',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.check
|
||||
)
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* LanguageTool settings component — rendered inside the Settings modal
|
||||
* (Editor → Spell check section) when the languagetoolSettings module import
|
||||
* is wired up in settings-modal-context.tsx.
|
||||
*
|
||||
* Persistence: language and enabled-flag are stored in localStorage per
|
||||
* project so collaborators can have independent preferences.
|
||||
*
|
||||
* Communication with the CodeMirror extension is done via a window
|
||||
* CustomEvent `lt:settings-changed` so this component does not need to be
|
||||
* inside the CodeMirrorViewContext.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import DropdownSetting from '@/features/settings/components/dropdown-setting'
|
||||
import ToggleSetting from '@/features/settings/components/toggle-setting'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
interface LTLanguage {
|
||||
name: string
|
||||
code: string
|
||||
longCode: string
|
||||
}
|
||||
|
||||
export default function LanguageToolLanguageSetting() {
|
||||
const { projectId } = useProjectContext()
|
||||
|
||||
const [ltAvailable, setLtAvailable] = useState<boolean>(false)
|
||||
const [enabled, setEnabled] = useState<boolean>(false)
|
||||
const [language, setLanguage] = useState<string>(() => {
|
||||
return localStorage.getItem(`lt-language-${projectId}`) || 'auto'
|
||||
})
|
||||
const [languages, setLanguages] = useState<LTLanguage[]>([])
|
||||
const [languagesLoading, setLanguagesLoading] = useState(false)
|
||||
|
||||
// ── Check availability ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const { languageToolAvailable } = getMeta('ol-ExposedSettings')
|
||||
|
||||
setLtAvailable(languageToolAvailable)
|
||||
if (languageToolAvailable) {
|
||||
const storedEnabled =
|
||||
localStorage.getItem(`lt-enabled-${projectId}`) !== 'false'
|
||||
setEnabled(storedEnabled)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// ── Load language list when LT is available and toggle is on ───────────────
|
||||
useEffect(() => {
|
||||
if (!ltAvailable || !enabled || languages.length > 0) return
|
||||
|
||||
setLanguagesLoading(true)
|
||||
getJSON<LTLanguage[]>('/languagetool/languages')
|
||||
.then(langs => {
|
||||
// Sort alphabetically by display name for usability
|
||||
const sorted = [...langs].sort((a, b) => a.name.localeCompare(b.name))
|
||||
setLanguages(sorted)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLanguagesLoading(false))
|
||||
}, [ltAvailable, enabled, languages.length])
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
const handleToggle = useCallback(
|
||||
(newEnabled: boolean) => {
|
||||
setEnabled(newEnabled)
|
||||
localStorage.setItem(`lt-enabled-${projectId}`, String(newEnabled))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', {
|
||||
detail: { enabled: newEnabled, language },
|
||||
})
|
||||
)
|
||||
},
|
||||
[projectId, language]
|
||||
)
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(newLanguage: string) => {
|
||||
setLanguage(newLanguage)
|
||||
localStorage.setItem(`lt-language-${projectId}`, newLanguage)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', {
|
||||
detail: { language: newLanguage, enabled },
|
||||
})
|
||||
)
|
||||
},
|
||||
[projectId, enabled]
|
||||
)
|
||||
|
||||
// Don't render anything if LT is not configured on the server
|
||||
if (!ltAvailable) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="lt-enabled"
|
||||
label="LanguageTool grammar check"
|
||||
description="Enable grammar and style checking powered by LanguageTool"
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<DropdownSetting
|
||||
id="lt-language"
|
||||
label="LanguageTool language"
|
||||
options={[{ value: 'auto', label: 'Auto-detect' }]}
|
||||
optgroups={
|
||||
languages.length > 0
|
||||
? [
|
||||
{
|
||||
label: 'Languages',
|
||||
options: languages.map(l => ({
|
||||
value: l.longCode,
|
||||
label: l.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
onChange={handleLanguageChange}
|
||||
value={language}
|
||||
width="wide"
|
||||
loading={languagesLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* LanguageTool CodeMirror 6 extension.
|
||||
*
|
||||
* Exported as a named `extension` function matching the `sourceEditorExtensions`
|
||||
* module-import slot signature: (options: Record<string, any>) => Extension
|
||||
*
|
||||
* Features:
|
||||
* - Grammar and style linting via the /languagetool/check backend proxy
|
||||
* - Uses a CUSTOM decoration + tooltip system instead of @codemirror/lint.
|
||||
* The Overleaf compile-log linter globally combines markerFilter (only
|
||||
* severity=error) and tooltipFilter (returns []) into the lint facet,
|
||||
* which hides LT grammar warnings. By managing our own StateField,
|
||||
* Decoration layer, and hoverTooltip we bypass those filters entirely.
|
||||
* - Debounced: checks 2 s after the last document change
|
||||
* - LaTeX-aware: uses the Lezer syntax tree to identify plain-text regions;
|
||||
* math, commands, comments, citation keys, labels, and package arguments
|
||||
* are sent as `markup` in the LT annotation format
|
||||
* - Falls back to regex-based parsing when the syntax tree is unavailable
|
||||
* - Filters TYPOS category when the Overleaf Hunspell spellchecker is active
|
||||
* to avoid duplicate underlines; tracks Hunspell state dynamically
|
||||
* - Cancels stale HTTP requests with AbortController
|
||||
* - Dynamic language: responds to `lt:settings-changed` window events
|
||||
*
|
||||
* All logic is self-contained — no imports from Overleaf core internals.
|
||||
*/
|
||||
|
||||
import {
|
||||
StateEffect,
|
||||
StateField,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
hoverTooltip,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language'
|
||||
import type { SyntaxNodeRef } from '@lezer/common'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
latexToAnnotations,
|
||||
type LTAnnotation,
|
||||
} from './utils/latex-to-annotations'
|
||||
|
||||
// ─── Persistence helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function getStoredLanguage(projectId: string): string {
|
||||
return localStorage.getItem(`lt-language-${projectId}`) || 'auto'
|
||||
}
|
||||
|
||||
function getStoredEnabled(projectId: string): boolean {
|
||||
return localStorage.getItem(`lt-enabled-${projectId}`) !== 'false'
|
||||
}
|
||||
|
||||
// ─── LT types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LTMatch {
|
||||
offset: number
|
||||
length: number
|
||||
message: string
|
||||
rule?: { id?: string; category?: { id?: string } }
|
||||
replacements?: Array<{ value: string }>
|
||||
}
|
||||
|
||||
/** Our own diagnostic type — independent of @codemirror/lint's Diagnostic. */
|
||||
interface LTDiagnostic {
|
||||
from: number
|
||||
to: number
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
source: string
|
||||
replacements: string[]
|
||||
}
|
||||
|
||||
// ─── LaTeX node skip-list ─────────────────────────────────────────────────────
|
||||
// Mirrors the `noSpellCheckProp` definitions in the Overleaf LaTeX grammar
|
||||
// without importing the core module. These argument node types contain
|
||||
// non-prose content (citation keys, labels, package names, etc.) and must be
|
||||
// skipped so that LanguageTool does not report false positives on them.
|
||||
|
||||
/** Node types whose entire subtree is always non-prose. */
|
||||
const ALWAYS_SKIP = new Set([
|
||||
'BibKeyArgument',
|
||||
'BibliographyArgument',
|
||||
'BibliographyStyleArgument',
|
||||
'DocumentClassArgument',
|
||||
'LabelArgument',
|
||||
'PackageArgument',
|
||||
'RefArgument',
|
||||
])
|
||||
|
||||
/** Node types to skip only when they appear inside certain parent contexts. */
|
||||
const CONTEXTUAL_SKIP: Record<string, string[][]> = {
|
||||
OptionalArgument: [
|
||||
['DocumentClass'],
|
||||
['IncludeGraphics'],
|
||||
['LineBreak'],
|
||||
['UsePackage'],
|
||||
['FigureEnvironment', 'BeginEnv'],
|
||||
['ListEnvironment', 'BeginEnv'],
|
||||
],
|
||||
ShortTextArgument: [['Date'], ['SetLengthCommand']],
|
||||
TextArgument: [['TabularEnvironment', 'BeginEnv']],
|
||||
}
|
||||
|
||||
function shouldSkipNode(node: SyntaxNodeRef): boolean {
|
||||
if (ALWAYS_SKIP.has(node.type.name)) return true
|
||||
const contexts = CONTEXTUAL_SKIP[node.type.name]
|
||||
if (contexts) {
|
||||
return contexts.some(ctx => node.matchContext(ctx))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Syntax-tree walker ───────────────────────────────────────────────────────
|
||||
|
||||
interface TextSpan {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all "Normal" text spans from a single document line using the Lezer
|
||||
* syntax tree. Nodes matching the skip-list above are pruned so their
|
||||
* children (including any nested Normal nodes) are never visited.
|
||||
*/
|
||||
function getNormalSpansFromLine(
|
||||
state: EditorState,
|
||||
lineFrom: number,
|
||||
lineTo: number
|
||||
): TextSpan[] {
|
||||
const tree = syntaxTree(state)
|
||||
const spans: TextSpan[] = []
|
||||
|
||||
tree.iterate({
|
||||
from: lineFrom,
|
||||
to: lineTo,
|
||||
enter(node: SyntaxNodeRef) {
|
||||
if (shouldSkipNode(node)) return false
|
||||
if (node.type.name === 'Normal') {
|
||||
spans.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: state.doc.sliceString(node.from, node.to),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
// ─── Annotation builder (syntax tree) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a gap (everything between two Normal text spans) as whitespace → text
|
||||
* (so LanguageTool sees sentence boundaries) and non-whitespace → markup.
|
||||
*/
|
||||
function emitGap(annotations: LTAnnotation[], gap: string): void {
|
||||
const segments = gap.match(/\s+|\S+/g)
|
||||
if (!segments) return
|
||||
for (const segment of segments) {
|
||||
if (/^\s+$/.test(segment)) {
|
||||
annotations.push({ text: segment })
|
||||
} else {
|
||||
annotations.push({ markup: segment })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LT annotations from the Lezer syntax tree. Only "Normal" text nodes
|
||||
* are emitted as checkable `text`; everything else becomes `markup`.
|
||||
*
|
||||
* Invariant: annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*/
|
||||
function buildAnnotationsFromTree(view: EditorView): LTAnnotation[] {
|
||||
const { state } = view
|
||||
const doc = state.doc
|
||||
const annotations: LTAnnotation[] = []
|
||||
let lastEnd = 0
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i)
|
||||
const spans = getNormalSpansFromLine(state, line.from, line.to)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.from > lastEnd) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, span.from))
|
||||
}
|
||||
annotations.push({ text: span.text })
|
||||
lastEnd = span.to
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEnd < doc.length) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, doc.length))
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// ─── State management ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// We deliberately do NOT use @codemirror/lint's `linter()` because its
|
||||
// `lintConfig` facet is a global combine: the compile-log linter's
|
||||
// `markerFilter` (only severity=error) and `tooltipFilter` (returns [])
|
||||
// suppress our grammar-warning underlines and tooltips. Instead we manage
|
||||
// our own StateField → DecorationSet pipeline and a standalone hoverTooltip.
|
||||
|
||||
const setLTDiagnosticsEffect = StateEffect.define<readonly LTDiagnostic[]>()
|
||||
|
||||
const ltDiagnosticsField = StateField.define<{
|
||||
diagnostics: readonly LTDiagnostic[]
|
||||
decorations: DecorationSet
|
||||
}>({
|
||||
create() {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
},
|
||||
update({ diagnostics, decorations }, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setLTDiagnosticsEffect)) {
|
||||
const diags = effect.value
|
||||
if (diags.length === 0) {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
}
|
||||
const marks = diags
|
||||
.filter(d => d.from < d.to && d.to <= tr.state.doc.length)
|
||||
.map(d =>
|
||||
Decoration.mark({
|
||||
class: `cm-lt-underline cm-lt-underline-${d.severity}`,
|
||||
}).range(d.from, d.to)
|
||||
)
|
||||
return {
|
||||
diagnostics: diags,
|
||||
decorations: Decoration.set(marks, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tr.docChanged) {
|
||||
const mapped = diagnostics
|
||||
.map(d => ({
|
||||
...d,
|
||||
from: tr.changes.mapPos(d.from, 1),
|
||||
to: tr.changes.mapPos(d.to, -1),
|
||||
}))
|
||||
.filter(d => d.from < d.to)
|
||||
return {
|
||||
diagnostics: mapped,
|
||||
decorations: decorations.map(tr.changes),
|
||||
}
|
||||
}
|
||||
return { diagnostics, decorations }
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f, val => val.decorations),
|
||||
})
|
||||
|
||||
// ─── Hover tooltip ────────────────────────────────────────────────────────────
|
||||
|
||||
const ltTooltip = hoverTooltip(
|
||||
(view, pos) => {
|
||||
const { diagnostics } = view.state.field(ltDiagnosticsField)
|
||||
const matches = diagnostics.filter(d => d.from <= pos && pos <= d.to)
|
||||
if (!matches.length) return null
|
||||
|
||||
const primary = matches[0]
|
||||
return {
|
||||
pos: primary.from,
|
||||
end: primary.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'cm-lt-tooltip'
|
||||
|
||||
for (const match of matches) {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'cm-lt-tooltip-entry'
|
||||
|
||||
const msg = document.createElement('div')
|
||||
msg.className = 'cm-lt-tooltip-message'
|
||||
msg.textContent = match.message
|
||||
entry.appendChild(msg)
|
||||
|
||||
if (match.source) {
|
||||
const src = document.createElement('div')
|
||||
src.className = 'cm-lt-tooltip-source'
|
||||
src.textContent = match.source
|
||||
entry.appendChild(src)
|
||||
}
|
||||
|
||||
if (match.replacements.length) {
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'cm-lt-tooltip-actions'
|
||||
for (const rep of match.replacements) {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'cm-lt-tooltip-action'
|
||||
btn.textContent = rep
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
view.dispatch({
|
||||
changes: { from: match.from, to: match.to, insert: rep },
|
||||
})
|
||||
})
|
||||
actions.appendChild(btn)
|
||||
}
|
||||
entry.appendChild(actions)
|
||||
}
|
||||
|
||||
dom.appendChild(entry)
|
||||
}
|
||||
|
||||
return { dom }
|
||||
},
|
||||
}
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
)
|
||||
|
||||
// ─── Theme ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ltTheme = EditorView.baseTheme({
|
||||
'.cm-lt-underline': {
|
||||
backgroundImage: 'none !important',
|
||||
textDecoration: 'underline wavy',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
'.cm-lt-underline-warning': {
|
||||
textDecorationColor: '#E8A200',
|
||||
},
|
||||
'.cm-lt-underline-error': {
|
||||
textDecorationColor: '#d11',
|
||||
},
|
||||
'.cm-lt-tooltip': {
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
},
|
||||
'.cm-lt-tooltip-entry + .cm-lt-tooltip-entry': {
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid #eee',
|
||||
},
|
||||
'.cm-lt-tooltip-message': {
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-source': {
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-actions': {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-action': {
|
||||
padding: '2px 8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0e0e0',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Module export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const extension = (options: Record<string, any>): Extension => {
|
||||
const projectId = getMeta('ol-project_id') ?? ''
|
||||
|
||||
/**
|
||||
* ViewPlugin that manages the full lifecycle:
|
||||
* 1. On mount — checks if LT is available on the server and enables checking if LT
|
||||
* is available and the user has it enabled for this project.
|
||||
* 2. On document changes — debounces a new check (2 s after last edit).
|
||||
* 3. Listens to `lt:settings-changed` window events for language/enable
|
||||
* changes dispatched by the settings React component.
|
||||
* 4. Tracks Hunspell state dynamically via duck-typing on
|
||||
* setSpellCheckLanguageEffect payloads.
|
||||
* 5. Dispatches setLTDiagnosticsEffect to update the StateField, which
|
||||
* in turn drives the decoration layer and hover tooltip.
|
||||
*/
|
||||
const syncPlugin = ViewPlugin.define(view => {
|
||||
let ltServerAvailable = false
|
||||
let hunspellActive = !!options?.spelling?.spellCheckLanguage
|
||||
let currentLanguage = getStoredLanguage(projectId)
|
||||
let enabled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
|
||||
// ── Scheduling ──────────────────────────────────────────────────────
|
||||
|
||||
function scheduleCheck(delay = 2000) {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (!enabled) return
|
||||
timer = setTimeout(() => runCheck(), delay)
|
||||
}
|
||||
|
||||
// ── Core check logic ────────────────────────────────────────────────
|
||||
|
||||
async function runCheck() {
|
||||
if (!enabled) return
|
||||
|
||||
// Abort any in-flight request.
|
||||
if (controller) controller.abort()
|
||||
const ac = new AbortController()
|
||||
controller = ac
|
||||
|
||||
const source = view.state.doc.toString()
|
||||
if (source.trim().length < 20) {
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the Lezer syntax tree; fall back to regex-based parser.
|
||||
let annotations: LTAnnotation[]
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 500)
|
||||
if (tree) {
|
||||
annotations = buildAnnotationsFromTree(view)
|
||||
} else {
|
||||
annotations = latexToAnnotations(source)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/languagetool/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Csrf-Token': getMeta('ol-csrfToken'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language: currentLanguage,
|
||||
data: { annotation: annotations },
|
||||
}),
|
||||
signal: ac.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const result: { matches: LTMatch[] } = await response.json()
|
||||
|
||||
// Filter TYPOS when Hunspell is active (it handles typos better).
|
||||
let matches = result.matches
|
||||
if (hunspellActive) {
|
||||
matches = matches.filter(m => m.rule?.category?.id !== 'TYPOS')
|
||||
}
|
||||
|
||||
const docLen = view.state.doc.length
|
||||
const diagnostics: LTDiagnostic[] = matches
|
||||
.filter(m => m.offset >= 0 && m.offset + m.length <= docLen)
|
||||
.map(m => ({
|
||||
from: m.offset,
|
||||
to: m.offset + m.length,
|
||||
severity:
|
||||
m.rule?.category?.id === 'TYPOS'
|
||||
? ('error' as const)
|
||||
: ('warning' as const),
|
||||
message: m.message,
|
||||
source: `LanguageTool (${m.rule?.id ?? ''})`,
|
||||
replacements: (m.replacements ?? []).slice(0, 5).map(r => r.value),
|
||||
}))
|
||||
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of(diagnostics) })
|
||||
}
|
||||
} catch {
|
||||
// AbortError or network failure — silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initialisation ──────────────────────────────────────────────────
|
||||
ltServerAvailable = getMeta('ol-ExposedSettings').languageToolAvailable
|
||||
if (ltServerAvailable && getStoredEnabled(projectId)) {
|
||||
enabled = true
|
||||
scheduleCheck(500)
|
||||
}
|
||||
|
||||
// ── Settings-changed listener ───────────────────────────────────────
|
||||
|
||||
const handler = (event: Event) => {
|
||||
if (!ltServerAvailable) return
|
||||
|
||||
const detail = (
|
||||
event as CustomEvent<{ language?: string; enabled?: boolean }>
|
||||
).detail
|
||||
|
||||
if (detail.language !== undefined) {
|
||||
currentLanguage = detail.language
|
||||
}
|
||||
const isEnabled =
|
||||
(detail.enabled ?? getStoredEnabled(projectId)) && ltServerAvailable
|
||||
|
||||
if (isEnabled !== enabled || detail.language !== undefined) {
|
||||
enabled = isEnabled
|
||||
if (enabled) {
|
||||
scheduleCheck(100)
|
||||
} else {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (controller) controller.abort()
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('lt:settings-changed', handler)
|
||||
|
||||
// ── ViewPlugin callbacks ────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged && enabled) {
|
||||
// Abort stale in-flight request and schedule a fresh check.
|
||||
if (controller) controller.abort()
|
||||
scheduleCheck()
|
||||
}
|
||||
|
||||
// Track Hunspell state changes (duck-typed from effects).
|
||||
for (const tr of update.transactions) {
|
||||
for (const effect of tr.effects) {
|
||||
const val = effect.value as Record<string, unknown> | null
|
||||
if (
|
||||
val !== null &&
|
||||
typeof val === 'object' &&
|
||||
'spellCheckLanguage' in val
|
||||
) {
|
||||
const newActive = !!(val.spellCheckLanguage as string)
|
||||
if (newActive !== hunspellActive) {
|
||||
hunspellActive = newActive
|
||||
if (enabled) scheduleCheck(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('lt:settings-changed', handler)
|
||||
if (timer) clearTimeout(timer)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controller = null
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [ltDiagnosticsField, ltTooltip, ltTheme, syncPlugin]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* LanguageTool settings section hook — registers the LanguageTool setting in
|
||||
* the Settings modal through the `settingsModalSpellcheckSections` slot.
|
||||
*/
|
||||
|
||||
import type { SettingsSection } from '@/features/settings/context/types'
|
||||
import LanguageToolLanguageSetting from './components/languagetool-language-setting'
|
||||
|
||||
export default function languageToolSection(): SettingsSection {
|
||||
return {
|
||||
key: 'languagetool',
|
||||
title: 'Language Tool',
|
||||
settings: [
|
||||
{
|
||||
key: 'languagetool-settings',
|
||||
component: <LanguageToolLanguageSetting />
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Converts LaTeX source to a LanguageTool annotation array.
|
||||
*
|
||||
* The invariant is:
|
||||
* annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*
|
||||
* This means that the character offsets returned by LanguageTool (which count
|
||||
* both text and markup characters) map directly to positions in the original
|
||||
* CodeMirror document — no offset remapping is needed.
|
||||
*
|
||||
* LaTeX commands, math environments, and comments are emitted as `markup`.
|
||||
* Regular text content is emitted as `text` and will be grammar-checked.
|
||||
*/
|
||||
|
||||
export interface LTAnnotation {
|
||||
text?: string
|
||||
markup?: string
|
||||
}
|
||||
|
||||
/** Math environments whose entire content should be treated as markup. */
|
||||
const MATH_ENVS = new Set([
|
||||
'equation',
|
||||
'equation*',
|
||||
'align',
|
||||
'align*',
|
||||
'alignat',
|
||||
'alignat*',
|
||||
'gather',
|
||||
'gather*',
|
||||
'multline',
|
||||
'multline*',
|
||||
'displaymath',
|
||||
'eqnarray',
|
||||
'eqnarray*',
|
||||
'flalign',
|
||||
'flalign*',
|
||||
'subequations',
|
||||
'math',
|
||||
'dmath',
|
||||
'dgroup',
|
||||
'dgroup*',
|
||||
])
|
||||
|
||||
export function latexToAnnotations(source: string): LTAnnotation[] {
|
||||
const annotations: LTAnnotation[] = []
|
||||
let textStart = 0 // start of the current un-emitted text segment
|
||||
let i = 0
|
||||
|
||||
/** Emit accumulated plain text up to `end`. */
|
||||
function flushText(end: number): void {
|
||||
if (end > textStart) {
|
||||
annotations.push({ text: source.slice(textStart, end) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `source[start..end)` as markup, flushing any buffered text first.
|
||||
* Also advances `textStart` to `end`.
|
||||
*/
|
||||
function addMarkup(start: number, end: number): void {
|
||||
flushText(start)
|
||||
if (end > start) {
|
||||
annotations.push({ markup: source.slice(start, end) })
|
||||
}
|
||||
textStart = end
|
||||
}
|
||||
|
||||
while (i < source.length) {
|
||||
const ch = source[i]
|
||||
|
||||
// ── LaTeX comments: % … (end of line) ──────────────────────────────────
|
||||
if (ch === '%') {
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '\n') end++
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Display math: $$…$$ ────────────────────────────────────────────────
|
||||
if (ch === '$' && i + 1 < source.length && source[i + 1] === '$') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '$' && source[j + 1] === '$') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Inline math: $…$ ───────────────────────────────────────────────────
|
||||
if (ch === '$') {
|
||||
const start = i
|
||||
let j = i + 1
|
||||
while (j < source.length && source[j] !== '$') {
|
||||
if (source[j] === '\\') j++ // skip one escaped character
|
||||
j++
|
||||
}
|
||||
const end = j < source.length ? j + 1 : j
|
||||
addMarkup(start, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── LaTeX commands ─────────────────────────────────────────────────────
|
||||
if (ch === '\\' && i + 1 < source.length) {
|
||||
const next = source[i + 1]
|
||||
|
||||
// \[…\] (display math)
|
||||
if (next === '[') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ']') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \(…\) (inline math)
|
||||
if (next === '(') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ')') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \begin{envname}
|
||||
if (source.startsWith('begin{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 7)
|
||||
if (closeBrace !== -1) {
|
||||
const envName = source.slice(i + 7, closeBrace)
|
||||
|
||||
if (MATH_ENVS.has(envName)) {
|
||||
// Entire math environment is markup
|
||||
const endTag = `\\end{${envName}}`
|
||||
const endIdx = source.indexOf(endTag, closeBrace + 1)
|
||||
const envEnd =
|
||||
endIdx !== -1 ? endIdx + endTag.length : source.length
|
||||
addMarkup(i, envEnd)
|
||||
i = envEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-math environment: only mark up the \begin{...} tag itself
|
||||
addMarkup(i, closeBrace + 1)
|
||||
i = closeBrace + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// \end{…}
|
||||
if (source.startsWith('end{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 5)
|
||||
const end = closeBrace !== -1 ? closeBrace + 1 : i + 5
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// General alpha command: \commandname (optional trailing whitespace)
|
||||
if (/[a-zA-Z]/.test(next)) {
|
||||
let j = i + 1
|
||||
while (j < source.length && /[a-zA-Z]/.test(source[j])) j++
|
||||
// Absorb trailing horizontal whitespace into the markup token
|
||||
while (j < source.length && (source[j] === ' ' || source[j] === '\t'))
|
||||
j++
|
||||
addMarkup(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Single non-letter command: \\, \%, \$, \{, \}, \&, \_, \#, etc.
|
||||
addMarkup(i, i + 2)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Curly braces (LaTeX grouping — always markup) ──────────────────────
|
||||
if (ch === '{' || ch === '}') {
|
||||
addMarkup(i, i + 1)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Regular text character ─────────────────────────────────────────────
|
||||
i++
|
||||
}
|
||||
|
||||
flushText(source.length)
|
||||
return annotations
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
let LanguageToolModule = {}
|
||||
if (process.env.LANGUAGE_TOOL_ENABLED?.toLowerCase() === 'true') {
|
||||
logger.debug({}, 'Enable LanguageTool module')
|
||||
|
||||
Settings.languageToolURL = process.env.LANGUAGE_TOOL_URL ||
|
||||
`http://${process.env.LANGUAGE_TOOL_HOST || 'languagetool'}:${process.env.LANGUAGE_TOOL_PORT || '8010'}`
|
||||
|
||||
const { default: LanguageToolRouter } = await import('./app/src/LanguageToolRouter.mjs')
|
||||
|
||||
LanguageToolModule = {
|
||||
router: LanguageToolRouter,
|
||||
}
|
||||
}
|
||||
|
||||
export default LanguageToolModule
|
||||
Reference in New Issue
Block a user