diff --git a/Dockerfile b/Dockerfile
index 5ade42f0..b9e54dd0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,8 +30,10 @@ WORKDIR /usr/build/packages/harper.js
RUN pnpm build && ./docs.sh
-WORKDIR /usr/build/packages/web
+WORKDIR /usr/build/packages/lint-framework
+RUN pnpm build
+WORKDIR /usr/build/packages/web
RUN pnpm build
FROM node:${NODE_VERSION}
diff --git a/justfile b/justfile
index b8694274..f8313356 100644
--- a/justfile
+++ b/justfile
@@ -24,6 +24,15 @@ build-harperjs: build-wasm
# Generate API reference
./docs.sh
+# Build the browser lint framework module
+build-lint-framework:
+ #!/usr/bin/env bash
+ set -eo pipefail
+
+ cd "{{justfile_directory()}}/packages/lint-framework"
+ pnpm install
+ pnpm build
+
test-harperjs: build-harperjs
#!/usr/bin/env bash
set -eo pipefail
@@ -66,7 +75,7 @@ build-wp: build-harperjs
pnpm plugin-zip
# Compile the website's dependencies and start a development server. Note that if you make changes to `harper-wasm`, you will have to re-run this command.
-dev-web: build-harperjs
+dev-web: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@@ -75,7 +84,7 @@ dev-web: build-harperjs
pnpm dev
# Build the Harper website.
-build-web: build-harperjs
+build-web: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@@ -96,7 +105,7 @@ build-obsidian: build-harperjs
zip harper-obsidian-plugin.zip manifest.json main.js
# Build the Chrome extension.
-build-chrome-plugin: build-harperjs
+build-chrome-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@@ -106,7 +115,7 @@ build-chrome-plugin: build-harperjs
pnpm zip-for-chrome
# Start a development server for the Chrome extension.
-dev-chrome-plugin: build-harperjs
+dev-chrome-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
@@ -116,7 +125,7 @@ dev-chrome-plugin: build-harperjs
pnpm dev
# Build the Firefox extension.
-build-firefox-plugin: build-harperjs
+build-firefox-plugin: build-harperjs build-lint-framework
#!/usr/bin/env bash
set -eo pipefail
diff --git a/packages/chrome-plugin/assets/book-down.svg b/packages/chrome-plugin/assets/book-down.svg
deleted file mode 100644
index cf8bb0a2..00000000
--- a/packages/chrome-plugin/assets/book-down.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/chrome-plugin/package.json b/packages/chrome-plugin/package.json
index a0768a65..2949c352 100644
--- a/packages/chrome-plugin/package.json
+++ b/packages/chrome-plugin/package.json
@@ -32,7 +32,6 @@
"@types/jquery": "^3.5.32",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
- "@types/virtual-dom": "^2.1.4",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.44.18",
"flowbite-svelte-icons": "^2.1.1",
@@ -53,10 +52,10 @@
"@tailwindcss/vite": "^4.1.4",
"@webcomponents/custom-elements": "^1.6.0",
"harper.js": "workspace:*",
+ "lint-framework": "workspace:*",
"jquery": "^3.7.1",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
- "tailwindcss": "^4.1.4",
- "virtual-dom": "^2.1.1"
+ "tailwindcss": "^4.1.4"
}
}
diff --git a/packages/chrome-plugin/src/ProtocolClient.ts b/packages/chrome-plugin/src/ProtocolClient.ts
index e0c70bc2..c1dfc4de 100644
--- a/packages/chrome-plugin/src/ProtocolClient.ts
+++ b/packages/chrome-plugin/src/ProtocolClient.ts
@@ -1,7 +1,7 @@
import type { Dialect, LintConfig } from 'harper.js';
+import type { UnpackedLint } from 'lint-framework';
import { LRUCache } from 'lru-cache';
import type { ActivationKey } from './protocol';
-import type { UnpackedLint } from './unpackLint';
export default class ProtocolClient {
private static readonly lintCache = new LRUCache>({
diff --git a/packages/chrome-plugin/src/SuggestionBox.ts b/packages/chrome-plugin/src/SuggestionBox.ts
deleted file mode 100644
index 73e42093..00000000
--- a/packages/chrome-plugin/src/SuggestionBox.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
-import h from 'virtual-dom/h';
-import bookDownSvg from '../assets/book-down.svg?raw';
-import type { IgnorableLintBox, LintBox } from './Box';
-import lintKindColor from './lintKindColor';
-import ProtocolClient from './ProtocolClient';
-import type { UnpackedSuggestion } from './unpackLint';
-
-var FocusHook = function () {};
-FocusHook.prototype.hook = function (node, _propertyName, _previousValue) {
- if ((node as any).__harperAutofocused) {
- return;
- }
-
- requestAnimationFrame(() => {
- node.focus();
- Object.defineProperty(node, '__harperAutofocused', {
- value: true,
- enumerable: false,
- configurable: false,
- });
- });
-};
-
-/** biome-ignore-all lint/complexity/useArrowFunction: It cannot be an arrow function for the logic to work. */
-var CloseOnEscapeHook = function (onClose: () => void) {
- this.onClose = onClose;
-};
-
-CloseOnEscapeHook.prototype.hook = function (this: { onClose: () => void }, node: HTMLElement) {
- const handler = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- this.onClose();
- }
- };
- window.addEventListener('keydown', handler);
- (node as any).__harperCloseOnEscapeHandler = handler;
-};
-
-CloseOnEscapeHook.prototype.unhook = function (this: any, node: HTMLElement) {
- const handler = (node as any).__harperCloseOnEscapeHandler;
- if (handler) {
- window.removeEventListener('keydown', handler);
- delete (node as any).__harperCloseOnEscapeHandler;
- }
-};
-
-function header(title: string, color: string, onClose: () => void): any {
- const closeButton = h(
- 'button',
- {
- className: 'harper-close-btn',
- onclick: onClose,
- title: 'Close',
- 'aria-label': 'Close',
- },
- '×',
- );
-
- const settingsButton = h(
- 'button',
- {
- className: 'harper-gear-btn',
- onclick: () => {
- ProtocolClient.openOptions();
- },
- title: 'Settings',
- 'aria-label': 'Settings',
- },
- '⚙',
- );
-
- const controls = h('div', { className: 'harper-controls' }, [settingsButton, closeButton]);
- const titleEl = h('span', {}, title);
-
- return h(
- 'div',
- {
- className: 'harper-header',
- style: { borderBottom: `2px solid ${color}` },
- },
- [titleEl, controls],
- );
-}
-
-function body(message_html: string): any {
- return h('div', { className: 'harper-body', innerHTML: message_html }, []);
-}
-
-function button(
- label: string,
- extraStyle: { [key: string]: string },
- onClick: (event: Event) => void,
- description?: string,
- extraProps: Record = {},
-): any {
- const desc = description || label;
- return h(
- 'button',
- {
- className: 'harper-btn',
- style: extraStyle,
- onclick: onClick,
- title: desc,
- 'aria-label': desc,
- ...extraProps,
- },
- label,
- );
-}
-
-function footer(leftChildren: any, rightChildren: any) {
- const left = h('div', { className: 'harper-child-cont' }, leftChildren);
- const right = h('div', { className: 'harper-child-cont' }, rightChildren);
- return h('div', { className: 'harper-footer' }, [left, right]);
-}
-
-function addToDictionary(box: LintBox): any {
- return h(
- 'button',
- {
- className: 'harper-btn',
- onclick: () => {
- ProtocolClient.addToUserDictionary([box.lint.problem_text]);
- },
- title: 'Add word to user dictionary',
- 'aria-label': 'Add word to user dictionary',
- innerHTML: bookDownSvg,
- },
- [],
- );
-}
-
-function suggestions(
- suggestions: UnpackedSuggestion[],
- apply: (s: UnpackedSuggestion) => void,
-): any {
- return suggestions.map((s: UnpackedSuggestion, i: number) => {
- const label = s.replacement_text !== '' ? s.replacement_text : s.kind;
- const desc = `Replace with "${label}"`;
- const props = i === 0 ? { hook: new FocusHook() } : {};
- return button(label, { background: '#2DA44E', color: '#FFFFFF' }, () => apply(s), desc, props);
- });
-}
-
-function styleTag() {
- return h('style', { id: 'harper-suggestion-style' }, [
- `code{
- background-color:#e3eccf;
- padding:0.125rem;
- border-radius:0.25rem
- }
- .harper-container{
- max-width:420px;
- max-height:400px;
- overflow-y:auto;
- background:#ffffff;
- border:1px solid #d0d7de;
- border-radius:8px;
- box-shadow:0 4px 12px rgba(140,149,159,0.3);
- padding:8px;
- display:flex;
- flex-direction:column;
- z-index:5000;
- font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
- pointer-events:auto
- }
- .harper-header{
- display:flex;
- align-items:center;
- justify-content:space-between;
- font-weight:600;
- font-size:14px;
- line-height:20px;
- color:#1f2328;
- padding-bottom:4px;
- margin-bottom:4px;
- user-select:none
- }
- .harper-body{
- font-size:14px;
- line-height:20px;
- color:#57606a
- }
- .harper-btn{
- display:inline-flex;
- align-items:center;
- justify-content:center;
- gap:4px;
- cursor:pointer;
- border:none;
- border-radius:6px;
- padding:3px 6px;
- min-height:28px;
- font-size:13px;
- font-weight:600;
- line-height:20px;
- transition:background 120ms ease,transform 80ms ease
- }
- .harper-btn:hover{filter:brightness(0.92)}
- .harper-btn:active{transform:scale(0.97)}
- .harper-close-btn{background:transparent;border:none;cursor:pointer;font-size:20px;line-height:1;color:#57606a;padding:0 4px;}
- .harper-close-btn:hover{color:#1f2328;}
- .harper-gear-btn{background:transparent;border:none;cursor:pointer;font-size:22px;line-height:1;color:#57606a;padding:0 4px;}
- .harper-gear-btn:hover{color:#1f2328;}
- .harper-controls{display:flex;align-items:center;gap:6px;}
- .harper-child-cont{
- display:flex;
- flex-wrap:wrap;
- justify-content:flex-end;
- gap:8px
- }
- .harper-footer{
- display:flex;
- flex-wrap:wrap;
- justify-content:space-between;
- padding:2px;
- gap:16px
- }
-
- .fade-in {
- animation: fadeIn 100ms ease-in-out forwards;
- }
-
- @keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
- }
-
- @media (prefers-color-scheme:dark){
- code{background-color:#1f2d3d;color:#c9d1d9}
- .harper-container{
- background:#0d1117;
- border-color:#30363d;
- box-shadow:0 4px 12px rgba(1,4,9,0.85)
- }
- .harper-header{color:#e6edf3}
- .harper-body{color:#8b949e}
- .harper-btn{
- background:#21262d;
- color:#c9d1d9
- }
- .harper-btn:hover{filter:brightness(1.15)}
- .harper-close-btn{color:#8b949e;}
- .harper-close-btn:hover{color:#e6edf3;}
- .harper-gear-btn{color:#8b949e;}
- .harper-gear-btn:hover{color:#e6edf3;}
- .harper-btn[style*="background: #2DA44E"]{background:#238636}
- .harper-btn[style*="background: #e5e5e5"]{
- background:#4b4b4b;
- color:#ffffff
- }
- }`,
- ]);
-}
-
-function ignoreLint(onIgnore: () => void): any {
- return button(
- 'Ignore',
- { background: '#e5e5e5', color: '#000000', fontWeight: 'lighter' },
- onIgnore,
- 'Ignore this lint',
- );
-}
-
-export default function SuggestionBox(box: IgnorableLintBox, close: () => void) {
- const top = box.y + box.height + 3;
- let bottom: number | undefined;
- const left = box.x;
-
- if (top + 400 > window.innerHeight) {
- bottom = window.innerHeight - box.y - 3;
- }
-
- const positionStyle: { [key: string]: string } = {
- position: 'fixed',
- top: bottom ? '' : `${top}px`,
- bottom: bottom ? `${bottom}px` : '',
- left: `${left}px`,
- };
-
- return h(
- 'div',
- {
- className: 'harper-container fade-in',
- style: positionStyle,
- 'harper-close-on-escape': new CloseOnEscapeHook(close),
- },
- [
- styleTag(),
- header(box.lint.lint_kind_pretty, lintKindColor(box.lint.lint_kind), close),
- body(box.lint.message_html),
- footer(
- suggestions(box.lint.suggestions, (v) => {
- box.applySuggestion(v);
- close();
- }),
- [
- box.lint.lint_kind === 'Spelling' ? addToDictionary(box) : undefined,
- ignoreLint(box.ignoreLint),
- ],
- ),
- ],
- );
-}
diff --git a/packages/chrome-plugin/src/background/index.ts b/packages/chrome-plugin/src/background/index.ts
index fe404774..57323a9a 100644
--- a/packages/chrome-plugin/src/background/index.ts
+++ b/packages/chrome-plugin/src/background/index.ts
@@ -1,4 +1,5 @@
import { BinaryModule, Dialect, type LintConfig, LocalLinter } from 'harper.js';
+import { unpackLint } from 'lint-framework';
import {
ActivationKey,
type AddToUserDictionaryRequest,
@@ -29,7 +30,6 @@ import {
type SetUserDictionaryRequest,
type UnitResponse,
} from '../protocol';
-import unpackLint from '../unpackLint';
console.log('background is running');
diff --git a/packages/chrome-plugin/src/contentScript/index.ts b/packages/chrome-plugin/src/contentScript/index.ts
index 97f2e9ec..ca97f550 100644
--- a/packages/chrome-plugin/src/contentScript/index.ts
+++ b/packages/chrome-plugin/src/contentScript/index.ts
@@ -1,10 +1,14 @@
import '@webcomponents/custom-elements';
import $ from 'jquery';
-import { isVisible, leafNodes } from '../domUtils';
-import LintFramework from '../LintFramework';
+import { isVisible, LintFramework, leafNodes } from 'lint-framework';
import ProtocolClient from '../ProtocolClient';
-const fw = new LintFramework();
+const fw = new LintFramework((text, domain) => ProtocolClient.lint(text, domain), {
+ ignoreLint: (hash) => ProtocolClient.ignoreHash(hash),
+ getActivationKey: () => ProtocolClient.getActivationKey(),
+ openOptions: () => ProtocolClient.openOptions(),
+ addToUserDictionary: (words) => ProtocolClient.addToUserDictionary(words),
+});
const keepAliveCallback = () => {
ProtocolClient.lint('', 'example.com');
diff --git a/packages/chrome-plugin/src/editorUtils.ts b/packages/chrome-plugin/src/editorUtils.ts
deleted file mode 100644
index e61e0386..00000000
--- a/packages/chrome-plugin/src/editorUtils.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { type Box, domRectToBox } from './Box';
-import TextFieldRange from './TextFieldRange';
-
-export function findAncestor(
- el: HTMLElement,
- predicate: (el: HTMLElement) => boolean,
-): HTMLElement | null {
- let node = el.parentElement;
-
- while (node != null) {
- if (predicate(node)) {
- return node;
- }
-
- node = node.parentElement;
- }
-
- return null;
-}
-
-export function findChild(
- el: HTMLElement,
- predicate: (el: HTMLElement) => boolean,
-): HTMLElement | null {
- const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
-
- while (queue.length > 0) {
- const node = queue.shift() as HTMLElement;
-
- if (predicate(node)) {
- return node;
- }
-
- queue.push(...(Array.from(node.children) as HTMLElement[]));
- }
-
- return null;
-}
-
-/** Determines if a given node is a child of a P2 editor instance.
- * If so, returns the root node of that instance. */
-export function getP2Root(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('p2-editor'));
-}
-
-/** Determines if a given node is a child of a Gutenberg editor instance.
- * If so, returns the root node of that instance. */
-export function getGutenbergRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) =>
- node.classList.contains('block-editor-block-canvas'),
- );
-}
-
-/** Determines if a given node is a child of a Lexical editor instance.
- * If so, returns the root node of that instance. */
-export function getLexicalRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(
- el,
- (node: HTMLElement) => node.getAttribute('data-lexical-editor') == 'true',
- );
-}
-
-export function getLexicalEditable(el: HTMLElement): HTMLElement | null {
- const lexical = getLexicalRoot(el);
-
- if (lexical == null) {
- return null;
- }
-
- return findChild(lexical, (node: HTMLElement) => node.getAttribute('contenteditable') == 'true');
-}
-
-/** Determines if a given node is a child of a Ghost editor instance.
- * If so, returns the root node of that instance. */
-export function getGhostRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('gh-editor'));
-}
-
-/** Determines if a given node is a child of a Slate.js editor instance.
- * If so, returns the root node of that instance. */
-export function getSlateRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.getAttribute('data-slate-editor') == 'true');
-}
-
-/** Determines if a given node is a child of a Draft.js editor instance.
- * If so, returns the root node of that instance. */
-export function getDraftRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('DraftEditor-root'));
-}
-
-/** Determines if a given node is a child of a Trix editor instance.
- * If so, returns the root node of that instance. */
-export function getTrixRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.nodeName == 'TRIX-EDITOR');
-}
-
-/** Determines if a given node is a child of a Reddit composer instance.
- * If so, returns the root node of that instance. */
-export function getShredditComposerRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.nodeName == 'SHREDDIT-COMPOSER');
-}
-
-/** Determines if a given node is a child of a Quill.js editor instance.
- * If so, returns the root node of that instance. */
-export function getQuillJsRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('ql-container'));
-}
-
-/** Determines if a given node is a child of a Medium.com editor instance.
- * If so, returns the root node of that instance. */
-export function getMediumRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(
- el,
- (node: HTMLElement) => node.nodeName == 'MAIN' && location.hostname == 'medium.com',
- );
-}
-
-/** Determines if a given node is a child of a Notion editor instance.
- * If so, returns the root node of that instance. */
-export function getNotionRoot(el: HTMLElement): HTMLElement | null {
- return document.getElementById('notion-app');
-}
-
-/** Determines if a given node is a child of a CodeMirror editor instance.
- * If so, returns the root node of that instance. */
-export function getCMRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('cm-editor'));
-}
-
-/** Determines if a given node is a child of a ProseMirror editor instance.
- * If so, returns the root node of that instance. */
-export function getPMRoot(el: HTMLElement): HTMLElement | null {
- return findAncestor(el, (node: HTMLElement) => node.classList.contains('ProseMirror'));
-}
-
-export function getCaretPosition(): Box | null {
- const active = document.activeElement;
-
- if (
- active instanceof HTMLTextAreaElement ||
- (active instanceof HTMLInputElement && active.type === 'text')
- ) {
- if (
- active.selectionStart == null ||
- active.selectionEnd == null ||
- active.selectionStart !== active.selectionEnd
- ) {
- return null;
- }
-
- const offset = active.selectionStart;
- const tfRange = new TextFieldRange(active, offset, offset);
- const rects = tfRange.getClientRects();
- tfRange.detach();
-
- return rects.length ? domRectToBox(rects[0]) : null;
- }
-
- const selection = window.getSelection();
- if (!selection || selection.rangeCount === 0) return null;
-
- const range = selection.getRangeAt(0);
- if (!range.collapsed) return null;
-
- return domRectToBox(range.getBoundingClientRect());
-}
diff --git a/packages/chrome-plugin/src/protocol.ts b/packages/chrome-plugin/src/protocol.ts
index 4654058d..56ed4439 100644
--- a/packages/chrome-plugin/src/protocol.ts
+++ b/packages/chrome-plugin/src/protocol.ts
@@ -1,5 +1,5 @@
import type { Dialect, LintConfig, Summary } from 'harper.js';
-import type { UnpackedLint, UnpackedSuggestion } from './unpackLint';
+import type { UnpackedLint, UnpackedSuggestion } from 'lint-framework';
export type Request =
| LintRequest
diff --git a/packages/chrome-plugin/tests/lint-kinds.spec.ts b/packages/chrome-plugin/tests/lint-kinds.spec.ts
index af9d58a3..528b09a9 100644
--- a/packages/chrome-plugin/tests/lint-kinds.spec.ts
+++ b/packages/chrome-plugin/tests/lint-kinds.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import lintKindColor, { LINT_KINDS } from '../src/lintKindColor';
+import { LINT_KINDS, lintKindColor } from 'lint-framework';
test('display lint kind colors', async ({ page }, testInfo) => {
// Generate color boxes for each lint kind
@@ -14,13 +14,13 @@ test('display lint kind colors', async ({ page }, testInfo) => {
Lint Kind Colors