Language Tool- #179 : Test file under tools/languagetools_server_compose/lt-test.tex ; picky problem solved; theme problem solved

This commit is contained in:
David Rotermund
2026-10-26 11:35:41 +00:00
parent 3cc6928e50
commit a99a12887c
21 changed files with 1538 additions and 10 deletions
+3
View File
@@ -8,3 +8,6 @@ server-ce/test
services/web/scripts/plan-prices
services/web/scripts/translations
services/web/scripts/ukamf
# local LanguageTool ngrams data (deploy-only, not part of the image)
tools/languagetools_server_compose
@@ -604,6 +604,58 @@ const EditorController = {
)
},
// ─────────────────────────────────────────────────────────────
// LanguageTool settings (project-wide), mirrored to all clients
// in the room (see use-project-wide-settings-socket-listener).
// ─────────────────────────────────────────────────────────────
setLanguageToolEnabled(projectId, enabled, callback) {
ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function (
err
) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolEnabledUpdated',
enabled
)
callback()
})
},
setLanguageToolPicky(projectId, picky, callback) {
ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolPickyUpdated',
picky
)
callback()
})
},
setLanguageToolLanguage(projectId, language, callback) {
ProjectOptionsHandler.setLanguageToolLanguage(
projectId,
language,
function (err) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolLanguageUpdated',
language
)
callback()
}
)
},
setPublicAccessLevel(projectId, newAccessLevel, callback) {
async.series(
[
@@ -747,6 +799,13 @@ EditorController.promises = {
setImageName: promisify(EditorController.setImageName),
setPng2pdf: promisify(EditorController.setPng2pdf),
setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage),
setLanguageToolEnabled: promisify(
EditorController.setLanguageToolEnabled
),
setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky),
setLanguageToolLanguage: promisify(
EditorController.setLanguageToolLanguage
),
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
setRootDoc: promisify(EditorController.setRootDoc),
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
@@ -109,6 +109,9 @@ const updateProjectSettingsSchema = z.object({
rootDocId: zz.objectId().optional(),
spellCheckLanguage: z.string().optional(),
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
languageToolEnabled: z.boolean().optional(),
languageToolPicky: z.boolean().optional(),
languageToolLanguage: z.string().optional(),
}),
})
@@ -127,6 +130,9 @@ const updateProjectSettingsFallbackSchema = z.object({
rootDocId: zz.objectId().optional(),
spellCheckLanguage: z.string().optional(),
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
languageToolEnabled: z.boolean().optional(),
languageToolPicky: z.boolean().optional(),
languageToolLanguage: z.string().optional(),
}),
})
@@ -228,6 +234,27 @@ const _ProjectController = {
)
}
if (body.languageToolEnabled != null) {
await EditorController.promises.setLanguageToolEnabled(
projectId,
body.languageToolEnabled
)
}
if (body.languageToolPicky != null) {
await EditorController.promises.setLanguageToolPicky(
projectId,
body.languageToolPicky
)
}
if (body.languageToolLanguage != null) {
await EditorController.promises.setLanguageToolLanguage(
projectId,
body.languageToolLanguage
)
}
if (body.rootDocId != null) {
await EditorController.promises.setRootDoc(projectId, body.rootDocId)
}
@@ -1108,8 +1135,10 @@ const _ProjectController = {
canUseClsiCache:
Features.hasFeature('saas') &&
ownerFeatures?.compileGroup === 'priority',
// CE: saas/priority gate removed so the "Optimise images" toggle is available
canUsePng2Pdf: true,
canUsePng2Pdf:
Features.hasFeature('saas') &&
ownerFeatures?.compileGroup === 'priority' &&
splitTestAssignments['png2pdf']?.variant === 'enabled',
user: {
id: userId,
email: user.email,
@@ -127,6 +127,29 @@ const ProjectOptionsHandler = {
const update = { referenceFormat: newReferenceFormat }
return Project.updateOne(conditions, update, {})
},
// ─────────────────────────────────────────────────────────────────
// LanguageTool settings (project-wide; shared across collaborators).
// Unset values mean the client defaults: enabled=true, picky=true,
// language='auto' — i.e. picky level is the default selection.
// ─────────────────────────────────────────────────────────────────
async setLanguageToolEnabled(projectId, enabled) {
const conditions = { _id: projectId }
const update = { languageToolEnabled: Boolean(enabled) }
return Project.updateOne(conditions, update, {})
},
async setLanguageToolPicky(projectId, picky) {
const conditions = { _id: projectId }
const update = { languageToolPicky: Boolean(picky) }
return Project.updateOne(conditions, update, {})
},
async setLanguageToolLanguage(projectId, language) {
const conditions = { _id: projectId }
const update = { languageToolLanguage: language || 'auto' }
return Project.updateOne(conditions, update, {})
},
}
export default {
@@ -144,5 +167,14 @@ export default {
ProjectOptionsHandler.setHistoryRangesSupport
),
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
setLanguageToolEnabled: callbackify(
ProjectOptionsHandler.setLanguageToolEnabled
),
setLanguageToolPicky: callbackify(
ProjectOptionsHandler.setLanguageToolPicky
),
setLanguageToolLanguage: callbackify(
ProjectOptionsHandler.setLanguageToolLanguage
),
promises: ProjectOptionsHandler,
}
@@ -425,6 +425,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,
+13 -8
View File
@@ -1034,7 +1034,12 @@ module.exports = {
),
],
contactUsModal: [],
sourceEditorExtensions: [],
sourceEditorExtensions: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-extension'
),
],
sourceEditorVisualExtensions: [],
sourceEditorComponents: [],
pdfLogEntryHeaderActionComponents: [],
@@ -1175,7 +1180,12 @@ module.exports = {
],
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [],
settingsModalSpellcheckSections: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-section'
),
],
editorFloatingMenuActions: [],
errorLogsComponents: [],
referenceIndices: [
@@ -1198,6 +1208,7 @@ module.exports = {
'symbol-palette',
'reference-picker',
'track-changes',
'languagetool',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
@@ -1262,12 +1273,6 @@ module.exports = {
'export-html': 'enabled',
'export-markdown': 'enabled',
} : {}),
...(process.env.ENABLE_PNG2PDF_CONVERSIONS === 'true' ? {
'png2pdf': 'enabled',
// compile-from-history is required for CLSI to record the "slow png" list
// (clsi CompileManager.js saveSlowPngList is gated on isCompileFromHistory)
'compile-from-history': 'enabled',
} : {}),
...(process.env.OVERLEAF_HISTORY_RESTORE?.toLowerCase() === 'true' ? {
'history-ranges-support': 'enabled',
'revert-file': 'enabled',
@@ -13,6 +13,15 @@ type ProjectSettingsSetterContextValue = {
setSpellCheckLanguage: (
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
) => void
setLanguageToolEnabled: (
languageToolEnabled: ProjectSettings['languageToolEnabled']
) => void
setLanguageToolPicky: (
languageToolPicky: ProjectSettings['languageToolPicky']
) => void
setLanguageToolLanguage: (
languageToolLanguage: ProjectSettings['languageToolLanguage']
) => void
setReferenceFormat: (
referenceFormat: ProjectSettings['referenceFormat']
) => Promise<void>
@@ -72,6 +81,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setSpellCheckLanguage,
referenceFormat,
setReferenceFormat,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
} = useProjectWideSettings()
const {
@@ -135,6 +150,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
@@ -193,6 +214,12 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
@@ -53,6 +53,33 @@ export default function useProjectWideSettingsSocketListener() {
[project, updateProject]
)
const setLanguageToolEnabled = useCallback(
(languageToolEnabled: ProjectSettings['languageToolEnabled']) => {
if (project) {
updateProject({ languageToolEnabled })
}
},
[project, updateProject]
)
const setLanguageToolPicky = useCallback(
(languageToolPicky: ProjectSettings['languageToolPicky']) => {
if (project) {
updateProject({ languageToolPicky })
}
},
[project, updateProject]
)
const setLanguageToolLanguage = useCallback(
(languageToolLanguage: ProjectSettings['languageToolLanguage']) => {
if (project) {
updateProject({ languageToolLanguage })
}
},
[project, updateProject]
)
useEffect(() => {
// data is not available on initial mounting
const dataAvailable = !!project
@@ -63,6 +90,9 @@ export default function useProjectWideSettingsSocketListener() {
socket.on('png2pdfUpdated', setPng2pdf)
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
socket.on('referenceFormatUpdated', setReferenceFormat)
socket.on('languageToolEnabledUpdated', setLanguageToolEnabled)
socket.on('languageToolPickyUpdated', setLanguageToolPicky)
socket.on('languageToolLanguageUpdated', setLanguageToolLanguage)
return () => {
socket.removeListener('compilerUpdated', setCompiler)
socket.removeListener('imageNameUpdated', setImageName)
@@ -72,6 +102,15 @@ export default function useProjectWideSettingsSocketListener() {
setSpellCheckLanguage
)
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
socket.removeListener(
'languageToolEnabledUpdated',
setLanguageToolEnabled
)
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
socket.removeListener(
'languageToolLanguageUpdated',
setLanguageToolLanguage
)
}
}
}, [
@@ -82,5 +121,8 @@ export default function useProjectWideSettingsSocketListener() {
setReferenceFormat,
setPng2pdf,
setSpellCheckLanguage,
setLanguageToolEnabled,
setLanguageToolPicky,
setLanguageToolLanguage,
])
}
@@ -3,6 +3,7 @@ import type { ProjectSettings } from '../utils/api'
import useRootDocId from './use-root-doc-id'
import useSaveProjectSettings from './use-save-project-settings'
import useSetSpellCheckLanguage from './use-set-spell-check-language'
import useSetLanguageToolSetting from './use-set-language-tool'
import { debugConsole } from '@/utils/debugging'
import { useProjectContext } from '@/shared/context/project-context'
import getMeta from '@/utils/meta'
@@ -49,6 +50,9 @@ export default function useProjectWideSettings() {
const { setRootDocId, rootDocId } = useRootDocId()
const setSpellCheckLanguage = useSetSpellCheckLanguage()
const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled')
const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky')
const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage')
return {
compiler: project?.compiler,
@@ -63,5 +67,11 @@ export default function useProjectWideSettings() {
setRootDocId,
spellCheckLanguage: project?.spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled: project?.languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky: project?.languageToolPicky,
setLanguageToolPicky,
languageToolLanguage: project?.languageToolLanguage,
setLanguageToolLanguage,
}
}
@@ -0,0 +1,25 @@
import { useCallback } from 'react'
import { type ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
type LanguageToolSettingKey =
| 'languageToolEnabled'
| 'languageToolPicky'
| 'languageToolLanguage'
/**
* Setter for the LanguageTool project settings (enabled / picky / language),
* following the `useSet*` hook pattern of this directory.
*/
export default function useSetLanguageToolSetting(
key: LanguageToolSettingKey
) {
const saveProjectSettings = useSaveProjectSettings()
return useCallback(
(newValue: ProjectSettings[LanguageToolSettingKey]) => {
saveProjectSettings(key, newValue).catch(() => {})
},
[key, saveProjectSettings]
)
}
@@ -15,6 +15,11 @@ export interface ProjectSettings {
name: string
mainBibliographyDocId?: string
referenceFormat?: 'bibtex' | 'biblatex'
// LanguageTool grammar check (project-wide; defaults when unset:
// enabled=true, picky=true → level=picky, language='auto')
languageToolEnabled?: boolean
languageToolPicky?: boolean
languageToolLanguage?: string
}
type SaveUserSettings = Partial<
@@ -55,6 +55,7 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useProjectContext } from '@/shared/context/project-context'
import getMeta from '@/utils/meta'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { SearchQuery } from '@codemirror/search'
@@ -109,6 +110,33 @@ function useCodeMirrorScope(view: EditorView) {
const hunspellManager = useHunspell(spellCheckLanguage)
// ── LanguageTool grammar check (project-wide settings) ─────────────────────
// Stored on the project (shared by all collaborators). Defaults when unset:
// enabled + picky ON → level=picky. Values are handed to the CodeMirror
// extension through `createExtensions` below, and every change is
// announced with a window event the extension listens for.
const languageTool = {
available:
getMeta('ol-ExposedSettings')?.languageToolAvailable === true,
// off for read-only views (like spell-checking)
enabled:
(permissions.write || permissions.trackedWrite) &&
project?.languageToolEnabled !== false,
picky: project?.languageToolPicky !== false,
language: project?.languageToolLanguage || 'auto',
}
useEffect(() => {
window.dispatchEvent(
new CustomEvent('lt:settings-changed', { detail: languageTool })
)
}, [
languageTool.available,
languageTool.enabled,
languageTool.picky,
languageTool.language,
])
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
const { referenceKeys, searchLocalReferences } = useReferencesContext()
@@ -362,6 +390,7 @@ function useCodeMirrorScope(view: EditorView) {
settings: settingsRef.current,
phrases: phrasesRef.current,
spelling: spellingRef.current,
languageTool,
showVisual: showVisualRef.current,
previewByPath: previewByPathRef.current,
currentDocFolder: dirnameRef.current(currentDocument.doc_id),
@@ -0,0 +1,90 @@
# LanguageTool module
Grammar and style checking in the Overleaf source editor, powered by an
external [LanguageTool](https://languagetool.org) server (API v2).
Based on the upstream PR:
<https://github.com/yu-i-i/overleaf-cep/pull/179> (commit 8337e00), adapted
for ext-ce-v6.3.1 with:
- **Picky level as the default** and an on/off "Picky mode" toggle in the
project settings (the deployed LT fork, `erikvl87/languagetool`, supports
`default` + `picky`; other levels return HTTP 400).
- **Project-wide settings** (stored on the project document, shared by all
collaborators, synced over the editor socket):
- `languageToolEnabled` — grammar check on/off (default: on)
- `languageToolPicky` — picky level on/off (default: **on** → `level=picky`)
- `languageToolLanguage` — LT language code, e.g. `en-US`, `de` (default: `auto`)
- **Dark/light theme support**: tooltip + underlines use the Overleaf themed
CSS variables (`--bg-primary-themed`, `--content-primary-themed`, ...)
with light-mode fallbacks (fixes the dark-theme tooltip bug from the PR).
## Server (Overleaf) environment
| Variable | Default | Notes |
| ----------------------- | ----------------- | --------------------------------------- |
| `LANGUAGE_TOOL_ENABLED` | _unset_ | must be exactly `true` to activate |
| `LANGUAGE_TOOL_HOST` | `languagetool` | host of the LT service |
| `LANGUAGE_TOOL_PORT` | `8010` | LT API port |
| `LANGUAGE_TOOL_URL` | derived | overrides host+port when set |
The overleaf container needs network access to the LT service. In this
deployment the overleaf service joins the external
`languagetools_server_compose_default` network (see
`/data_1/docker/compose/overleaf/compose.yaml`) and talks to
`http://languagetool:8010`.
## Endpoints (authenticated)
- `GET /languagetool/languages` — LT language list (used by the settings UI)
- `POST /languagetool/check` — JSON body `{ language, level?, text? | data? }`;
proxied to LT `/v2/check` with a 100 KB cap and LaTeX-noise `disabledRules`.
**Exempt from CSRF** (`webRouter.csrf.disableDefaultCsrfProtection` in the
router): it is a stateless, login-gated query — and long-lived editor tabs
outlive session rotations, where the page's meta token would stale out and
403 every check. The client still sends `X-CSRF-Token`; it passes when the
session is fresh.
## Frontend
- `frontend/js/languagetool-extension.ts` — CodeMirror 6 extension
(wired via the `sourceEditorExtensions` slot in
`services/web/config/settings.defaults.js`). Wavy underlines + hover
tooltip with replacement buttons; LaTeX-aware annotation via the Lezer
syntax tree (regex fallback); debounced 2 s; aborts stale requests;
filters TYPOS while the Hunspell spellchecker is active.
- `frontend/js/languagetool-section.tsx` +
`frontend/js/components/languagetool-settings.tsx` — Settings modal,
Spelling and Language → **LanguageTool** (wired via the
`settingsModalSpellcheckSections` slot).
Settings reach the editor through `options.languageTool`
(`use-codemirror-scope.ts`) and the `lt:settings-changed` window event.
## Files
```
index.mjs module entry (env-gated router)
app/src/LanguageToolRouter.mjs
app/src/LanguageToolController.mjs
frontend/js/languagetool-extension.ts
frontend/js/languagetool-section.tsx
frontend/js/components/languagetool-settings.tsx
frontend/js/utils/latex-to-annotations.ts
```
Core Overleaf changes (see git diff):
- `services/web/config/settings.defaults.js`
(`sourceEditorExtensions`, `settingsModalSpellcheckSections`,
`moduleImportSequence`)
- `services/web/app/src/infrastructure/ExpressLocals.mjs`
(exposed setting `languageToolAvailable`)
- `services/web/types/exposed-settings.ts`
- `services/web/app/src/Features/Project/ProjectController.mjs`
(project-settings schema + update)
- `services/web/app/src/Features/Project/ProjectOptionsHandler.mjs` (persist)
- `services/web/app/src/Features/Editor/EditorController.mjs` (socket emit)
- `services/web/frontend/js/features/ide-settings/...` (settings UI context,
hooks, socket listener, `api.ts`)
- `services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts`
@@ -0,0 +1,138 @@
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
// Levels understood by the LanguageTool fork we deploy
// (erikvl87/languagetool supports: default and picky).
const ALLOWED_LEVELS = new Set([
'default',
'normal',
'careful',
'meticulous',
'picky',
])
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, level?, text?, data? }
*
* `level` is optional; when omitted the LanguageTool default level is used.
* The client sends `picky` when the project's "picky mode" is enabled —
* picky is the recommended default in this deployment.
*/
async check(req, res, next) {
const { language = 'auto', level, text, data } = req.body
if (!text && !data) {
return res.status(400).json({ error: 'text or data is required' })
}
if (level && !ALLOWED_LEVELS.has(level)) {
return res.status(400).json({ error: `unknown level: ${level}` })
}
// 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 (level) {
params.set('level', level)
}
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, level, body: body.slice(0, 500) },
'LanguageTool check failed'
)
return res
.status(502)
.json({ error: 'LanguageTool check failed', serverStatus: response.status })
}
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,31 @@
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')
// POST /languagetool/check is a stateless grammar query (login required,
// no server state changes). Exempt it from CSRF: long-lived editor tabs
// hold the page's meta token, and after an in-flight (re-)login the
// browser's session has rotated, so the stale token would 403 every
// check even though the request is fully authenticated.
if (webRouter.csrf && webRouter.csrf.disableDefaultCsrfProtection) {
webRouter.csrf.disableDefaultCsrfProtection('/languagetool/check', 'POST')
}
// Authenticated endpoints
webRouter.get(
'/languagetool/languages',
AuthenticationController.requireLogin(),
LanguageToolController.getLanguages
)
webRouter.post(
'/languagetool/check',
AuthenticationController.requireLogin(),
LanguageToolController.check
)
}
}
@@ -0,0 +1,152 @@
/**
* LanguageTool settings component — rendered inside the Settings modal
* (Spelling and Language → LanguageTool section) via the
* `settingsModalSpellcheckSections` slot.
*
* Persistence: all three settings are PROJECT settings — stored on the
* project document so every collaborator sees the same values:
* - languageToolEnabled (default: true)
* - languageToolPicky (default: true → level=picky is the default)
* - languageToolLanguage (default: 'auto')
*
* Changes are saved server-side through the project settings context and
* synced to other clients over the project socket. The CodeMirror
* extension is informed via the `lt:settings-changed` window event so this
* component does not need to be inside the CodeMirrorViewContext.
*/
import { useState, useEffect, useCallback } from 'react'
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
import { getJSON } from '@/infrastructure/fetch-json'
import DropdownSetting from '@/features/ide-settings/components/dropdown-setting'
import ToggleSetting from '@/features/ide-settings/components/toggle-setting'
import getMeta from '@/utils/meta'
interface LTLanguage {
name: string
code: string
longCode: string
}
export default function LanguageToolSettings() {
const {
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
} = useProjectSettingsContext()
// Defaults: enabled + picky (picky level is the recommended default).
const enabled = languageToolEnabled !== false
const picky = languageToolPicky !== false
const language = languageToolLanguage || 'auto'
const gtAvailable =
getMeta('ol-ExposedSettings')?.languageToolAvailable === true
const [languages, setLanguages] = useState<LTLanguage[]>([])
const [languagesLoading, setLanguagesLoading] = useState(false)
// ── Load language list when LT is available and grammar check is on ──
useEffect(() => {
if (!gtAvailable || !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))
}, [gtAvailable, enabled, languages.length])
// ── Notify the CodeMirror extension of the effective settings ─────────
const notify = useCallback(
(detail: { enabled: boolean; picky: boolean; language: string }) => {
window.dispatchEvent(
new CustomEvent('lt:settings-changed', {
detail: { ...detail, available: gtAvailable },
})
)
},
[gtAvailable]
)
const handleToggleEnabled = useCallback(
(newEnabled: boolean) => {
setLanguageToolEnabled(newEnabled)
notify({ enabled: newEnabled, picky, language })
},
[setLanguageToolEnabled, picky, language, notify]
)
const handleTogglePicky = useCallback(
(newPicky: boolean) => {
setLanguageToolPicky(newPicky)
notify({ enabled, picky: newPicky, language })
},
[setLanguageToolPicky, enabled, language, notify]
)
const handleLanguageChange = useCallback(
(newLanguage: string) => {
setLanguageToolLanguage(newLanguage || 'auto')
notify({ enabled, picky, language: newLanguage || 'auto' })
},
[setLanguageToolLanguage, enabled, picky, notify]
)
// Don't render anything if LT is not configured on the server
if (!gtAvailable) return null
return (
<>
<ToggleSetting
id="languagetool-enabled"
label="LanguageTool grammar check"
description="Grammar and style checking powered by LanguageTool"
checked={enabled}
onChange={handleToggleEnabled}
/>
{enabled && (
<>
<ToggleSetting
id="languagetool-picky"
label="Picky mode"
description="Picky level catches more (subtle) grammar and style issues; off uses LanguageTool's default level"
checked={picky}
onChange={handleTogglePicky}
/>
<DropdownSetting
id="languagetool-language"
label="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,597 @@
/**
* 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
* - Project-wide settings: `enabled` / `picky` / `language` arrive through
* `options.languageTool` (from the project settings, default: enabled,
* picky ON → level=picky) and live `lt:settings-changed` window events
* dispatched by the settings UI / the source-editor scope.
* - Theme aware: tooltip and underlines use the Overleaf themed CSS
* variables (--*-themed) with light-mode fallbacks, so they render
* correctly in both the dark and the light theme.
*
* 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'
// ─── 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[]
}
/** Effective per-project settings (see languagetool-settings.tsx). */
interface LTSettings {
available: boolean
enabled: boolean
picky: boolean
language: string
}
const DEFAULT_SETTINGS: LTSettings = {
available: false,
enabled: true,
picky: true,
language: 'auto',
}
// ─── 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,
// NB: this closure uses the guard's `view` (EditorView) — do NOT
// shadow it, hoverTooltip passes the editor *state* to create().
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 ────────────────────────────────────────────────────────────────────
// Overleaf 6.x themes via body[data-theme] → semantic CSS variables
// (--bg-primary-themed & friends, see
// stylesheets/abstracts/themes-common-variables.scss). The light fallbacks
// in var(...) keep the extension usable where the variables are undefined.
const ltTheme = EditorView.baseTheme({
'.cm-lt-underline': {
backgroundImage: 'none !important',
textDecoration: 'underline wavy',
textUnderlineOffset: '3px',
},
'.cm-lt-underline-warning': {
textDecorationColor: 'var(--content-warning-themed, #E8A200)',
},
'.cm-lt-underline-error': {
textDecorationColor: 'var(--content-danger-themed, #ff5252)',
},
'.cm-lt-tooltip': {
color: 'var(--content-primary-themed, #333)',
backgroundColor: 'var(--bg-primary-themed, #fff)',
border: '1px solid var(--border-divider-themed, #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 var(--border-divider-themed, #eee)',
},
'.cm-lt-tooltip-message': {
marginBottom: '4px',
},
'.cm-lt-tooltip-source': {
fontSize: '11px',
color: 'var(--content-secondary-themed, #888)',
marginBottom: '4px',
},
'.cm-lt-tooltip-actions': {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
},
'.cm-lt-tooltip-action': {
padding: '2px 8px',
border: '1px solid var(--border-divider-themed, #ccc)',
borderRadius: '3px',
backgroundColor: 'var(--bg-secondary-themed, #f5f5f5)',
color: 'var(--content-primary-themed, #333)',
cursor: 'pointer',
fontSize: '12px',
'&:hover': {
backgroundColor: 'var(--bg-tertiary-themed, #e0e0e0)',
},
},
})
// ─── Module export ────────────────────────────────────────────────────────────
export const extension = (options: Record<string, any>): Extension => {
/**
* ViewPlugin that manages the full lifecycle:
* 1. On mount — starts disabled until the first `lt:settings-changed`
* event (dispatched by the source-editor scope with the project
* settings) or the options snapshot confirms LT is available and
* 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 carrying the
* per-project `{ enabled, picky, language }` settings.
* 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 => {
const initial: LTSettings = {
...DEFAULT_SETTINGS,
...(options?.languageTool ?? {}),
}
let ltServerAvailable = !!initial.available
let hunspellActive = !!options?.spelling?.spellCheckLanguage
let currentLanguage = initial.language || 'auto'
let picky = initial.picky !== false
let enabled = !!initial.available && (initial.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)
}
function clearAll() {
if (timer) clearTimeout(timer)
timer = null
if (controller) controller.abort()
controller = null
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
}
// ── 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,
// picky mode ON (the default) → level=picky; OFF → level=default
level: picky ? 'picky' : 'default',
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 ?? ''})${
picky ? ' (picky)' : ''
}`,
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.
}
}
// ── Settings-changed listener ───────────────────────────────────────
//
// Detail shape (from languagetool-settings.tsx / use-codemirror-scope):
// { available?: boolean, enabled?: boolean, picky?: boolean, language?: string }
const applySettings = (detail: Partial<LTSettings>) => {
if (detail.available != null) ltServerAvailable = !!detail.available
const newLanguage = detail.language || currentLanguage || 'auto'
const newPicky = detail.picky !== false
const newEnabled = ltServerAvailable && (detail.enabled !== false)
const changed =
newEnabled !== enabled || newPicky !== picky || newLanguage !== currentLanguage
enabled = newEnabled
picky = newPicky
currentLanguage = newLanguage
if (!enabled) {
clearAll()
} else if (changed) {
scheduleCheck(100)
}
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<Partial<LTSettings>>).detail
if (!detail) return
applySettings(detail)
}
window.addEventListener('lt:settings-changed', handler)
// ── Initialisation ──────────────────────────────────────────────────
if (enabled) {
scheduleCheck(500)
}
// ── 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,24 @@
/**
* LanguageTool settings section hook — registers the LanguageTool settings in
* the Settings modal (Spelling and Language tab) through the
* `settingsModalSpellcheckSections` slot.
*
* Signature required by the slot consumer: a default-exported function that
* returns a SettingsSection (or null).
*/
import type { SettingsSection } from '@/features/ide-settings/context/types'
import LanguageToolSettings from './components/languagetool-settings'
export default function languageToolSection(): SettingsSection {
return {
key: 'languagetool',
title: 'LanguageTool',
settings: [
{
key: 'languagetool-settings',
component: <LanguageToolSettings />,
},
],
}
}
@@ -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
+1
View File
@@ -56,5 +56,6 @@ export type ExposedSettings = {
linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean
zoteroEnabled: boolean
languageToolAvailable: boolean
enablePandocConversions: boolean
}