refactor(web): build out feedback mechanisms (#2069)

This commit is contained in:
Elijah Potter 2025-10-22 11:37:49 -06:00 committed by GitHub
parent bbcfb2b35e
commit 353de58647
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 2053 additions and 189 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ build
.DS_Store
*.pdf
node_modules
mariadb_data
# Ignore direnv files
.direnv/*

View file

@ -1,4 +1,7 @@
ARG NODE_VERSION=slim
# This Dockerfile is for the Harper website and web services.
# You do not need it to use Harper.
ARG NODE_VERSION=24
FROM rust:latest AS wasm-build
RUN rustup toolchain install
@ -38,7 +41,10 @@ RUN pnpm build
FROM node:${NODE_VERSION}
COPY --from=node-build /usr/build/node_modules /usr/build/node_modules
COPY --from=node-build /usr/build/packages/web/node_modules /usr/build/packages/web/node_modules
COPY --from=node-build /usr/build/packages/web/build /usr/build/packages/web/build
COPY ./packages/web/drizzle /usr/build/packages/web/build/drizzle
COPY --from=node-build /usr/build/packages/web/package.json /usr/build/packages/web/package.json
WORKDIR /usr/build/packages/web/build

View file

@ -12,6 +12,7 @@
"**/*.json",
"!**/test-results",
"!**/node_modules",
"!**/mariadb_data",
"!**/dist",
"!**/target",
"!**/build",

21
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,21 @@
# This Docker compose file is for development of the Harper website and web services.
# You do not need it to use Harper.
services:
db:
image: mariadb:lts
restart: always
environment:
MARIADB_ROOT_PASSWORD: password
MARIADB_DATABASE: harper
MARIADB_USER: devuser
MARIADB_PASSWORD: password
ports:
- "3306:3306"
volumes:
- ./mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10

33
docker-compose.yml Normal file
View file

@ -0,0 +1,33 @@
# This Docker compose file is for development of the Harper website and web services.
# You do not need it to use Harper.
services:
site:
build:
dockerfile: Dockerfile
restart: always
ports:
- "3000:3000"
environment:
- ORIGIN=http://localhost:3000
- DATABASE_URL=mysql://devuser:password@db:3306/harper
depends_on:
db:
condition: service_healthy
db:
image: mariadb:lts
restart: always
environment:
MARIADB_ROOT_PASSWORD: password
MARIADB_DATABASE: harper
MARIADB_USER: devuser
MARIADB_PASSWORD: password
ports:
- "3306:3306"
volumes:
- ./mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10

View file

@ -20,7 +20,7 @@
--color-accent-sand: #d68c45;
}
@source "../node_modules/flowbite-svelte/dist";
@source "./node_modules/flowbite-svelte/dist";
code {
@apply bg-primary-100 rounded p-1;

View file

@ -33,7 +33,6 @@
"@types/node": "catalog:",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.44.18",
"flowbite-svelte-icons": "^2.1.1",
"gulp": "^5.0.0",
"gulp-zip": "^6.0.0",
"http-server": "^14.1.1",
@ -48,12 +47,14 @@
"vite": "^5.4.10"
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tailwindcss/vite": "^4.1.4",
"@webcomponents/custom-elements": "^1.6.0",
"harper.js": "workspace:*",
"lint-framework": "workspace:*",
"lodash-es": "^4.17.21",
"lru-cache": "^11.1.0",
"svelte-fa": "^4.0.4",
"tailwindcss": "^4.1.4"
}
}

View file

@ -0,0 +1,17 @@
export type PopupState =
| {
page: 'onboarding';
}
| {
page: 'main';
}
| {
page: 'report-error';
feedback: string;
example: string;
rule_id: string;
};
export function main(): PopupState {
return { page: 'main' };
}

View file

@ -95,8 +95,28 @@ export default class ProtocolClient {
this.lintCache.clear();
}
public static async openReportError(
example: string,
ruleId: string,
feedback: string,
): Promise<void> {
await chrome.runtime.sendMessage({
kind: 'openReportError',
example,
rule_id: ruleId,
feedback,
});
}
public static async openOptions(): Promise<void> {
// Use background to open options to support content scripts reliably
await chrome.runtime.sendMessage({ kind: 'openOptions' });
}
public static async postFormData(
url: string,
formData: Record<string, string>,
): Promise<boolean> {
return (await chrome.runtime.sendMessage({ kind: 'postFormData', url, formData })).success;
}
}

View file

