From 8e50b31cd0d133fadc1dc0bba4f0f11b5726d0c4 Mon Sep 17 00:00:00 2001 From: Jonathan Kaplan Date: Wed, 25 Sep 2024 09:22:25 -0700 Subject: [PATCH] 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 b0a0f6d..6e839f7 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 6a81ad6..0967fef 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 d31afcd..53adb7d 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 33e9320..92eba68 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 + } + ]); + }); });