diff --git a/packages/cheatsheet-local/jest.config.ts b/packages/cheatsheet-local/jest.config.ts new file mode 100644 index 0000000000..0997b3e91f --- /dev/null +++ b/packages/cheatsheet-local/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from "jest"; +import { preactModuleNameMapper } from "@cursorless/common"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "jsdom", + moduleNameMapper: { + ...preactModuleNameMapper, + "^@cursorless/cheatsheet$": "/../cheatsheet/src/index.ts", + "\\.(css|scss)$": "/src/test/styleMock.ts", + }, +}; + +export default config; diff --git a/packages/cheatsheet-local/package.json b/packages/cheatsheet-local/package.json index 8898baad17..7eacb64b6c 100644 --- a/packages/cheatsheet-local/package.json +++ b/packages/cheatsheet-local/package.json @@ -19,6 +19,7 @@ } }, "scripts": { + "test": "jest", "compile": "tsc --build", "watch": "tsc --build --watch", "build": "pnpm build:prod", @@ -29,19 +30,14 @@ "dependencies": { "@cursorless/cheatsheet": "workspace:*", "@cursorless/common": "workspace:*", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "preact": "^10.29.0", "tslib": "^2.8.1" }, "devDependencies": { + "@preact/preset-vite": "^2.10.3", "@tailwindcss/postcss": "^4.2.1", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/node": "^24.12.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "jest": "^30.3.0", "postcss": "^8.5.8", "tailwindcss": "^4.2.1", diff --git a/packages/cheatsheet-local/src/app/app.spec.tsx b/packages/cheatsheet-local/src/app/app.spec.tsx index 4326c685cf..349a063591 100644 --- a/packages/cheatsheet-local/src/app/app.spec.tsx +++ b/packages/cheatsheet-local/src/app/app.spec.tsx @@ -1,16 +1,36 @@ -import { render } from "@testing-library/react"; +import { fakeCheatsheetInfo } from "@cursorless/cheatsheet"; +import { render } from "preact"; +import { act } from "preact/test-utils"; import { App } from "./app"; describe("App", () => { - it("should render successfully", () => { - const { baseElement } = render(); + beforeEach(() => { + document.cheatsheetInfo = fakeCheatsheetInfo; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); - expect(baseElement).toBeTruthy(); + it("should render successfully", async () => { + const container = document.createElement("div"); + document.body.append(container); + + await act(() => { + render(, container); + }); + + expect(container).toBeTruthy(); }); - it("should have a greeting as the title", () => { - const { getByText } = render(); + it("should have a greeting as the title", async () => { + const container = document.createElement("div"); + document.body.append(container); + + await act(() => { + render(, container); + }); - expect(getByText(/Welcome cheatsheet-local/gi)).toBeTruthy(); + expect(container.textContent).toMatch(/Cursorless Cheatsheet/gi); }); }); diff --git a/packages/cheatsheet-local/src/index.tsx b/packages/cheatsheet-local/src/index.tsx index ea9956ce43..b08e8d5b1c 100644 --- a/packages/cheatsheet-local/src/index.tsx +++ b/packages/cheatsheet-local/src/index.tsx @@ -1,12 +1,18 @@ -import { StrictMode } from "react"; -import * as ReactDOM from "react-dom/client"; +import { render } from "preact"; +import { StrictMode } from "preact/compat"; import { App } from "./app/app"; -const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement, -); -root.render( +render( , + getRoot(), ); + +function getRoot() { + const root = document.getElementById("root"); + if (root == null) { + throw new Error("Missing root container"); + } + return root; +} diff --git a/packages/cheatsheet-local/src/test/styleMock.ts b/packages/cheatsheet-local/src/test/styleMock.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/packages/cheatsheet-local/src/test/styleMock.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/cheatsheet-local/tsconfig.json b/packages/cheatsheet-local/tsconfig.json index 7c7f5c8486..1af66ad93a 100644 --- a/packages/cheatsheet-local/tsconfig.json +++ b/packages/cheatsheet-local/tsconfig.json @@ -4,6 +4,7 @@ "target": "es2022", "lib": ["dom", "es2022"], "jsx": "react-jsx", + "jsxImportSource": "preact", "allowSyntheticDefaultImports": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/packages/cheatsheet-local/vite.config.ts b/packages/cheatsheet-local/vite.config.ts index 45a0a5ec21..aae848e987 100644 --- a/packages/cheatsheet-local/vite.config.ts +++ b/packages/cheatsheet-local/vite.config.ts @@ -1,6 +1,6 @@ import { fakeCheatsheetInfo } from "@cursorless/cheatsheet"; import { viteHtmlParams } from "@cursorless/common"; -import react from "@vitejs/plugin-react"; +import preact from "@preact/preset-vite"; import { defineConfig } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; @@ -11,7 +11,7 @@ export default defineConfig(() => { }, plugins: [ - react(), + preact(), viteSingleFile(), viteHtmlParams({ FAKE_CHEATSHEET_INFO: JSON.stringify(fakeCheatsheetInfo), diff --git a/packages/cheatsheet/jest.config.ts b/packages/cheatsheet/jest.config.ts index f7a6a00f40..288622febd 100644 --- a/packages/cheatsheet/jest.config.ts +++ b/packages/cheatsheet/jest.config.ts @@ -1,8 +1,10 @@ import type { Config } from "jest"; +import { preactModuleNameMapper } from "@cursorless/common"; const config: Config = { preset: "ts-jest", testEnvironment: "jsdom", + moduleNameMapper: preactModuleNameMapper, }; export default config; diff --git a/packages/cheatsheet/package.json b/packages/cheatsheet/package.json index 4679603d3d..05bca8797c 100644 --- a/packages/cheatsheet/package.json +++ b/packages/cheatsheet/package.json @@ -1,7 +1,7 @@ { "name": "@cursorless/cheatsheet", "version": "0.1.0", - "description": "Core cheatsheet react component", + "description": "Core cheatsheet Preact component", "license": "MIT", "type": "module", "main": "./out/index.js", @@ -26,21 +26,15 @@ "watch": "pnpm run --filter @cursorless/cheatsheet --parallel '/^watch:.*/'" }, "dependencies": { + "@cursorless/common": "workspace:*", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-string-replace": "^2.0.1", + "preact": "^10.29.0", "react-use": "^17.6.0" }, "devDependencies": { - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/react-helmet": "^6.1.11", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "ts-jest": "^29.4.6", diff --git a/packages/cheatsheet/src/lib/CheatsheetPage.tsx b/packages/cheatsheet/src/lib/CheatsheetPage.tsx index 3e591cca4b..13a82f2649 100644 --- a/packages/cheatsheet/src/lib/CheatsheetPage.tsx +++ b/packages/cheatsheet/src/lib/CheatsheetPage.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type { ComponentChildren } from "preact"; import CheatsheetListComponent from "./components/CheatsheetListComponent"; import CheatsheetLegendComponent from "./components/CheatsheetLegendComponent"; import cheatsheetLegend from "./cheatsheetLegend"; @@ -56,7 +56,7 @@ function Cheatsheet({ cheatsheetInfo }: Props) { } type CheatsheetSectionProps = { - children?: React.ReactNode; + children?: ComponentChildren; }; function CheatsheetSection({ children }: CheatsheetSectionProps) { diff --git a/packages/cheatsheet/src/lib/cheatsheet.spec.tsx b/packages/cheatsheet/src/lib/cheatsheet.spec.tsx index 5b87ae62be..8749aefb87 100644 --- a/packages/cheatsheet/src/lib/cheatsheet.spec.tsx +++ b/packages/cheatsheet/src/lib/cheatsheet.spec.tsx @@ -1,12 +1,21 @@ -import { render } from "@testing-library/react"; +import { render } from "preact"; +import { act } from "preact/test-utils"; import { CheatsheetPage } from "./CheatsheetPage"; import { fakeCheatsheetInfo } from "./fakeCheatsheetInfo"; describe("Cheatsheet", () => { - it("should render successfully", () => { - const { baseElement } = render( - , - ); - expect(baseElement).toBeTruthy(); + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("should render successfully", async () => { + const container = document.createElement("div"); + document.body.append(container); + + await act(() => { + render(, container); + }); + + expect(container).toBeTruthy(); }); }); diff --git a/packages/cheatsheet/src/lib/components/CheatsheetLegendComponent.tsx b/packages/cheatsheet/src/lib/components/CheatsheetLegendComponent.tsx index c73d7bc4bf..5adcb95799 100644 --- a/packages/cheatsheet/src/lib/components/CheatsheetLegendComponent.tsx +++ b/packages/cheatsheet/src/lib/components/CheatsheetLegendComponent.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type { JSX } from "preact"; import type { CheatsheetLegend } from "../cheatsheetLegend"; import useIsHighlighted from "../hooks/useIsHighlighted"; import { formatCaptures } from "./formatCaptures"; @@ -10,7 +10,7 @@ type Props = { export default function CheatsheetLegendComponent({ data, -}: Props): React.JSX.Element { +}: Props): JSX.Element { const isHighlighted = useIsHighlighted("legend"); const borderClassName = isHighlighted diff --git a/packages/cheatsheet/src/lib/components/CheatsheetListComponent.tsx b/packages/cheatsheet/src/lib/components/CheatsheetListComponent.tsx index 0f53149208..7f7bdfcc53 100644 --- a/packages/cheatsheet/src/lib/components/CheatsheetListComponent.tsx +++ b/packages/cheatsheet/src/lib/components/CheatsheetListComponent.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import type { JSX } from "preact"; import type { CheatsheetSection, Variation } from "../CheatsheetInfo"; import useIsHighlighted from "../hooks/useIsHighlighted"; import { formatCaptures } from "./formatCaptures"; diff --git a/packages/cheatsheet/src/lib/components/CheatsheetNotesComponent.tsx b/packages/cheatsheet/src/lib/components/CheatsheetNotesComponent.tsx index 3e7246d298..87c8215cb1 100644 --- a/packages/cheatsheet/src/lib/components/CheatsheetNotesComponent.tsx +++ b/packages/cheatsheet/src/lib/components/CheatsheetNotesComponent.tsx @@ -1,7 +1,7 @@ -import * as React from "react"; +import type { JSX } from "preact"; import SmartLink from "./SmartLink"; -export default function CheatsheetNotesComponent(): React.JSX.Element { +export default function CheatsheetNotesComponent(): JSX.Element { return (
= ({ +export default function SmartLink({ to, children, noFormatting = false, -}) => { +}: SmartLinkProps) { const className = noFormatting ? "" : "text-blue-500 hover:text-violet-700 dark:text-cyan-400 dark:hover:text-violet-200"; @@ -35,6 +35,4 @@ const SmartLink: React.FC = ({ )} ); -}; - -export default SmartLink; +} diff --git a/packages/cheatsheet/src/lib/components/formatCaptures.spec.tsx b/packages/cheatsheet/src/lib/components/formatCaptures.spec.tsx new file mode 100644 index 0000000000..ea40b5e9f4 --- /dev/null +++ b/packages/cheatsheet/src/lib/components/formatCaptures.spec.tsx @@ -0,0 +1,34 @@ +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { formatCaptures } from "./formatCaptures"; + +describe("formatCaptures", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("formats capture placeholders", async () => { + const container = document.createElement("div"); + document.body.append(container); + + await act(() => { + render(
{formatCaptures("hello world")}
, container); + }); + + expect(container.textContent).toBe("hello [target] world"); + expect(container.querySelector('a[href="#legend"]')).not.toBeNull(); + }); + + it("leaves malformed captures as plain text", async () => { + const container = document.createElement("div"); + document.body.append(container); + const input = "<<=<=<="; + + await act(() => { + render(
{formatCaptures(input)}
, container); + }); + + expect(container.textContent).toBe(input); + expect(container.querySelector('a[href="#legend"]')).toBeNull(); + }); +}); diff --git a/packages/cheatsheet/src/lib/components/formatCaptures.tsx b/packages/cheatsheet/src/lib/components/formatCaptures.tsx index ccb6fdfd6d..a9f46b0e63 100644 --- a/packages/cheatsheet/src/lib/components/formatCaptures.tsx +++ b/packages/cheatsheet/src/lib/components/formatCaptures.tsx @@ -1,20 +1,30 @@ -import reactStringReplace from "react-string-replace"; +import type { ComponentChildren } from "preact"; import SmartLink from "./SmartLink"; export function formatCaptures(input: string) { - return reactStringReplace(input, captureRegex, (match, i) => { + const parts: ComponentChildren[] = []; + let lastIndex = 0; + + for (const match of input.matchAll(captureRegex)) { + const [fullMatch, capture] = match; + const index = match.index ?? 0; + + if (index > lastIndex) { + parts.push(input.slice(lastIndex, index)); + } + const innerElement = - match === "ordinal" ? ( + capture === "ordinal" ? ( nth ) : ( - match + capture ); - return ( + parts.push( @@ -22,8 +32,17 @@ export function formatCaptures(input: string) { {innerElement} {"]"} - + , ); - }); + + lastIndex = index + fullMatch.length; + } + + if (lastIndex < input.length) { + parts.push(input.slice(lastIndex)); + } + + return parts; } + const captureRegex = /<([^>]+)>/g; diff --git a/packages/cheatsheet/tsconfig.json b/packages/cheatsheet/tsconfig.json index c01e663801..8a0fde570c 100644 --- a/packages/cheatsheet/tsconfig.json +++ b/packages/cheatsheet/tsconfig.json @@ -4,10 +4,15 @@ "target": "es2022", "lib": ["dom", "es2022"], "jsx": "react-jsx", + "jsxImportSource": "preact", "esModuleInterop": true, "skipLibCheck": true }, - "references": [], + "references": [ + { + "path": "../common" + } + ], "include": [ "src/**/*.ts", "src/**/*.json", diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 408f3bcd41..8490084871 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -45,12 +45,14 @@ export * from "./testUtil/asyncSafety"; export * from "./testUtil/extractTargetedMarks"; export * from "./testUtil/fromPlainObject"; export * from "./testUtil/getSnapshotForComparison"; +export * from "./testUtil/preactModuleNameMapper"; export * from "./testUtil/serialize"; export * from "./testUtil/serializeTestFixture"; export * from "./testUtil/shouldUpdateFixtures"; export * from "./testUtil/spyToPlainObject"; export * from "./testUtil/TestCaseSnapshot"; export * from "./testUtil/testConstants"; +export * from "./testUtil/viteHtmlParamsPlugin"; export * from "./types/command/ActionDescriptor"; export * from "./types/command/command.types"; export * from "./types/command/CommandV6.types"; @@ -122,4 +124,3 @@ export * from "./util/type"; export * from "./util/typeUtils"; export * from "./util/uniqWithHash"; export * from "./util/zipStrict"; -export * from "./viteHtmlParamsPlugin"; diff --git a/packages/common/src/testUtil/preactModuleNameMapper.ts b/packages/common/src/testUtil/preactModuleNameMapper.ts new file mode 100644 index 0000000000..d403f320d0 --- /dev/null +++ b/packages/common/src/testUtil/preactModuleNameMapper.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const preactModuleNameMapper = { + "^react$": "/node_modules/preact/compat/dist/compat.js", + "^react-dom$": "/node_modules/preact/compat/dist/compat.js", + "^react-dom/test-utils$": + "/node_modules/preact/test-utils/dist/testUtils.js", + "^react/jsx-runtime$": + "/node_modules/preact/jsx-runtime/dist/jsxRuntime.js", + "^react/jsx-dev-runtime$": + "/node_modules/preact/jsx-runtime/dist/jsxRuntime.js", + "^preact$": "/node_modules/preact/dist/preact.js", + "^preact/hooks$": "/node_modules/preact/hooks/dist/hooks.js", + "^preact/test-utils$": + "/node_modules/preact/test-utils/dist/testUtils.js", + "^preact/jsx-runtime$": + "/node_modules/preact/jsx-runtime/dist/jsxRuntime.js", + "^preact/jsx-dev-runtime$": + "/node_modules/preact/jsx-runtime/dist/jsxRuntime.js", +}; diff --git a/packages/common/src/viteHtmlParamsPlugin.ts b/packages/common/src/testUtil/viteHtmlParamsPlugin.ts similarity index 100% rename from packages/common/src/viteHtmlParamsPlugin.ts rename to packages/common/src/testUtil/viteHtmlParamsPlugin.ts diff --git a/packages/cursorless-org-docs/src/docs/components/Header.tsx b/packages/cursorless-org-docs/src/docs/components/Header.tsx index f01ea95d4f..e6942839d1 100644 --- a/packages/cursorless-org-docs/src/docs/components/Header.tsx +++ b/packages/cursorless-org-docs/src/docs/components/Header.tsx @@ -26,10 +26,10 @@ export function H5(props: Props) { } function renderHeader( - level: number, + level: 2 | 3 | 4 | 5, { className, id, title, children }: Props, ): React.JSX.Element { - const Tag = `h${level}` as keyof React.JSX.IntrinsicElements; + const Tag = `h${level}` as "h2" | "h3" | "h4" | "h5"; const encodedId = uriEncodeHashId(id ?? children); return (