This commit is contained in:
Matthieu Jolimaitre 2025-07-10 23:37:19 +02:00
commit 515596ba2b
12 changed files with 427 additions and 0 deletions

1
assets/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/grammalecte

19
assets/update_grammalecte Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/bash
set -e
cd "$(dirname "$(realpath "$0")")"
dl_ref="$(wget -qO- https://grammalecte.net/ | grep ' <a class="button" href="zip/Grammalecte-fr-' | head -n 1 | cut -d '"' -f 4)"
if [ ".$dl_ref" = "." ]
then echo "[update_grammalecte] Failed to get latest grammalecte." && exit 1
fi
rm -fr grammalecte
mkdir -p grammalecte
wget -O ./grammalecte/grammalecte.zip "https://grammalecte.net/$dl_ref"
(
cd grammalecte
unzip grammalecte.zip
)

7
deno.json Normal file
View file

@ -0,0 +1,7 @@
{
"fmt": {
"useTabs": true,
"lineWidth": 120,
"semiColons": false
}
}

44
deno.lock generated Normal file
View file

@ -0,0 +1,44 @@
{
"version": "4",
"specifiers": {
"jsr:@hono/hono@4.8.4": "4.8.4",
"jsr:@std/assert@1.0.13": "1.0.13",
"jsr:@std/fs@1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.6": "1.0.9",
"jsr:@std/internal@^1.0.9": "1.0.9",
"jsr:@std/path@^1.1.1": "1.1.1",
"npm:zod@4.0.2": "4.0.2"
},
"jsr": {
"@hono/hono@4.8.4": {
"integrity": "dabc0ea1185040fc1e89abfe29906d9e3dd425a4c40f903f9ead78c48917da06"
},
"@std/assert@1.0.13": {
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
"dependencies": [
"jsr:@std/internal@^1.0.6"
]
},
"@std/fs@1.0.19": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
"dependencies": [
"jsr:@std/internal@^1.0.9",
"jsr:@std/path"
]
},
"@std/internal@1.0.9": {
"integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8"
},
"@std/path@1.1.1": {
"integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76",
"dependencies": [
"jsr:@std/internal@^1.0.9"
]
}
},
"npm": {
"zod@4.0.2": {
"integrity": "sha512-X2niJNY54MGam4L6Kj0AxeedeDIi/E5QFW0On2faSX5J4/pfLk1tW+cRMIMoojnCavn/u5W/kX17e1CSGnKMxA=="
}
}
}

50
src/common.ts Normal file
View file

@ -0,0 +1,50 @@
import { z } from "npm:zod@4.0.2"
export type Request = z.infer<ReturnType<typeof request_schema>>
export function request_schema() {
return z.object({
text: z.string(),
})
}
export function item_schema() {
return z.object({
"iParagraph": z.number(),
"lGrammarErrors": z.array(grammar_error_schema()),
"lSpellingErrors": z.array(spelling_error_schema()),
})
}
export function grammar_error_schema() {
return z.object({
"nStart": z.number(),
"nEnd": z.number(),
"sLineId": z.string(),
"sRuleId": z.string(),
"sType": z.string(),
"aColor": z.tuple([z.number(), z.number(), z.number()]),
"sMessage": z.string(),
"aSuggestions": z.array(z.string()),
"URL": z.string(),
})
}
export function spelling_error_schema() {
return z.object({
"i": z.number(),
"sType": z.literal("WORD"),
"sValue": z.string(),
"nStart": z.number(),
"nEnd": z.number(),
"aSuggestions": z.array(z.string()),
})
}
export type Output = z.infer<ReturnType<typeof output_schema>>
export function output_schema() {
return z.object({
"grammalecte": z.string(),
"lang": z.literal("fr"),
"data": z.array(item_schema()),
})
}

62
src/lib/grammalecte.ts Normal file
View file

