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