# LanguageTool Grammar Checking

**URL:** <https://community.silverbullet.md/t/languagetool-grammar-checking/4121>\
**Category:** Plugs and Libraries\
**Created:** [May 8, 2026, 3:53am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121 "2026-05-08T03:53:20Z")\
**Posts on this page:** 14\
**Page:** 1

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [May 8, 2026, 3:53am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/1 "2026-05-08T03:53:21Z")

</div>

I've been working on a (very much WIP) LanguageTool based grammar checker library. So far I've pointed it to my self-hosted LanguageTool server and it works pretty well. I hope you guys like it!

Also, any suggestions on how to render the feedback? Right now, I just insert the feedback as markdown at the user's cursor position, which isn't ideal. Is there a way I can render lines under the text similar to grammarly?

> **Code (click to open)**
>
> ```space-lua
> 
> config.define("grammarcheck", {
> description = "Configure Grammar Checker Library",
> type = "object",
> properties = {
> url = {
> type = "string",
> default = "http://127.0.0.1:8081/v2/check",
> description = "API Endpoint for languagetool",
> ui = { category = "Grammar Checker", label = "API Endpoint" }
> },
> lang = {
> type = "string",
> default = "en-US",
> description = "Language code to use for LanguageTool. (auto will autodetect language, your mileage may vary)",
> ui = { category = "Grammar Checker", label = "Language" }
> },
> apiKey = {
> type = "string",
> description = "API Key for premium LanguageTool features",
> ui = { category = "Grammar Checker", label = "API Key" }
> }
> },
> additionalProperties = false
> })
> 
> local function urlencode(str)
> if str then
> str = str:gsub("\n", "\r\n")
> str = str:gsub("([^%w %-%_%.%~])", function(c)
> return string.format("%%%02X", string.byte(c))
> end)
> str = str:gsub(" ", "+") -- Standard for application/x-www-form-urlencoded
> end
> return str
> end
> 
> slashCommand.define {
> name = "grammar",
> description = "Check all grammar on this page, inserting feedback at cursor",
> run = function()
> local languagetool_url = config.get("grammarcheck.url", "http://127.0.0.1:8081/v2/check")
> local lang = config.get("grammarcheck.lang", "en-US")
> 
> local text = urlencode(editor.getText())
> 
> local reqbody = string.format("language=%s&text=%s", lang, text)
> if config.get("grammarcheck.apiKey", nil) then
> body = body .. "&apiKey=" .. config.get("grammarcheck.apiKey", nil)
> end
>     
> local resp = net.proxyFetch(languagetool_url, {
> method = "POST",
> headers = {
> ["accept"] = "application/json",
> ["Content-type"] = "application/x-www-form-urlencoded"
> },
> body = reqbody
> })
> 
> if resp.ok and resp.status ~= 400 then
> local body = resp.body
> -- TODO: add highlighting to grammar errors in place
> -- TODO: add ability to go through errors and approve changes.
> local confirm = editor.confirm("Do you want to insert feedback?")
> if confirm then
> editor.insertAtCursor(string.format("### %d grammar errors\n\n", #body.matches))
> for _, match in ipairs(body.matches) do
> local blockquote = match.context.text
>           
> -- Insert the highlight
> blockquote = blockquote:sub(1, match.context.offset) .. "==" .. blockquote:sub(match.context.offset + 1, match.context.offset + match.context.length) .. "==" .. blockquote:sub(match.context.offset + match.context.length + 1)
> 
> -- Reformat to block quote
> blockquote = blockquote:gsub("\r", "")
> blockquote = blockquote:gsub("\n", "\n> ")
> local msg = match.message:gsub("\r", "")
> msg = msg:gsub("\n", " ")
> local feedback = "> [!WARNING] " .. msg .. "\n> " .. blockquote .. "\n\n"
> editor.insertAtCursor(feedback)
> end
> end
> else
> editor.alert(string.format("Language tool call to url %s failed with code %d", languagetool_url, resp.status))
> end
> end
> }
> 
> ```

---

<div class="post-metadata">