@ -0,0 +1,62 @@
import { exists } from "jsr:@std/fs@1.0.19"
import { assertEquals } from "jsr:@std/assert@1.0.13"
import { Output, output_schema } from "../common.ts"
async function run(...args_: string[]) {
const script_url = new URL("../../assets/grammalecte/grammalecte-cli.py", import.meta.url)
const update_url = new URL("../../assets/update_grammalecte", import.meta.url)
if (!await exists(script_url)) await new Deno.Command(update_url).output()
const args = [script_url.pathname, ...args_]
const process = new Deno.Command("python3", { args, stdout: "piped" }).spawn()
const output = await process.output()
return new TextDecoder().decode(output.stdout)
}
Deno.test("test_run", async () => {
console.log(await run("--help"))
})
export async function check(content: string): Promise<Output> {
const temp_file = "/tmp/gram_input"
await Deno.writeTextFile(temp_file, content)
const output = await run("--file", temp_file, "--with_spell_sugg", "--json")
return output_schema().parse(JSON.parse(output))
}
Deno.test("test_check", async () => {
const value = await check("Très mal écri et un arbres.")
// Contains weird characters that throws off equality.
// deno-lint-ignore no-explicit-any
delete (value.data[0].lGrammarErrors[0] as any).sMessage
// Array with an inconsistent order.
// deno-lint-ignore no-explicit-any
delete (value.data[0].lSpellingErrors[0] as any).aSuggestions
assertEquals(value, {
"grammalecte": "2.1.1",
"lang": "fr",
"data": [
{
"iParagraph": 1,
"lGrammarErrors": [{
"nStart": 20,
"nEnd": 26,
"sLineId": "#24552",
"sRuleId": "g3__gn_un_1m__b2_a3_1",
"sType": "gn",
"aColor": [64, 127, 191],
"aSuggestions": ["arbre"],
"URL": "",
// deno-lint-ignore no-explicit-any
} as any],
"lSpellingErrors": [{
"i": 3,
"sType": "WORD",
"sValue": "écri",
"nStart": 9,
"nEnd": 13,
// deno-lint-ignore no-explicit-any
} as any],
},
],
})
})

21
src/main.ts Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env -S deno run --allow-all
import { Hono } from "jsr:@hono/hono@4.8.4"
import { request_schema } from "./common.ts"
import { check } from "./lib/grammalecte.ts"
async function main() {
const page = await Deno.readTextFile(new URL("page/index.html", import.meta.url))
const js = await Deno.readTextFile(new URL("page/main.mjs", import.meta.url))
const server = new Hono()
server.get("/", (c) => c.html(page))
server.get("/main.mjs", () => new Response(js, { headers: { "Content-Type": "text/javascript" } }))
server.post("/check", async (c) => {
const request = request_schema().parse(await c.req.json())
const response = await check(request.text)
return c.json(response)
})
Deno.serve(server.fetch)
}
if (import.meta.main) await main()

6
src/page/gen Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/bash
set -e
cd "$(dirname "$(realpath "$0")")"
deno bundle --unstable-raw-imports --minify --platform=browser main.ts --output main.mjs

49
src/page/index.html Normal file
View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sakura.css/css/sakura.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css" media="screen and (prefers-color-scheme: dark)" />
<title>Document</title>
<style>
.tooltip_parent {
position: relative;
}
.tooltip {
visibility: hidden;
position: absolute;
z-index: 1;
background-color: #0008;
backdrop-filter: blur(5px);
left: 0;
top: 1rem;
}
.tooltip_parent:hover .tooltip {
visibility: visible;
}
.choice:hover {
background-color: white;
cursor: pointer;
color: black;
}
.description {
background-color: black;
padding: 0.2rem;
}
</style>
</head>
<body style="padding: 0;">
<header style="height: 10rem; display: grid; place-items: center left;">
<h1>grammaire.barnulf.net</h1>
</header>
<main>
<textarea id="input" style="height: calc((100vh - 30rem) / 2);" placeholder="Texte à corriger ..."></textarea>
<pre id="fixes" style="min-height: calc((100vh - 30rem) / 2);"></pre>
</main>
<footer style="height: 10rem; display: grid; place-items: center left;">
<p>Correcteur grammatical basé sur grammalecte.</p>
</footer>
<script type="module" src="./main.mjs"></script>
</body>
</html>

