From 45b252158fcaa01c7661dd93c27e9b11019d03cf Mon Sep 17 00:00:00 2001 From: Jonathan Kaplan Date: Sun, 22 Sep 2024 13:22:22 -0700 Subject: [PATCH 1/2] Reactor cleanup --- packages/reactor/index.ts | 4 ++-- packages/reactor/interfaces.ts | 2 +- packages/reactor/main.ts | 6 ++--- packages/reactor/modifications.ts | 24 +++++++++---------- .../reactor/modifications/adjacentHTML.ts | 2 +- packages/reactor/modifications/highlight.ts | 2 +- packages/reactor/modifications/noop.ts | 2 +- packages/reactor/modifications/remove.ts | 2 +- packages/reactor/modifications/replace.ts | 2 +- packages/reactor/modifications/replaceAll.ts | 6 ++--- packages/reactor/modifications/swapImage.ts | 2 +- packages/reactor/modifications/toast.ts | 2 +- packages/reactor/mutationObserver.ts | 10 ++++---- packages/reactor/reactor.ts | 8 +++---- packages/reactor/tests/modifications.test.ts | 2 +- packages/reactor/tests/mutation.test.ts | 2 +- packages/reactor/tsconfig.json | 4 +++- packages/reactor/utils.ts | 2 +- 18 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/reactor/index.ts b/packages/reactor/index.ts index 2b4417ed..7a783141 100644 --- a/packages/reactor/index.ts +++ b/packages/reactor/index.ts @@ -4,8 +4,8 @@ export type { Highlighter, Modification, ModificationRequest, -} from "./interfaces"; +} from "./interfaces.js"; -import Reactor from "./reactor"; +import Reactor from "./reactor.js"; export { Reactor }; \ No newline at end of file diff --git a/packages/reactor/interfaces.ts b/packages/reactor/interfaces.ts index 730b1a5b..b0a0f6dd 100644 --- a/packages/reactor/interfaces.ts +++ b/packages/reactor/interfaces.ts @@ -1,4 +1,4 @@ -import { generateRandomString } from "./utils"; +import { generateRandomString } from "./utils.js"; export type Modification = { selector?: string; diff --git a/packages/reactor/main.ts b/packages/reactor/main.ts index 2d7a05e6..6a81ad64 100644 --- a/packages/reactor/main.ts +++ b/packages/reactor/main.ts @@ -2,9 +2,9 @@ import type { AppliedModifications, DomJsonExportNode, ModificationRequest, -} from "./interfaces"; -import { generateModifications } from "./modifications"; -import { parseRequest } from "./utils"; +} from "./interfaces.js"; +import { generateModifications } from "./modifications.js"; +import { parseRequest } from "./utils.js"; export async function modifyHtml( htmlString: string, diff --git a/packages/reactor/modifications.ts b/packages/reactor/modifications.ts index dcaaf5db..d94bc429 100644 --- a/packages/reactor/modifications.ts +++ b/packages/reactor/modifications.ts @@ -1,11 +1,11 @@ -import { AdjacentHTMLModification } from "./modifications/adjacentHTML"; -import { HighlightModification } from "./modifications/highlight"; -import { NoopModification } from "./modifications/noop"; -import { RemoveModification } from "./modifications/remove"; -import { ReplaceModification } from "./modifications/replace"; -import { ReplaceAllModification } from "./modifications/replaceAll"; -import { SwapImageModification } from "./modifications/swapImage"; -import { ToastModification } from "./modifications/toast"; +import { AdjacentHTMLModification } from "./modifications/adjacentHTML.js"; +import { HighlightModification } from "./modifications/highlight.js"; +import { NoopModification } from "./modifications/noop.js"; +import { RemoveModification } from "./modifications/remove.js"; +import { ReplaceModification } from "./modifications/replace.js"; +import { ReplaceAllModification } from "./modifications/replaceAll.js"; +import { SwapImageModification } from "./modifications/swapImage.js"; +import { ToastModification } from "./modifications/toast.js"; import type { AppliableModification, @@ -13,7 +13,7 @@ import type { Highlighter, Modification, ModificationRequest, -} from "./interfaces"; +} from "./interfaces.js"; export class AppliedModificationsImpl implements AppliedModifications { modificationRequest: ModificationRequest; @@ -70,9 +70,9 @@ export async function generateModifications( // construct a new NodeListOf from items found by the xpath elements = []; if (!mod.xpath.startsWith("//html")) { - mod.xpath = `//html/${mod.xpath}`; + mod.xpath = `//html${mod.xpath}`; } - const xpath = document.evaluate( + const xpath = window.document.evaluate( mod.xpath, doc, null, @@ -201,7 +201,7 @@ export function matchesSelector(element: Element, mod: Modification): boolean { if (mod.selector) { return element.matches(mod.selector); } else if (mod.xpath) { - const xpathResult = document.evaluate(mod.xpath, document, null, XPathResult.BOOLEAN_TYPE, null); + const xpathResult = window.document.evaluate(mod.xpath, window.document, null, XPathResult.BOOLEAN_TYPE, null); return xpathResult.booleanValue; } diff --git a/packages/reactor/modifications/adjacentHTML.ts b/packages/reactor/modifications/adjacentHTML.ts index 15f2d591..81b2dfcd 100644 --- a/packages/reactor/modifications/adjacentHTML.ts +++ b/packages/reactor/modifications/adjacentHTML.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; export class AdjacentHTMLModification extends AppliableModification { elementId: string; diff --git a/packages/reactor/modifications/highlight.ts b/packages/reactor/modifications/highlight.ts index 7c0708b4..cf5ba6b6 100644 --- a/packages/reactor/modifications/highlight.ts +++ b/packages/reactor/modifications/highlight.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; import * as cssSelector from "css-selector-generator"; export class HighlightModification extends AppliableModification { diff --git a/packages/reactor/modifications/noop.ts b/packages/reactor/modifications/noop.ts index 6c142fec..0323969b 100644 --- a/packages/reactor/modifications/noop.ts +++ b/packages/reactor/modifications/noop.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; export class NoopModification extends AppliableModification { action: string; diff --git a/packages/reactor/modifications/remove.ts b/packages/reactor/modifications/remove.ts index a6682368..0aa017a6 100644 --- a/packages/reactor/modifications/remove.ts +++ b/packages/reactor/modifications/remove.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; import * as cssSelector from "css-selector-generator"; export class RemoveModification extends AppliableModification { diff --git a/packages/reactor/modifications/replace.ts b/packages/reactor/modifications/replace.ts index ff7f6f36..5e8aa56e 100644 --- a/packages/reactor/modifications/replace.ts +++ b/packages/reactor/modifications/replace.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; import * as cssSelector from "css-selector-generator"; export class ReplaceModification extends AppliableModification { diff --git a/packages/reactor/modifications/replaceAll.ts b/packages/reactor/modifications/replaceAll.ts index f3c4aa59..b6276292 100644 --- a/packages/reactor/modifications/replaceAll.ts +++ b/packages/reactor/modifications/replaceAll.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; export class ReplaceAllModification extends AppliableModification { element: Element; @@ -106,7 +106,7 @@ function walkTree( const changeNodes: Node[] = []; const changes: TreeChange[] = []; - const treeWalker = document.createTreeWalker( + const treeWalker = window.document.createTreeWalker( rootElement, NodeFilter.SHOW_TEXT, (node) => { @@ -195,7 +195,7 @@ function replaceText( for (let i = 0; i < split.length; i++) { if (typeof split[i] !== "undefined") { - const textNode = document.createTextNode(split[i] || ""); + const textNode = window.document.createTextNode(split[i] || ""); parentElement.insertBefore(textNode, nextSibling); if (i % 2 !== 0) { diff --git a/packages/reactor/modifications/swapImage.ts b/packages/reactor/modifications/swapImage.ts index 713a3742..f1f392bb 100644 --- a/packages/reactor/modifications/swapImage.ts +++ b/packages/reactor/modifications/swapImage.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; import * as cssSelector from "css-selector-generator"; export class SwapImageModification extends AppliableModification { diff --git a/packages/reactor/modifications/toast.ts b/packages/reactor/modifications/toast.ts index 58702b80..412bc607 100644 --- a/packages/reactor/modifications/toast.ts +++ b/packages/reactor/modifications/toast.ts @@ -1,4 +1,4 @@ -import { AppliableModification } from "../interfaces"; +import { AppliableModification } from "../interfaces.js"; export class ToastModification extends AppliableModification { message: string; diff --git a/packages/reactor/mutationObserver.ts b/packages/reactor/mutationObserver.ts index e3373cf5..7b31844b 100644 --- a/packages/reactor/mutationObserver.ts +++ b/packages/reactor/mutationObserver.ts @@ -1,6 +1,6 @@ -import Reactor from "./reactor"; -import { AppliedModificationsImpl } from "./modifications"; -import { applyModification, matchesSelector } from "./modifications"; +import Reactor from "./reactor.js"; +import { AppliedModificationsImpl } from "./modifications.js"; +import { applyModification, matchesSelector } from "./modifications.js"; export class ReactorMutationObserver { private reactor: Reactor; @@ -42,7 +42,7 @@ export class ReactorMutationObserver { } walkAddedElements(element: Element) { - const treeWalker = document.createTreeWalker( + const treeWalker = window.document.createTreeWalker( element, NodeFilter.SHOW_ELEMENT, null @@ -68,7 +68,7 @@ export class ReactorMutationObserver { } walkRemovedElements(element: Element) { - const treeWalker = document.createTreeWalker( + const treeWalker = window.document.createTreeWalker( element, NodeFilter.SHOW_ELEMENT, null diff --git a/packages/reactor/reactor.ts b/packages/reactor/reactor.ts index b26661ff..d31afcd3 100644 --- a/packages/reactor/reactor.ts +++ b/packages/reactor/reactor.ts @@ -3,13 +3,13 @@ import type { DomJsonExportNode, Highlighter, ModificationRequest, -} from "./interfaces"; -import { htmlElementToJson } from "./main"; +} from "./interfaces.js"; +import { htmlElementToJson } from "./main.js"; import { AppliedModificationsImpl, generateModifications, -} from "./modifications"; -import { ReactorMutationObserver } from "./mutationObserver"; +} from "./modifications.js"; +import { ReactorMutationObserver } from "./mutationObserver.js"; /** * Reactor applied modifications to the current page. Modifications diff --git a/packages/reactor/tests/modifications.test.ts b/packages/reactor/tests/modifications.test.ts index 62cfc28e..f42e0d8e 100644 --- a/packages/reactor/tests/modifications.test.ts +++ b/packages/reactor/tests/modifications.test.ts @@ -11,7 +11,7 @@ describe("Utils", () => { // Vitest beforeEach function for setup beforeEach(() => { - doc = document.implementation.createHTMLDocument("Test Document"); + doc = window.document.implementation.createHTMLDocument("Test Document"); }); describe("applyModification", () => { diff --git a/packages/reactor/tests/mutation.test.ts b/packages/reactor/tests/mutation.test.ts index f6a66789..3a065c24 100644 --- a/packages/reactor/tests/mutation.test.ts +++ b/packages/reactor/tests/mutation.test.ts @@ -11,7 +11,7 @@ describe("test mutation listeners", {}, () => { // Vitest beforeEach function for setup beforeEach(() => { - doc = document.implementation.createHTMLDocument("Test Document"); + doc = window.document.implementation.createHTMLDocument("Test Document"); reactor = new Reactor(); reactor.attach(doc, { diff --git a/packages/reactor/tsconfig.json b/packages/reactor/tsconfig.json index 001bfa82..4b213349 100644 --- a/packages/reactor/tsconfig.json +++ b/packages/reactor/tsconfig.json @@ -7,7 +7,9 @@ "module": "ESNext", "target": "ESNext", "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true }, "include": ["index.ts", "receivers", "utils", "commands"], "exclude": ["node_modules", "dist", "test"] diff --git a/packages/reactor/utils.ts b/packages/reactor/utils.ts index 4eba2125..dc79270c 100644 --- a/packages/reactor/utils.ts +++ b/packages/reactor/utils.ts @@ -1,6 +1,6 @@ import type { ModificationRequest, -} from "./interfaces"; +} from "./interfaces.js"; export function parseRequest(userRequest: string): ModificationRequest { try { From 8e50b31cd0d133fadc1dc0bba4f0f11b5726d0c4 Mon Sep 17 00:00:00 2001 From: Jonathan Kaplan Date: Wed, 25 Sep 2024 09:22:25 -0700 Subject: [PATCH 2/2] Add optional flag for including styles --- packages/reactor/interfaces.ts | 8 +- packages/reactor/main.ts | 41 +++++++++- packages/reactor/reactor.ts | 5 +- packages/reactor/tests/main.test.ts | 121 ++++++++++++++++++++++++++-- 4 files changed, 161 insertions(+), 14 deletions(-) diff --git a/packages/reactor/interfaces.ts b/packages/reactor/interfaces.ts index b0a0f6dd..6e839f73 100644 --- a/packages/reactor/interfaces.ts +++ b/packages/reactor/interfaces.ts @@ -37,11 +37,15 @@ export interface AppliedModifications { setHighlight(highlight: boolean): void; } -export interface DomJsonExportNode { +export type DomJsonExportOptions = { + styles?: boolean; +} + +export type DomJsonExportNode = { tag: string; visible: boolean; text?: string; - attributes?: Record; + attributes: Record; children?: DomJsonExportNode[]; } diff --git a/packages/reactor/main.ts b/packages/reactor/main.ts index 6a81ad64..0967fef9 100644 --- a/packages/reactor/main.ts +++ b/packages/reactor/main.ts @@ -1,6 +1,7 @@ import type { AppliedModifications, DomJsonExportNode, + DomJsonExportOptions, ModificationRequest, } from "./interfaces.js"; import { generateModifications } from "./modifications.js"; @@ -39,7 +40,11 @@ export async function modifyDom( } } -export const htmlElementToJson = (root: HTMLElement): DomJsonExportNode[] => { +export const htmlElementToJson = (root: HTMLElement, options?: DomJsonExportOptions): DomJsonExportNode[] => { + const stylesMap: {[key: string]: string} = {}; + const styleIndex: { idx: number } = { idx: 1 }; + const exportStyles = options?.styles ?? false; + function nodeToJson(node: Node): DomJsonExportNode { if (node instanceof Text) { return { @@ -49,6 +54,7 @@ export const htmlElementToJson = (root: HTMLElement): DomJsonExportNode[] => { node.parentElement.offsetHeight > 0 : false, text: node.data, + attributes: {} }; } @@ -60,15 +66,36 @@ export const htmlElementToJson = (root: HTMLElement): DomJsonExportNode[] => { element instanceof HTMLElement ? element.offsetWidth > 0 || element.offsetHeight > 0 : false, + attributes: {}, }; if (element.attributes.length > 0) { - obj.attributes = {}; for (const attr of Array.from(element.attributes)) { obj.attributes[attr.name] = attr.value; } } + if (exportStyles) { + const styles = window.getComputedStyle(element); + if (styles.length > 0) { + const styleString = Array.from(styles) + .map((style) => `${style}: ${styles.getPropertyValue(style)}`) + .join('; '); + let styleClass = stylesMap[styleString]; + if (!styleClass) { + styleClass = `mocksi-${styleIndex.idx}`; + stylesMap[styleString] = styleClass; + styleIndex.idx += 1; + } + + if (obj.attributes['class']) { + obj.attributes['class'] += ' ' + styleClass; + } else { + obj.attributes['class'] = styleClass; + } + } + } + const children = Array.from(element.childNodes).filter(textElementFilter); // special case: if the element has only one child, and that child is a text node, then @@ -101,6 +128,16 @@ export const htmlElementToJson = (root: HTMLElement): DomJsonExportNode[] => { .filter(textElementFilter) .map((child) => nodeToJson(child)); + if (exportStyles) { + const stylesString = Object.entries(stylesMap).map(([styleString, clazz]) => `.${clazz} { ${styleString} }`).join('\n'); + json.push({ + tag: 'style', + visible: false, + text: stylesString, + attributes: {} + }) + } + return json; }; diff --git a/packages/reactor/reactor.ts b/packages/reactor/reactor.ts index d31afcd3..53adb7d2 100644 --- a/packages/reactor/reactor.ts +++ b/packages/reactor/reactor.ts @@ -1,6 +1,7 @@ import type { AppliedModifications, DomJsonExportNode, + DomJsonExportOptions, Highlighter, ModificationRequest, } from "./interfaces.js"; @@ -122,7 +123,7 @@ class Reactor { * @throws {Error} If the reactor is not attached and no element is specified. * @return {DomJsonExportNode[]} An array of `DomJsonExportNode` objects representing the exported DOM. */ - exportDOM(element: null | HTMLElement = null): DomJsonExportNode[] { + exportDOM(element: null | HTMLElement = null, options?: DomJsonExportOptions): DomJsonExportNode[] { let useElement = element; if (!useElement) { @@ -133,7 +134,7 @@ class Reactor { } } - return htmlElementToJson(useElement); + return htmlElementToJson(useElement, options); } /** diff --git a/packages/reactor/tests/main.test.ts b/packages/reactor/tests/main.test.ts index 33e93205..92eba68c 100644 --- a/packages/reactor/tests/main.test.ts +++ b/packages/reactor/tests/main.test.ts @@ -1,16 +1,17 @@ import { JSDOM } from "jsdom"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import type { ModificationRequest } from "../interfaces"; -import { modifyDom, modifyHtml } from "../main"; +import { modifyDom, modifyHtml, htmlElementToJson } from "../main"; import type { AppliedModificationsImpl } from "../modifications"; -// Set up a mock DOM environment -const dom = new JSDOM(""); -global.document = dom.window.document; -// biome-ignore lint/suspicious/noExplicitAny: testing -global.window = dom.window as any; - describe("modifyHtml", () => { + let doc: Document; + + // Vitest beforeEach function for setup + beforeEach(() => { + doc = window.document.implementation.createHTMLDocument("Test Document"); + }); + it("should replace text content", async () => { const html = `
Eliza Hart
`; const userRequest = JSON.stringify({ @@ -330,4 +331,108 @@ describe("modifyHtml", () => { expect(result).not.toContain("

New content

"); expect(result).toContain("

Old content

"); }); + + it('should convert a simple HTML element to JSON', async () => { + doc.body.innerHTML = '
Hello World!
'; + const json = htmlElementToJson(doc.body); + + expect(json).toEqual([ + { + tag: 'div', + visible: false, + attributes: { + id: 'test', + class: 'example', + }, + text: 'Hello World!', + }, + ]); + }); + + it('should handle nested HTML elements', async () => { + doc.body.innerHTML = '

Hello

World!
'; + const json = htmlElementToJson(doc.body); + + expect(json).toEqual([ + { + tag: 'div', + visible: false, + attributes: { + id: 'test', + }, + children: [ + { + attributes: {}, + tag: 'p', + visible: false, + text: 'Hello', + }, + { + attributes: {}, + tag: 'span', + visible: false, + text: 'World!', + }, + ], + }, + ]); + }); + + it('should export styles when the option is set', async () => { + doc.body.innerHTML = '
Hello World!
'; + const json = htmlElementToJson(doc.body, {styles: true}); + + expect(json).toEqual([ + { + tag: 'div', + visible: false, + attributes: { + class: 'mocksi-1', + id: 'test', + style: 'color: red; font-size: 24px;', + }, + text: 'Hello World!', + }, + { + attributes: {}, + tag: "style", + text: ".mocksi-1 { display: block; color: rgb(255, 0, 0); font-size: 24px; visibility: visible; pointer-events: auto; background-color: rgba(0, 0, 0, 0); border-block-start-color: rgb(255, 0, 0); border-block-end-color: rgb(255, 0, 0); border-inline-start-color: rgb(255, 0, 0); border-inline-end-color: rgb(255, 0, 0); border-top-color: rgb(255, 0, 0); border-right-color: rgb(255, 0, 0); border-bottom-color: rgb(255, 0, 0); border-left-color: rgb(255, 0, 0); caret-color: auto }", + visible: false + } + ]); + }); + + it('should consolidate styles when they are the same', async () => { + doc.body.innerHTML = '
Hello World!
Hello World!
'; + const json = htmlElementToJson(doc.body, {styles: true}); + + expect(json).toEqual([ + { + tag: 'div', + visible: false, + attributes: { + class: 'mocksi-1', + id: 'test', + style: 'color: red; font-size: 24px;', + }, + text: 'Hello World!', + }, + { + tag: 'div', + visible: false, + attributes: { + class: 'mocksi-1', + id: 'test', + style: 'color: red; font-size: 24px;', + }, + text: 'Hello World!', + }, + { + attributes: {}, + tag: "style", + text: ".mocksi-1 { display: block; color: rgb(255, 0, 0); font-size: 24px; visibility: visible; pointer-events: auto; background-color: rgba(0, 0, 0, 0); border-block-start-color: rgb(255, 0, 0); border-block-end-color: rgb(255, 0, 0); border-inline-start-color: rgb(255, 0, 0); border-inline-end-color: rgb(255, 0, 0); border-top-color: rgb(255, 0, 0); border-right-color: rgb(255, 0, 0); border-bottom-color: rgb(255, 0, 0); border-left-color: rgb(255, 0, 0); caret-color: auto }", + visible: false + } + ]); + }); });