@ -1,5 +1,6 @@
import { BinaryModule, Dialect, type LintConfig, LocalLinter } from 'harper.js';
import { type UnpackedLintGroups, unpackLint } from 'lint-framework';
import type { PopupState } from '../PopupState';
import {
ActivationKey,
type AddToUserDictionaryRequest,
@ -20,6 +21,9 @@ import {
type IgnoreLintRequest,
type LintRequest,
type LintResponse,
type OpenReportErrorRequest,
type PostFormDataRequest,
type PostFormDataResponse,
type Request,
type Response,
type SetActivationKeyRequest,
@ -141,9 +145,13 @@ function handleRequest(message: Request): Promise<Response> {
return handleGetActivationKey();
case 'setActivationKey':
return handleSetActivationKey(message);
case 'openReportError':
return handleOpenReportError(message);
case 'openOptions':
chrome.runtime.openOptionsPage();
return createUnitResponse();
return Promise.resolve(createUnitResponse());
case 'postFormData':
return handlePostFormData(message);
}
}
@ -156,9 +164,7 @@ async function handleLint(req: LintRequest): Promise<LintResponse> {
const grouped = await linter.organizedLints(req.text);
const unpackedEntries = await Promise.all(
Object.entries(grouped).map(async ([source, lints]) => {
const unpacked = await Promise.all(
lints.map((lint) => unpackLint(req.text, lint, linter, source)),
);
const unpacked = await Promise.all(lints.map((lint) => unpackLint(req.text, lint, linter)));
return [source, unpacked] as const;
}),
);
@ -273,6 +279,46 @@ async function handleSetActivationKey(req: SetActivationKeyRequest): Promise<Uni
return createUnitResponse();
}
async function handleOpenReportError(req: OpenReportErrorRequest): Promise<UnitResponse> {
const popupState: PopupState = {
page: 'report-error',
example: req.example,
rule_id: req.rule_id,
feedback: req.feedback,
};
await chrome.storage.local.set({ popupState });
if (chrome.action?.openPopup) {
try {
await chrome.action.openPopup();
} catch (error) {
console.error('Failed to open popup for report error', error);
}
}
return createUnitResponse();
}
async function handlePostFormData(req: PostFormDataRequest): Promise<PostFormDataResponse> {
const formData = new FormData();
for (const [key, value] of Object.entries(req.formData)) {
formData.append(key, value);
}
try {
const response = await fetch(req.url, {
method: 'POST',
body: formData,
});
return { kind: 'postFormData', success: response.ok };
} catch (error) {
console.error('Failed to post form data', error);
return { kind: 'postFormData', success: false };
}
}
/** Set the lint configuration inside the global `linter` and in permanent storage. */
async function setLintConfig(lintConfig: LintConfig): Promise<void> {
await linter.setLintConfig(lintConfig);

View file

@ -1,5 +1,5 @@
import '@webcomponents/custom-elements';
import { isVisible, LintFramework, leafNodes } from 'lint-framework';
import { isVisible, LintFramework, leafNodes, type UnpackedLint } from 'lint-framework';
import isWordPress from '../isWordPress';
import ProtocolClient from '../ProtocolClient';
@ -12,8 +12,23 @@ const fw = new LintFramework((text, domain) => ProtocolClient.lint(text, domain)
getActivationKey: () => ProtocolClient.getActivationKey(),
openOptions: () => ProtocolClient.openOptions(),
addToUserDictionary: (words) => ProtocolClient.addToUserDictionary(words),
reportError: (lint: UnpackedLint, ruleId: string) =>
ProtocolClient.openReportError(
padWithContext(lint.source, lint.span.start, lint.span.end, 15),
ruleId,
'',
),
});
function padWithContext(source: string, start: number, end: number, contextLength: number): string {
const normalizedStart = Math.max(0, Math.min(start, source.length));
const normalizedEnd = Math.max(normalizedStart, Math.min(end, source.length));
const contextStart = Math.max(0, normalizedStart - contextLength);
const contextEnd = Math.min(source.length, normalizedEnd + contextLength);
return source.slice(contextStart, contextEnd);
}
const keepAliveCallback = () => {
ProtocolClient.lint('', 'example.com');

View file

@ -23,6 +23,8 @@ export function makeExtensionCSP(isDev: boolean): string {
connectSrc.push('http://127.0.0.1:*', 'ws://127.0.0.1:*');
}
connectSrc.push('https://writewithharper.com');
// Assemble the semicolon-delimited CSP
return `${[
`script-src ${scriptSrc.join(' ')}`,
@ -73,4 +75,5 @@ export default defineManifest({
content_security_policy: {
extension_pages: makeExtensionCSP(isDev),
},
host_permissions: ['https://writewithharper.com/*'],
});

View file

@ -22,7 +22,6 @@ $effect(() => {
*/
export async function getCurrentTabDomain(): Promise<string | undefined> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
console.log(tab);
if (!tab?.url) return undefined;

View file

@ -1,20 +1,23 @@
<script lang="ts">
import { faCaretLeft } from '@fortawesome/free-solid-svg-icons';
import { Button } from 'flowbite-svelte';
import { createEventDispatcher } from 'svelte';
import Fa from 'svelte-fa';
import logo from '/logo.png';
import { main, type PopupState } from '../PopupState';
import Main from './Main.svelte';
import Onboarding from './Onboarding.svelte';
import ReportProblematicLint from './ReportProblematicLint.svelte';
let page: 'onboarding' | 'main' = $state('main');
let popupState: PopupState = $state({ page: 'main' });
$effect(() => {
chrome.storage.local.get({ popupState: 'onboarding' }).then((result) => {
page = result.popupState;
chrome.storage.local.get({ popupState: { page: 'onboarding' } }).then((result) => {
popupState = result.popupState;
});
});
$effect(() => {
chrome.storage.local.set({ popupState: page });
chrome.storage.local.set({ popupState: $state.snapshot(popupState) });
});
function openSettings() {
@ -23,15 +26,25 @@ function openSettings() {
</script>
<div class="w-[340px] border border-gray-200 bg-white font-sans flex flex-col rounded-lg shadow-sm select-none">
<header class="flex items-center gap-2 px-3 py-2 bg-gray-50/60 rounded-t-lg">
<img src={logo} alt="Harper logo" class="h-6 w-auto" />
<span class="font-semibold text-sm">Harper</span>
<header class="flex flex-row justify-between items-center gap-2 px-3 py-2 bg-gray-50/60 rounded-t-lg">
<div class="flex flex-row justify-start items-center">
<img src={logo} alt="Harper logo" class="h-6 w-auto" />
<span class="font-semibold text-sm">Harper</span>
</div>
{#if popupState.page != "main"}
<Button outline on:click={() => {
popupState = main();
}}><Fa icon={faCaretLeft}/></Button>
{/if}
</header>
{#if page == "onboarding"}
<Onboarding onConfirm={() => { page = "main";}} />
{:else if page == "main"}
{#if popupState.page == "onboarding"}
<Onboarding onConfirm={() => { popupState = main();}} />
{:else if popupState.page == "main"}
<Main />
{:else if popupState.page == 'report-error'}
<ReportProblematicLint example={popupState.example} rule_id={popupState.rule_id} feedback={popupState.feedback} onSubmit={() => { popupState = main();}} />
{/if}
<footer class="flex items-center justify-center gap-6 px-3 py-2 text-sm border-t border-gray-100 rounded-b-lg bg-white/60">

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { Button, Checkbox, Input, Label } from 'flowbite-svelte';
import ProtocolClient from '../ProtocolClient';
let {
rule_id,
feedback,
example,
onSubmit,
}: { rule_id: string; feedback: string; example: string; onSubmit: () => void } = $props();
let submitting = $state(false);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
submitting = true;
const success = await ProtocolClient.postFormData(
'https://writewithharper.com/api/problematic-lints',
{
example,
rule_id,
feedback,
is_false_positive: 'true',
},
);
submitting = false;
if (success) {
onSubmit();
}
}
</script>
<div class="p-5">
<h1 class="text-2xl font-semibold">Report Problematic Lint</h1>
<p class="text-sm text-gray-600">
Only the data you enter below will be sent to the Harper maintainer.
</p>
<form class="mt-4 space-y-6" onsubmit={handleSubmit}>
<div class="space-y-3">
<div class="flex items-baseline gap-2">
<Label>What text caused (or should cause) feedback from Harper?</Label>
</div>
<Input name="example" bind:value={example} placeholder="Give us an example." />
<Checkbox name="is_false_positive" value="true" hidden />
<div class="flex items-baseline gap-2">
<Label>What rule caused (or should cause) feedback from Harper?</Label>
</div>
<Input
name="rule_id"
placeholder="We'd appreciate the specific rule ID, if applicable."
bind:value={rule_id}
/>
<div class="flex items-baseline gap-2">
<Label>Additional Feedback</Label>
</div>
<Input name="feedback" placeholder="Anything you want to add?" bind:value={feedback} />
<div class="flex items-center justify-between pt-2">
<Button type="submit" disabled={submitting}>Submit</Button>
</div>
</div>
</form>
</div>

View file

@ -19,7 +19,9 @@ export type Request =
| GetUserDictionaryRequest
| GetActivationKeyRequest
| SetActivationKeyRequest
| OpenOptionsRequest;
| OpenOptionsRequest
| OpenReportErrorRequest
| PostFormDataRequest;
export type Response =
| LintResponse
@ -31,7 +33,8 @@ export type Response =
| GetDefaultStatusResponse
| GetEnabledDomainsResponse
| GetUserDictionaryResponse
| GetActivationKeyResponse;
| GetActivationKeyResponse
| PostFormDataResponse;
export type LintRequest = {
kind: 'lint';
@ -169,6 +172,11 @@ export type GetActivationKeyResponse = {
key: ActivationKey;
};
export type PostFormDataResponse = {
kind: 'postFormData';
success: boolean;
};
export type SetActivationKeyRequest = {
kind: 'setActivationKey';
key: ActivationKey;
@ -177,3 +185,16 @@ export type SetActivationKeyRequest = {
export type OpenOptionsRequest = {
kind: 'openOptions';
};
export type OpenReportErrorRequest = {
kind: 'openReportError';
example: string;
rule_id: string;
feedback: string;
};
export type PostFormDataRequest = {
kind: 'postFormData';
url: string;
formData: Record<string, string>;
};

View file

@ -2,7 +2,7 @@ import computeLintBoxes from './computeLintBoxes';
import { isVisible } from './domUtils';
import Highlights from './Highlights';
import PopupHandler from './PopupHandler';
import type { UnpackedLintGroups } from './unpackLint';
import type { UnpackedLint, UnpackedLintGroups } from './unpackLint';
type ActivationKey = 'off' | 'shift' | 'control';
@ -32,6 +32,7 @@ export default class LintFramework {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
reportError?: (lint: UnpackedLint) => Promise<void>;
};
constructor(
@ -41,6 +42,7 @@ export default class LintFramework {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
reportError?: () => Promise<void>;
},
) {
this.lintProvider = lintProvider;
@ -50,6 +52,7 @@ export default class LintFramework {
getActivationKey: actions.getActivationKey,
openOptions: actions.openOptions,
addToUserDictionary: actions.addToUserDictionary,
reportError: actions.reportError,
});
this.targets = new Set();
this.scrollableAncestors = new Set();

View file

@ -39,12 +39,14 @@ export default class PopupHandler {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
reportError?: () => Promise<void>;
};
constructor(actions: {
getActivationKey?: () => Promise<ActivationKey>;
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
reportError?: () => Promise<void>;
}) {
this.actions = actions;
this.currentLintBoxes = [];

View file

@ -4,7 +4,7 @@ import bookDownSvg from '../assets/bookDownSvg';
import type { IgnorableLintBox, LintBox } from './Box';
import lintKindColor from './lintKindColor';
// Decoupled: actions passed in by framework consumer
import type { UnpackedSuggestion } from './unpackLint';
import type { UnpackedLint, UnpackedSuggestion } from './unpackLint';
var FocusHook: any = function () {};
FocusHook.prototype.hook = function (node: any, _propertyName: any, _previousValue: any) {
@ -185,6 +185,26 @@ function suggestions(
});
}
function reportProblemButton(reportError?: () => Promise<void>): any {
if (!reportError) {
return undefined;
}
return h(
'button',
{
className: 'harper-report-link',
type: 'button',
onclick: () => {
reportError();
},
title: 'Report an issue with this lint',
'aria-label': 'Report an issue with this lint',
},
'Report',
);
}
function styleTag() {
return h('style', { id: 'harper-suggestion-style' }, [
`code{
@ -341,6 +361,22 @@ function styleTag() {
.harper-hint-drawer{ border-top-color:#30363d; background:#151b23; color:#9aa4af; }
.harper-hint-icon{ background:#3a2f0b; color:#f2cc60; }
.harper-hint-title{ color:#e6edf3; }
}
.harper-report-link{
margin-top:8px;
align-self:flex-start;
background:none;
border:none;
padding:0;
color:#0969da;
font-size:13px;
font-weight:600;
cursor:pointer;
}
.harper-report-link:hover{text-decoration:underline;}
.harper-report-link:focus{outline:2px solid #0969da; outline-offset:2px;}
@media (prefers-color-scheme:dark){
.harper-report-link{color:#58a6ff;}
}`,
]);
}
@ -359,6 +395,7 @@ export default function SuggestionBox(
actions: {
openOptions?: () => Promise<void>;
addToUserDictionary?: (words: string[]) => Promise<void>;
reportError?: (lint: UnpackedLint, ruleId: string) => Promise<void>;
},
hint: string | null,
close: () => void,
@ -408,6 +445,14 @@ export default function SuggestionBox(
],
),
hintDrawer(hint),
actions.reportError
? reportProblemButton(() => {
if (actions.reportError) {
return actions.reportError(box.lint, box.rule);
}
return Promise.resolve();
})
: undefined,
],
);
}

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/lib/db/schema.ts',
dialect: 'mysql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,6 @@
CREATE TABLE `uninstall_feedback` (
`id` int AUTO_INCREMENT NOT NULL,
`feedback` text NOT NULL,
`timestamp` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `uninstall_feedback_id` PRIMARY KEY(`id`)
);

View file

@ -0,0 +1,8 @@
CREATE TABLE `problematic_lint` (
`id` int AUTO_INCREMENT NOT NULL,
`is_false_positive` boolean NOT NULL,
`example` text NOT NULL,
`feedback` text NOT NULL,
`timestamp` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `problematic_lint_id` PRIMARY KEY(`id`)
);

View file

@ -0,0 +1 @@
ALTER TABLE `problematic_lint` ADD `rule_id` text;

View file

@ -0,0 +1,55 @@
{
"version": "5",
"dialect": "mysql",
"id": "c5978949-00ad-4366-8af4-31ca63a60f87",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"uninstall_feedback": {
"name": "uninstall_feedback",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"feedback": {
"name": "feedback",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"uninstall_feedback_id": {
"name": "uninstall_feedback_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View file

@ -0,0 +1,106 @@
{
"version": "5",
"dialect": "mysql",
"id": "a13c6522-4577-4493-a811-fc9a29305fcb",
"prevId": "c5978949-00ad-4366-8af4-31ca63a60f87",
"tables": {
"problematic_lint": {
"name": "problematic_lint",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"is_false_positive": {
"name": "is_false_positive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"example": {
"name": "example",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feedback": {
"name": "feedback",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"problematic_lint_id": {
"name": "problematic_lint_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"uninstall_feedback": {
"name": "uninstall_feedback",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"feedback": {
"name": "feedback",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"uninstall_feedback_id": {
"name": "uninstall_feedback_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View file

@ -0,0 +1,113 @@
{
"version": "5",
"dialect": "mysql",
"id": "fbee3b57-d046-43fc-aa34-a2ef07e19993",
"prevId": "a13c6522-4577-4493-a811-fc9a29305fcb",
"tables": {
"problematic_lint": {
"name": "problematic_lint",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"is_false_positive": {
"name": "is_false_positive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"example": {
"name": "example",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feedback": {
"name": "feedback",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rule_id": {
"name": "rule_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"problematic_lint_id": {
"name": "problematic_lint_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"uninstall_feedback": {
"name": "uninstall_feedback",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"feedback": {
"name": "feedback",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"uninstall_feedback_id": {
"name": "uninstall_feedback_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1760386452851,
"tag": "0000_cute_zuras",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1760545628828,
"tag": "0001_blushing_corsair",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1760546819156,
"tag": "0002_blushing_chameleon",
"breakpoints": true
}
]
}

View file

@ -12,10 +12,11 @@
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.17.1",
"@sveltejs/kit": "^2.37.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/reveal.js": "^5.0.3",
"autoprefixer": "^10.4.21",
"drizzle-kit": "^0.31.5",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.44.18",
"postcss": "^8.4.31",
@ -23,9 +24,9 @@
"svelte-check": "^4.1.5",
"tailwindcss": "^3.3.3",
"tslib": "catalog:",
"tsx": "^4.20.6",
"typescript": "catalog:",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.4.1"
},
@ -34,12 +35,16 @@
"@sveltepress/theme-default": "^5.0.7",
"@sveltepress/vite": "^1.1.5",
"chart.js": "^4.4.8",
"drizzle-orm": "^0.44.6",
"drizzle-zod": "^0.8.3",
"harper.js": "workspace:*",
"lint-framework": "workspace:*",
"lodash-es": "^4.17.21",
"mysql2": "^3.15.2",
"posthog-js": "^1.245.1",
"reveal.js": "^5.1.0",
"svelte-intersection-observer": "^1.0.0",
"typed.js": "^2.1.0"
"typed.js": "^2.1.0",
"zod": "^4.1.12"
}
}

View file

@ -0,0 +1,10 @@
import { db } from '$lib/db';
import { migrate } from 'drizzle-orm/mysql2/migrator';
// Migrate exactly once at startup
try {
await migrate(db, { migrationsFolder: './drizzle', migrationsTable: '__drizzle_migrations' });
} catch (e: any) {
console.log('Failed to migrate database.');
console.error(e);
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import LintCard from '$lib/LintCard.svelte';
import LintCard from '$lib/components/LintCard.svelte';
import { Card } from 'flowbite-svelte';
import { type WorkerLinter } from 'harper.js';
import {
@ -10,7 +10,7 @@ import {
type UnpackedSuggestion,
unpackLint,
} from 'lint-framework';
import demo from '../../../../demo.md?raw';
import demo from '../../../../../demo.md?raw';
export let content = demo.trim();

View file

@ -0,0 +1,3 @@
<div class="fixed left-0 top-0 w-screen h-screen bg-white dark:bg-black z-1000">
<slot />
</div>

View file

@ -0,0 +1,3 @@
import { drizzle } from 'drizzle-orm/mysql2';
export const db = drizzle({ connection: { uri: process.env.DATABASE_URL } });

View file

@ -0,0 +1,20 @@
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { db } from '..';
import { problematicLintTable } from '../schema';
export type ProblematicLintRow = typeof problematicLintTable.$inferSelect;
const ProblematicLintRowParser = createSelectSchema(problematicLintTable);
export type ProblematicLintSubmission = typeof problematicLintTable.$inferInsert;
const ProblematicLintSubmissionParser = createInsertSchema(problematicLintTable);
export default class ProblematicLints {
public static async validateAndCreate(rec: any) {
const parsed = ProblematicLintSubmissionParser.parse(rec);
await this.create(parsed);
}
public static async create(rec: ProblematicLintSubmission) {
await db.insert(problematicLintTable).values(rec);
}
}

View file

@ -0,0 +1,20 @@
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { db } from '..';
import { uninstallFeedbackTable } from '../schema';
export type UninstallFeedbackRow = typeof uninstallFeedbackTable.$inferSelect;
const UninstallFeedbackRowParser = createSelectSchema(uninstallFeedbackTable);
export type UninstallFeedbackSubmission = typeof uninstallFeedbackTable.$inferInsert;
const UninstallFeedbackSubmissionParser = createInsertSchema(uninstallFeedbackTable);
export default class UninstallFeedback {
public static async validateAndCreate(rec: any) {
const parsed = UninstallFeedbackSubmissionParser.parse(rec);
await this.create(parsed);
}
public static async create(rec: UninstallFeedbackSubmission) {
await db.insert(uninstallFeedbackTable).values(rec);
}
}

View file

@ -0,0 +1,17 @@
import { boolean, int, mysqlTable, text, timestamp } from 'drizzle-orm/mysql-core';
export const uninstallFeedbackTable = mysqlTable('uninstall_feedback', {
id: int().autoincrement().primaryKey(),
feedback: text().notNull(),
timestamp: timestamp().notNull().defaultNow(),
});
export const problematicLintTable = mysqlTable('problematic_lint', {
id: int().autoincrement().primaryKey(),
/** If false, implied to be a false-negative. */
is_false_positive: boolean().notNull(),
example: text().notNull(),
feedback: text().notNull(),
rule_id: text(),
timestamp: timestamp().notNull().defaultNow(),
});

View file

@ -2,8 +2,8 @@
import '../app.css';
import { browser } from '$app/environment';
import AutomatticLogo from '$lib/AutomatticLogo.svelte';
import GutterCenter from '$lib/GutterCenter.svelte';
import AutomatticLogo from '$lib/components/AutomatticLogo.svelte';
import GutterCenter from '$lib/components/GutterCenter.svelte';
import posthog from 'posthog-js';
import { onMount } from 'svelte';

View file

@ -5,28 +5,33 @@ export const frontmatter = {
</script>
<script lang="ts">
import ChromeLogo from '$lib/ChromeLogo.svelte';
import CodeLogo from '$lib/CodeLogo.svelte';
import Editor from '$lib/Editor.svelte';
import FirefoxLogo from '$lib/FirefoxLogo.svelte';
import GitHubLogo from '$lib/GitHubLogo.svelte';
import ObsidianLogo from '$lib/ObsidianLogo.svelte';
import Logo from '$lib/Logo.svelte';
import Graph from '$lib/Graph.svelte';
import Section from '$lib/Section.svelte';
import EmacsLogo from '$lib/EmacsLogo.svelte';
import HelixLogo from '$lib/HelixLogo.svelte';
import NeovimLogo from '$lib/NeovimLogo.svelte';
import SublimeLogo from '$lib/SublimeLogo.svelte';
import WordPressLogo from '$lib/WordPressLogo.svelte';
import ZedLogo from '$lib/ZedLogo.svelte';
import EdgeLogo from '$lib/EdgeLogo.svelte';
import ChromeLogo from '$lib/components/ChromeLogo.svelte';
import CodeLogo from '$lib/components/CodeLogo.svelte';
import Editor from '$lib/components/Editor.svelte';
import FirefoxLogo from '$lib/components/FirefoxLogo.svelte';
import GitHubLogo from '$lib/components/GitHubLogo.svelte';
import ObsidianLogo from '$lib/components/ObsidianLogo.svelte';
import Logo from '$lib/components/Logo.svelte';
import Graph from '$lib/components/Graph.svelte';
import Section from '$lib/components/Section.svelte';
import EmacsLogo from '$lib/components/EmacsLogo.svelte';
import HelixLogo from '$lib/components/HelixLogo.svelte';
import NeovimLogo from '$lib/components/NeovimLogo.svelte';
import SublimeLogo from '$lib/components/SublimeLogo.svelte';
import WordPressLogo from '$lib/components/WordPressLogo.svelte';
import ZedLogo from '$lib/components/ZedLogo.svelte';
import EdgeLogo from '$lib/components/EdgeLogo.svelte';
import { browser } from '$app/environment';
/**
* @param {string} keyword
*/
function agentHas(keyword: string) {
return navigator.userAgent.toLowerCase().search(keyword.toLowerCase()) > -1;
function agentHas(keyword: string): boolean | undefined {
if (!browser) {
return false;
}
return navigator.userAgent.toLowerCase().includes(keyword.toLowerCase());
}
</script>
@ -88,7 +93,9 @@ function agentHas(keyword: string) {
</div>
<div class="h-[800px] w-full overflow-hidden rounded-xl border border-neutral-200 shadow-sm dark:border-neutral-800">
<Editor />
{#if browser}
<Editor />
{/if}
</div>
</div>

View file

@ -0,0 +1,15 @@
import ProblematicLints from '$lib/db/models/ProblematicLints';
import { type RequestEvent, redirect } from '@sveltejs/kit';
export const POST = async ({ request }: RequestEvent) => {
const data = await request.formData();
await ProblematicLints.validateAndCreate({
is_false_positive: data.get('is_false_positive') === 'true',
example: data.get('example'),
rule_id: data.get('rule_id'),
feedback: data.get('feedback'),
});
throw redirect(303, '/');
};

View file

@ -0,0 +1,11 @@
import UninstallFeedback from '$lib/db/models/UninstallFeedback';
import { type RequestEvent, redirect } from '@sveltejs/kit';
export const POST = async ({ request }: RequestEvent) => {
const data = await request.formData();
await UninstallFeedback.validateAndCreate({
feedback: data.get('feedback'),
});
throw redirect(303, '/');
};

View file

@ -6,7 +6,7 @@ Harper is a grammar checker designed to run anywhere there is text (so really, a
Most Harper users are catching their mistakes in [Neovim](./integrations/neovim), [Obsidian](./integrations/obsidian), or [Visual Studio Code](./integrations/visual-studio-code).
<script>
import Editor from "$lib/Editor.svelte"
import Editor from "$lib/components/Editor.svelte"
</script>
<div class="h-96">

View file

@ -1 +0,0 @@
export const prerender = true;

View file

@ -1,11 +1,13 @@
<script>
import { page } from '$app/stores';
/// This page exists to be embedded via an `iframe`.
import Editor from '$lib/Editor.svelte';
import { page } from '$app/stores';
import Editor from '$lib/components/Editor.svelte';
import Isolate from '$lib/components/Isolate.svelte';
let content = $page.url.searchParams.get('initialText') ?? '';
</script>
<div class="absolute top-0 left-0 w-full h-full z-[1000] bg-white dark:bg-black">
<Isolate>
<Editor {content}></Editor>
</div>
</Isolate>

View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import 'reveal.js/dist/reveal.css';
import 'reveal.js/dist/theme/serif.css';
import Logo from '$lib/Logo.svelte';
import Logo from '$lib/components/Logo.svelte';
import Reveal from 'reveal.js';
import { onMount } from 'svelte';

View file

@ -0,0 +1,38 @@
<script lang="ts">
import Isolate from '$lib/components/Isolate.svelte';
import { Button, Card, Checkbox, Input, Label, Radio } from 'flowbite-svelte';
</script>
<Isolate>
<div class="flex flex-row justify-center items-center h-screen">
<Card>
<h1 class="text-3xl font-semibold">Report Problematic Lint</h1>
<p class="text-sm text-gray-600">If you've encountered an example of Harper producing an incorrect result, we'd love to know about it.</p>
<form method="POST" class="mt-4 space-y-6" action="/api/problematic-lints">
<div class="space-y-3">
<div class="flex items-baseline gap-2">
<Label>What text caused (or should cause) feedback from Harper?</Label>
</div>
<Input name="example" placeholder="Give us an example." />
<Checkbox name="is_false_positive">Is it a false positive? Otherwise, leave unchecked.</Checkbox>
<div class="flex items-baseline gap-2">
<Label>What rule caused (or should cause) feedback from Harper?</Label>
</div>
<Input name="rule_id" placeholder="We'd appreciate the specific rule ID, if applicable." />
<div class="flex items-baseline gap-2">
<Label>Additional Feedback</Label>
</div>
<Input name="feedback" placeholder="Anything you want to add?" />
<div class="flex items-center justify-between pt-2">
<Button type="submit">Submit</Button>
</div>
</div>
</form>
</Card>
</div>
</Isolate>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import LintKindChart from '$lib/LintKindChart.svelte';
import LintKindChart from '$lib/components/LintKindChart.svelte';
import {
Fileupload,
Table,

View file

@ -1,5 +1,54 @@
<div class="fixed left-0 top-0 w-screen h-screen bg-white dark:bg-black z-1000">
<div class="max-w-4xl mx-auto shadow-md border-gray-300 dark:border-x h-full">
<iframe src="https://docs.google.com/forms/d/e/1FAIpQLSczTsdopx2AXpGiSemmYxuJAGoYBTKXLGWoORK3NpJKeHg3Vw/viewform?embedded=true" width="100%" height="100%" frameborder="0" marginheight="0" marginwidth="0">Loading…</iframe>
</div>
</div>
<script lang="ts">
import Isolate from '$lib/components/Isolate.svelte';
import { Button, Card, Input, Label, Radio } from 'flowbite-svelte';
const reasons = {
confused: 'I was confused by how it worked',
'unsupported-language': "It doesn't support my language",
'slowed-down-browser': 'It slowed down my browser',
'false-positive': 'It incorrectly flagged my text as an error',
'false-negative': "It didn't identify enough errors in my text",
'no-positives': "It didn't identify any errors in my text",
};
let otherSelected: string | number | undefined;
let otherText = '';
function handleFormData(e: FormDataEvent) {
const fd = e.formData;
if (fd.get('feedback') === 'other') {
const v = (fd.get('other') || '').toString().trim();
if (v) fd.set('feedback', v);
}
}
</script>
<Isolate>
<div class="flex flex-row justify-center items-center h-screen">
<Card>
<h1 class="text-3xl font-semibold">Uninstalling Harper</h1> <p class="text-sm text-gray-600">Were sorry to see you go. If you have a minute, would you mind telling us why you uninstalled our browser extension?</p>
<form method="POST" class="mt-4 space-y-6" action="/api/uninstall-feedback" on:formdata={handleFormData}>
<div class="space-y-3">
<div class="flex items-baseline gap-2">
<Label>Why did you uninstall Harper?</Label>
</div>
<div class="space-y-3">
{#each Object.entries(reasons) as [k, r], i}
<Radio value={k} name="feedback">{r}</Radio>
{/each}
<Radio name="feedback" value="other" bind:group={otherSelected}>Other</Radio>
{#if otherSelected}
<Input name="other" bind:value={otherText} placeholder="Your answer" />
{/if}
</div>
<div class="flex items-center justify-between pt-2">
<Button type="submit">Submit</Button>
</div>
</form>
</Card>
</div>
</Isolate>

View file

@ -5,8 +5,16 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
extensions: ['.svelte', '.md'],
preprocess: vitePreprocess(),
kit: {
csrf: {
trustedOrigins: [
'chrome-extension://lodbfhdipoipcjmlebjbgmmgekckhpfb',
'chrome-extension://hkjdmakdmihopipoiplebkelbhebigea',
],
},
prerender: {
entries: [],
},
adapter: adapter({
out: 'build',
}),

View file

@ -4,7 +4,12 @@ import { defineConfig } from 'vite';
import topLevelAwait from 'vite-plugin-top-level-await';
import wasm from 'vite-plugin-wasm';
const prod = process.env.APP_ENV === 'production';
export default defineConfig({
ssr: {
noExternal: prod ? ['mysql2', 'drizzle-orm', 'posthog-js', 'drizzle-zod', 'zod'] : [],
},
server: {
port: 3000,
fs: {

1258
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff