Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EditorView } from '@codemirror/view';
import { diagnosticsField, setDiagnosticsEffect } from './decoration';
import type { Diagnostic } from './decoration';
import { addToDictionary, shouldAddToDict } from './lint';
import { kindColors, kindColorsDark, fallback, fallbackDark } from './styling';
import { kindColors, kindColorsDark, fallback, fallbackDark, injectStyleSheet } from './styling';

/** Set `--harper-kind-color` / `--harper-kind-color-dark` CSS custom properties on an element. */
export function setAccentColor(el: HTMLElement, lintKind: string) {
Expand Down Expand Up @@ -188,9 +188,5 @@ export function cardContentCSS(): string {

/** Inject the shared card content CSS once. */
export function injectCardCSS() {
if (document.getElementById('harper-card-base-styles')) return;
const style = document.createElement('style');
style.id = 'harper-card-base-styles';
style.textContent = cardContentCSS();
document.head.appendChild(style);
injectStyleSheet('harper-card-base-styles', cardContentCSS());
}
9 changes: 2 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,14 @@ import { MarkEdit } from 'markedit-api';
import { diagnosticsField, setDiagnosticsEffect, lintToDiagnostic } from './decoration';
import { clickTooltipField, tooltipHandlers } from './tooltip';
import { panelExtension } from './panel';
import { baseTheme, kindCSS } from './styling';
import { baseTheme, kindCSS, injectStyleSheet } from './styling';
import { lint } from './lint';
import { getProofreadingSettings } from './settings';

const { autoLintDelay } = getProofreadingSettings(MarkEdit.userSettings);

const kindStyleInjector = ViewPlugin.define(() => {
if (!document.getElementById('harper-kind-styles')) {
const style = document.createElement('style');
style.id = 'harper-kind-styles';
style.textContent = kindCSS();
document.head.appendChild(style);
}
injectStyleSheet('harper-kind-styles', kindCSS());
return {};
});

Expand Down
9 changes: 7 additions & 2 deletions src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function buildMenuItem(): MenuItem {
},
{
title: 'Reset Dictionary',
action: async() => {
action: async () => {
const result = await MarkEdit.showAlert({
title: 'Are you sure you want to reset the dictionary?',
message: 'All custom words you have added will be removed. This action cannot be undone.',
Expand Down Expand Up @@ -54,8 +54,13 @@ export function buildMenuItem(): MenuItem {

async function proofreadNow() {
const view = MarkEdit.editorView;
const text = view.state.doc.toString();
const doc = view.state.doc;
const text = doc.toString();
const lints = await lint(text);

// Bail out if the document changed during linting
if (view.state.doc !== doc) return;

view.dispatch({ effects: setDiagnosticsEffect.of(lints.map(lintToDiagnostic)) });
}

Expand Down
7 changes: 2 additions & 5 deletions src/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ViewUpdate } from '@codemirror/view';
import { diagnosticsField, setDiagnosticsEffect } from './decoration';
import type { Diagnostic } from './decoration';
import { setAccentColor, findDiagnostic, buildCardContent, injectCardCSS } from './card';
import { injectStyleSheet } from './styling';

const paneWidth = 290;

Expand Down Expand Up @@ -562,9 +563,5 @@ export function paneCSS(): string {

function injectPaneCSS() {
injectCardCSS();
if (document.getElementById('harper-pane-styles')) return;
const style = document.createElement('style');
style.id = 'harper-pane-styles';
style.textContent = paneCSS();
document.head.appendChild(style);
injectStyleSheet('harper-pane-styles', paneCSS());
}
2 changes: 1 addition & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function getProofreadingSettings(userSettings: JSONObject | undefined): P
) as LintConfig;

const disabledLintKinds = parseStringArray(raw.disabledLintKinds);
const addToDict = raw.addToDict === false ? false : true;
const addToDict = raw.addToDict !== false;

return { autoLintDelay, lintPreset, lintRuleOverrides, disabledLintKinds, addToDict };
}
Expand Down
9 changes: 9 additions & 0 deletions src/styling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,12 @@ export const baseTheme = EditorView.baseTheme({
transition: 'text-decoration-color 0.15s, background-color 0.15s',
},
});

/** Inject a `<style>` element once, keyed by `id`. No-op if already present. */
export function injectStyleSheet(id: string, css: string): void {
if (document.getElementById(id)) return;
const style = document.createElement('style');
style.id = id;
style.textContent = css;
document.head.appendChild(style);
}
9 changes: 2 additions & 7 deletions src/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Tooltip, TooltipView } from '@codemirror/view';
import { diagnosticsField } from './decoration';
import type { Diagnostic } from './decoration';
import { setAccentColor, buildCardContent, ignoreDiagnostic, injectCardCSS } from './card';
import { injectStyleSheet } from './styling';

export const setClickTooltip = StateEffect.define<Diagnostic | null>();

Expand Down Expand Up @@ -141,13 +142,7 @@ export const tooltipCSS = `

function createTooltip(view: EditorView, diagnostic: Diagnostic) {
injectCardCSS();

if (!document.getElementById('harper-tooltip-styles')) {
const style = document.createElement('style');
style.id = 'harper-tooltip-styles';
style.textContent = tooltipCSS;
document.head.appendChild(style);
}
injectStyleSheet('harper-tooltip-styles', tooltipCSS);

const dom = document.createElement('div');
dom.className = 'harper-tooltip-wrap';
Expand Down
37 changes: 37 additions & 0 deletions tests/card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { cardContentCSS } from '../src/card';

describe('cardContentCSS', () => {
it('includes message, button, ignore, and actions styles', () => {
const css = cardContentCSS();
expect(css).toContain('.harper-msg');
expect(css).toContain('.harper-btn');
expect(css).toContain('.harper-ignore');
expect(css).toContain('.harper-actions');
});

it('uses CSS custom properties for accent-colored code', () => {
const css = cardContentCSS();
expect(css).toMatch(/\.harper-msg\s+code\s*\{[^}]*var\(--harper-kind-color/);
expect(css).toMatch(/\.harper-msg\s+code\s*\{[^}]*color-mix/);
});

it('includes hover and active states for buttons', () => {
const css = cardContentCSS();
expect(css).toContain('.harper-btn:hover');
expect(css).toContain('.harper-btn:active');
expect(css).toContain('.harper-ignore:hover');
expect(css).toContain('.harper-ignore:active');
});

it('includes dark mode overrides', () => {
const css = cardContentCSS();
expect(css).toContain('@media (prefers-color-scheme: dark)');
});

it('styles ignore button with transparent background and auto margin', () => {
const css = cardContentCSS();
expect(css).toMatch(/\.harper-ignore\s*\{[^}]*background:\s*transparent/);
expect(css).toMatch(/\.harper-ignore\s*\{[^}]*margin-left:\s*auto/);
});
});
28 changes: 28 additions & 0 deletions tests/kinds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { presetDisabledKinds } from '../src/kinds';

describe('presetDisabledKinds', () => {
it('strict disables no kinds', () => {
expect(presetDisabledKinds('strict').size).toBe(0);
});

it('standard disables Enhancement, Style, and WordChoice', () => {
const kinds = presetDisabledKinds('standard');
expect(kinds).toEqual(new Set(['Enhancement', 'Style', 'WordChoice']));
});

it('relaxed includes every standard kind', () => {
const standard = presetDisabledKinds('standard');
const relaxed = presetDisabledKinds('relaxed');

for (const kind of standard) {
expect(relaxed.has(kind)).toBe(true);
}
});

it('relaxed disables more kinds than standard', () => {
const standard = presetDisabledKinds('standard');
const relaxed = presetDisabledKinds('relaxed');
expect(relaxed.size).toBeGreaterThan(standard.size);
});
});
36 changes: 36 additions & 0 deletions tests/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { presetDisabledRules } from '../src/rules';

describe('presetDisabledRules', () => {
it('strict disables no rules', () => {
expect(presetDisabledRules('strict')).toEqual([]);
});

it('standard disables a non-empty list of rules', () => {
const rules = presetDisabledRules('standard');
expect(rules.length).toBeGreaterThan(0);
});

it('relaxed includes every standard rule', () => {
const standard = presetDisabledRules('standard');
const relaxed = presetDisabledRules('relaxed');

for (const rule of standard) {
expect(relaxed).toContain(rule);
}
});

it('relaxed disables more rules than standard', () => {
const standard = presetDisabledRules('standard');
const relaxed = presetDisabledRules('relaxed');
expect(relaxed.length).toBeGreaterThan(standard.length);
});

it('contains no duplicate rule names within each preset', () => {
for (const preset of ['standard', 'relaxed'] as const) {
const rules = presetDisabledRules(preset);
const unique = new Set(rules);
expect(unique.size).toBe(rules.length);
}
});
});
24 changes: 23 additions & 1 deletion tests/styling.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { kindCSS } from '../src/styling';
import { kindCSS, kindColors, kindColorsDark } from '../src/styling';

describe('kindCSS', () => {
it('includes fallback rules and dark mode section', () => {
Expand All @@ -14,4 +14,26 @@ describe('kindCSS', () => {
expect(css).toContain('.cm-harper-lint[data-lint-kind="Style"] { text-decoration: underline solid #C49000aa 2px;');
expect(css).toContain('.harper-badge[data-kind="Style"] { color: #C49000; background-color: #C4900022; }');
});

it('generates rules for every kind in kindColors', () => {
const css = kindCSS();
for (const kind of Object.keys(kindColors)) {
expect(css).toContain(`data-lint-kind="${kind}"`);
expect(css).toContain(`data-kind="${kind}"`);
}
});

it('includes hover and active states for each kind', () => {
const css = kindCSS();
for (const kind of Object.keys(kindColors)) {
expect(css).toContain(`.cm-harper-lint[data-lint-kind="${kind}"]:hover`);
expect(css).toContain(`.cm-harper-lint[data-lint-kind="${kind}"]:active`);
}
});

it('every kind in kindColors has a dark variant', () => {
for (const kind of Object.keys(kindColors)) {
expect(kindColorsDark).toHaveProperty(kind);
}
});
});