**Author:** ![zef](https://community.silverbullet.md/user_avatar/community.silverbullet.md/zef/32/7_2.png) [@zef](https://community.silverbullet.md/u/zef)\
**Post date:** [May 8, 2026, 11:02am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/2 "2026-05-08T11:02:08Z")

</div>

Nice! For those unfamiliar with LanguageTool (like me), this is it right? [https://dev.languagetool.org/](https://dev.languagetool.org/)

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [May 8, 2026, 11:25am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/3 "2026-05-08T11:25:06Z")

</div>

Yep, that’s it! You can either self host the server or use their software as a service to provide the grammar check API

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [May 8, 2026, 3:51pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/4 "2026-05-08T15:51:22Z")

</div>

Does this make the connection server-side? So if I'm hosting it on my home server, but access SilverBullet from abroad, it'll resolve the localhost uri correctly?

Also if you self host are you still limited on character limit like the saas free tier?

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [May 8, 2026, 7:17pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/5 "2026-05-08T19:17:35Z")

</div>

I used `net.proxyFetch` to do the API call, so I believe that fetches it server side. There shouldn't be any character limits if you self host, LanguageTool is an open source project.

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [July 1, 2026, 4:27pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/6 "2026-07-01T16:27:37Z")

</div>

I've rewritten the plugin to take advantage of the linter to show underline messages. Keep in mind this is a little experimental, and some Unicode characters will break the annotations.

> **Code**
>
> ```space-lua
> 
> config.defineCategory {
> name = "Grammar Checker",
> description = "Settings for Grammar Checker Library"
> }
> 
> config.define("grammarcheck", {
> description = "Configure Grammar Checker Library",
> type = "object",
> properties = {
> url = {
> type = "string",
> default = "http://127.0.0.1:8081/v2/check",
> description = "API Endpoint for languagetool",
> ui = { category = "Grammar Checker", label = "API Endpoint" }
> },
> lang = {
> type = "string",
> default = "en-US",
> description = "Language code to use for LanguageTool. (auto will autodetect language, your mileage may vary)",
> ui = { category = "Grammar Checker", label = "Language" }
> },
> apiKey = {
> type = "string",
> description = "API Key for premium LanguageTool features",
> ui = { category = "Grammar Checker", label = "API Key" }
> },
> enabled = {
> type = "boolean",
> default = true,
> description = "Enable the spell checker",
> ui = { category = "Grammar Checker", label = "Enabled" }
> },
> tag = {
> type = "boolean",
> default = true,
> description = "Only run on pages with grammarcheck: true in meta (recommended)",
> ui = { category = "Grammar Checker", label = "Only run on tagged pages" }
> }
> },
> additionalProperties = false
> })
> 
> local function urlencode(str)
> if str then
> str = str:gsub("([^%w %-%_%.%~])", function(c)
> return string.format("%%%02X", string.byte(c))
> end)
> str = str:gsub(" ", "+") -- Standard for application/x-www-form-urlencoded
> end
> return str
> end
> 
> local function getMetaKey(key)
> local meta = editor.getCurrentPageMeta()
> if meta ~= nil then
> return meta[key]
> end
> return nil
> end
> 
> if config.get("grammarcheck.enabled", false) then
> event.listen {
> name = "editor:lint",
> run = function()
> if config.get("tag", true) == false or getMetaKey("grammarcheck") == true then
> local languagetool_url = config.get("grammarcheck.url", "http://127.0.0.1:8081/v2/check")
> local lang = config.get("grammarcheck.lang", "en-US")
> 
> -- Also check page override
> local lang_override = getMetaKey("grammarcheck-lang")
> if lang_override ~= nil then
> lang = lang_override
> end
> 
> local text = editor.getText()
> -- Replace various unicode symbols
> -- TODO: find more characters that need replacing
> -- TODO: ignore feedback from frontmatter or in code blocks
>     
> -- Single quotes
> text = string.gsub(text, "[‘’]", "'")
> -- Double quotes
> text = string.gsub(text, "[”“]", "\"")
> -- Dashes
> text = string.gsub(text, "[—–]", "-")
> local textEncoded = urlencode(text)
> 
> local reqbody = string.format("language=%s&text=%s", lang, textEncoded)
> if config.get("grammarcheck.apiKey", nil) then
> body = body .. "&apiKey=" .. config.get("grammarcheck.apiKey", nil)
> end
>     
> local resp = net.proxyFetch(languagetool_url, {
> method = "POST",
> headers = {
> ["accept"] = "application/json",
> ["Content-type"] = "application/x-www-form-urlencoded"
> },
> body = reqbody
> })
> 
> if resp.ok and resp.status ~= 400 then
> local body = resp.body
> local result = {}
> 
> -- TODO: add ability to go through errors and approve changes.
> for _, match in ipairs(body.matches) do
> local msg = match.message
> local start = match.offset
> local len = match.length
>         
> table.insert(result, {
> from = start,
> to = start + len,
> message = msg,
> severity = "info"
> })
> end
> 
> return result
> else
> editor.flashNotification(string.format("Language tool call to url %s failed with code %d", languagetool_url, resp.status))
> end
> end
> end
> }
> end
> 
> ```

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [July 1, 2026, 7:17pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/7 "2026-07-01T19:17:01Z")

</div>

What causes the `editor:lint` event? I don't see it in the docs [here](https://silverbullet.md/Event) and nothing in this code sends it.

Also, if one has to manually call the lint event, I don't see why have config settings to disable the script globally nor on untagged pages.

Maybe it should be tied to `editor:pageModified`, with some debouncing? Or maybe on one of the saving related events, since those have debouncing already?

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [July 1, 2026, 8:29pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/8 "2026-07-01T20:29:41Z")

</div>

It's under the interactions section of the page. It seems to be called every time the page is updated. Here's a screenshot of it working (I changed the CSS a bit to make the underlines look more similar to grammarly):

 ![image](https://community.silverbullet.md/uploads/default/original/2X/8/8b33c68a92f295eb6d462935faace7edd4f2b32e.png)

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [July 2, 2026, 1:48am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/9 "2026-07-02T01:48:31Z")

</div>

Oh, thanks! I must've missed it under interactions somehow. It hadn't been working for me, and since I hadn't seen that event under editor, it felt plausible that the event being missing was why it wasn't working...

Turned out the actual issue for me was that I'd set `grammarcheck.tag = false` in CONFIG, but the code looks for `tag` instead (not under `grammarcheck`).

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [July 2, 2026, 1:55am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/10 "2026-07-02T01:55:38Z")

</div>

Now I just wish there was a way to add `spellcheck="false"` to all the inputs, so the browser doesn't add it's own squigglies!

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [July 3, 2026, 3:30am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/11 "2026-07-03T03:30:51Z")

</div>

That's my bad about the tag config option. I'm going to clean the code up and push it to a git repo in the next few days

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [July 10, 2026, 6:28pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/12 "2026-07-10T18:28:40Z")

</div>

You have a TODO in your code about stripping code blocks and frontmatter before passing the text to languagetool. The code to do that is:

````lua
    -- Code blocks
    text = string.gsub(text, "```.-```", "")
    text = string.gsub(text, "`[^`]-`", "")
    -- Frontmatter
    local fm = index.extractFrontmatter(text, {
      removeFrontMatterSection = true,
      removeTags = false
    })
    text = fm.text

````

edit: Ah, this makes the character ranges inaccurate. I see why this wasn't already implemented haha

---

<div class="post-metadata">

**Author:** ![\_jj](https://community.silverbullet.md/letter_avatar_proxy/v4/letter/_/8baadc/32.png) [@\_jj](https://community.silverbullet.md/u/_jj)\
**Post date:** [July 10, 2026, 7:19pm UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/13 "2026-07-10T19:19:37Z")

</div>

Exactly. In a perfect world this would work. I think the method might be to create a table of all ranges that contain stuff we don't want to check (frontmatter, code blocks, latex blocks, etc) and then discard feedback that falls within these ranges. This might cause other issues though.

---

<div class="post-metadata">

**Author:** ![thepaperpilot](https://community.silverbullet.md/user_avatar/community.silverbullet.md/thepaperpilot/32/1557_2.png) [@thepaperpilot](https://community.silverbullet.md/u/thepaperpilot)\
**Post date:** [July 11, 2026, 12:11am UTC](https://community.silverbullet.md/t/languagetool-grammar-checking/4121/14 "2026-07-11T00:11:18Z")

</div>

I have pages with a lot of code that made languagetool use a lot of cpu. Id rather strip it and write a function to map languagetool character indices to full text indices