30
src/page/main.mjs Normal file

File diff suppressed because one or more lines are too long

100
src/page/main.ts Normal file
View file

@ -0,0 +1,100 @@
/// <reference lib="dom" />
import { Output, output_schema, Request } from "../common.ts"
import { Slicer } from "./slicer.ts"
const input_area = document.querySelector<HTMLTextAreaElement>("#input")!
const fixes_area = document.querySelector<HTMLPreElement>("#fixes")!
function render(text: string, fixes: Output) {
const slicer = new Slicer(text, (text: string) => {
const element = document.createElement("span")
element.innerText = text
return element as HTMLElement
})
console.log(fixes.data)
const paragraphs = text.split("\n")
let i = 0
let offset = 0
for (const chunk of fixes.data) {
const paragraph = paragraphs[i++]
for (const error of chunk.lSpellingErrors) {
tooltip(
text,
offset + error.nStart,
offset + error.nEnd,
slicer,
"Mot inconnu : " + error.sValue,
"#f004",
error.aSuggestions,
)
}
for (const error of chunk.lGrammarErrors) {
tooltip(
text,
offset + error.nStart,
offset + error.nEnd,
slicer,
error.sMessage,
`rgba(${error.aColor.join(",")},0.25)`,
error.aSuggestions,
)
}
offset += paragraph.length + 1
}
fixes_area.innerHTML = ""
for (const part of slicer.parts) {
fixes_area.appendChild(part.content(part.text))
}
}
function tooltip(
text: string,
from: number,
to: number,
slicer: Slicer<(word: string) => Element>,
description: string,
color: string,
suggestions: string[],
) {
slicer.slice(from, to, (word) => {
const element = document.createElement("span")
element.innerText = word
element.style.backgroundColor = color
element.className = "tooltip_parent"
const hover_element = document.createElement("div")
hover_element.className = "tooltip"
const descr = document.createElement("div")
descr.className = "description"
descr.innerText = description
hover_element.appendChild(descr)
element.appendChild(hover_element)
for (const suggestion of suggestions) {
const choice = document.createElement("div")
choice.className = "choice"
choice.innerHTML = suggestion
hover_element.appendChild(choice)
choice.addEventListener("click", () => {
input_area.value = text.slice(0, from) + suggestion + text.slice(to)
update()
})
}
return element
})
}
let last_text = ""
async function update() {
const text = input_area.value
if (last_text === text) return
const request_body: Request = { text }
const result = await fetch("/check", { method: "POST", body: JSON.stringify(request_body) })
const body = await result.json()
const parsed = output_schema().parse(body)
render(text, parsed)
last_text = text
}
input_area.addEventListener("change", update)
setInterval(update, 1_000)
update()

38
src/page/slicer.ts Normal file
View file

@ -0,0 +1,38 @@
export class Slicer<T> {
public readonly parts: Part<T>[]
public constructor(
text: string,
public readonly content: T,
) {
this.parts = [new Part(text, 0, text.length, content)]
}
public slice(from: number, to: number, content: T) {
const found_id = this.parts.findIndex((p) => (p.from <= from) && (p.to >= to))
if (found_id === -1) return undefined
const found = this.parts[found_id]
const nodes = found.slice(from, to, content)
this.parts.splice(found_id, 1, ...nodes)
}
}
class Part<T> {
public constructor(
public readonly text: string,
public readonly from: number,
public readonly to: number,
public readonly content: T,
) {}
public len() {
return this.to - this.from
}
public slice(from: number, to: number, content: T) {
const before = new Part(this.text.slice(this.from - this.from, from - this.from), this.from, from, this.content)
const middle = new Part(this.text.slice(from - this.from, to - this.from), from, to, content)
const after = new Part(this.text.slice(to - this.from, this.to - this.from), to, this.to, this.content)
return [before, middle, after].filter((p) => p.len() > 0)
}
}