diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f9dcd489..f75f25580 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -112,7 +112,7 @@ export default tseslint.config( }, { - files: ['__tests__/**/*', 'perf/*'], + files: ['__tests__/**/*', 'website/**/*.test.ts', 'perf/*'], languageOptions: { globals: pluginJest.environments.globals.globals, }, diff --git a/jest.config.mjs b/jest.config.mjs index 1aaace83c..4bbd44198 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -5,7 +5,7 @@ const config = { transform: { '^.+\\.(js|ts)$': '/resources/jestPreprocessor.js', }, - testRegex: '/__tests__/.*\\.(ts|js)$', + testRegex: ['/__tests__/.*\\.(ts|js)$', '/website/.*\\.test\\.(ts|js)$'], testPathIgnorePatterns: ['/__tests__/ts-utils.ts'], }; diff --git a/package-lock.json b/package-lock.json index f2b6158e8..3fd7f4fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.5", "@eslint/js": "^9.20.0", + "@jdeniau/immutable-devtools": "^0.2.0", "@jest/globals": "^29.7.0", "@rollup/plugin-buble": "1.0.3", "@rollup/plugin-commonjs": "28.0.2", @@ -1925,6 +1926,13 @@ "node": ">=8" } }, + "node_modules/@jdeniau/immutable-devtools": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@jdeniau/immutable-devtools/-/immutable-devtools-0.2.0.tgz", + "integrity": "sha512-kncZLhyszWkGz0wAr8eoHFvhczuZz5Ud71OiLIhe5PFQ05nnLgsFdr520Qy+eHhMSL6roJYFrZ73ZJTv48/fUg==", + "dev": true, + "license": "BSD" + }, "node_modules/@jest/console": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.4.tgz", @@ -12931,6 +12939,12 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true }, + "@jdeniau/immutable-devtools": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@jdeniau/immutable-devtools/-/immutable-devtools-0.2.0.tgz", + "integrity": "sha512-kncZLhyszWkGz0wAr8eoHFvhczuZz5Ud71OiLIhe5PFQ05nnLgsFdr520Qy+eHhMSL6roJYFrZ73ZJTv48/fUg==", + "dev": true + }, "@jest/console": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.4.tgz", diff --git a/package.json b/package.json index baa0b4ab7..64c465f07 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.5", "@eslint/js": "^9.20.0", + "@jdeniau/immutable-devtools": "^0.2.0", "@jest/globals": "^29.7.0", "@rollup/plugin-buble": "1.0.3", "@rollup/plugin-commonjs": "28.0.2", diff --git a/website/src/repl/FormatterOutput.tsx b/website/src/repl/FormatterOutput.tsx index 3d96966ec..a27b9d44d 100644 --- a/website/src/repl/FormatterOutput.tsx +++ b/website/src/repl/FormatterOutput.tsx @@ -1,5 +1,6 @@ import { toHTML } from 'jsonml-html'; import { useEffect, useRef, type JSX } from 'react'; +import { Element, JsonMLElementList } from '../worker/jsonml-types'; /** * immutable-devtools is a console custom formatter. @@ -8,17 +9,13 @@ import { useEffect, useRef, type JSX } from 'react'; * The `jsonml-html` package can convert jsonml to HTML. */ type Props = { - output: { - header: Array; - body?: Array; - }; + output: JsonMLElementList | Element; }; export default function FormatterOutput({ output }: Props): JSX.Element { const header = useRef(null); - const body = useRef(null); - const htmlHeader = toHTML(output.header); + const htmlHeader = toHTML(output); useEffect(() => { if (header.current && htmlHeader) { @@ -26,18 +23,5 @@ export default function FormatterOutput({ output }: Props): JSX.Element { } }, [htmlHeader]); - const htmlBody = output.body ? toHTML(output.body) : null; - - useEffect(() => { - if (body.current) { - body.current.replaceChildren(htmlBody ?? ''); - } - }, [htmlBody]); - - return ( - <> -
-
- - ); + return
; } diff --git a/website/src/repl/Repl.tsx b/website/src/repl/Repl.tsx index 8b3c29d2b..dc5f7868e 100644 --- a/website/src/repl/Repl.tsx +++ b/website/src/repl/Repl.tsx @@ -4,15 +4,13 @@ import React, { useEffect, useRef, useState, type JSX } from 'react'; import { Editor } from './Editor'; import FormatterOutput from './FormatterOutput'; import './repl.css'; +import { Element, JsonMLElementList } from '../worker/jsonml-types'; type Props = { defaultValue: string; onRun?: (code: string) => void }; function Repl({ defaultValue, onRun }: Props): JSX.Element { const [code, setCode] = useState(defaultValue); - const [output, setOutput] = useState<{ - header: Array; - body?: Array; - }>({ header: [] }); + const [output, setOutput] = useState([]); const workerRef = useRef(null); useEffect(() => { @@ -41,9 +39,15 @@ function Repl({ defaultValue, onRun }: Props): JSX.Element { workerRef.current.postMessage(code); workerRef.current.onmessage = (event) => { if (event.data.error) { - setOutput({ header: ['div', 'Error: ' + event.data.error] }); + setOutput(['div', 'Error: ' + event.data.error]); } else { - setOutput(event.data.output); + const { output } = event.data; + + if (typeof output === 'object' && !Array.isArray(output)) { + setOutput(['div', { object: output }]); + } else { + setOutput(output); + } } }; } diff --git a/website/src/worker/jsonml-types.test.ts b/website/src/worker/jsonml-types.test.ts new file mode 100644 index 000000000..07426d8c4 --- /dev/null +++ b/website/src/worker/jsonml-types.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from '@jest/globals'; +import { Element, explodeElement } from './jsonml-types'; + +describe('explodeElement', () => { + it('should explode an element', () => { + expect(explodeElement(['div'])).toEqual({ + tagName: 'div', + attributes: undefined, + children: [], + }); + }); + + it('should explode an element with attributes', () => { + expect(explodeElement(['div', { id: 'test' }])).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: [], + }); + }); + + it('should explode an element with children', () => { + expect(explodeElement(['div', { id: 'test' }, 'Hello'])).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: ['Hello'], + }); + }); + + it('should explode an element with multiple children', () => { + expect(explodeElement(['div', { id: 'test' }, 'Hello', 'World'])).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: ['Hello', 'World'], + }); + }); + + it('should explode an element without attributes with multiple children', () => { + expect(explodeElement(['div', 'Hello', 'World'])).toEqual({ + tagName: 'div', + attributes: undefined, + children: ['Hello', 'World'], + }); + }); + + it('should explode an element with a nested element', () => { + expect(explodeElement(['div', { id: 'test' }, ['span', 'Hello']])).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: [['span', 'Hello']], + }); + }); + + it('should explode an element with a nested element with attributes', () => { + expect( + explodeElement([ + 'div', + { id: 'test' }, + ['span', { class: 'test' }, 'Hello'], + ]) + ).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: [['span', { class: 'test' }, 'Hello']], + }); + }); + + it('should explode an element with a nested element with multiple children', () => { + expect( + explodeElement([ + 'div', + { id: 'test' }, + ['span', 'Hello'], + ['span', { id: 'world' }, 'World'], + ]) + ).toEqual({ + tagName: 'div', + attributes: { id: 'test' }, + children: [ + ['span', 'Hello'], + ['span', { id: 'world' }, 'World'], + ], + }); + }); + + it('should handle immutable list jsonml', () => { + const spanElement: Element = [ + 'span', + { style: 'color: light-dark( #881391, #D48CE6)' }, + '0: ', + ]; + const objectElement: Element = ['object', { object: ['a'] }]; + + const element: Element = ['li', spanElement, objectElement]; + + expect(explodeElement(element)).toEqual({ + tagName: 'li', + attributes: undefined, + children: [ + ['span', { style: 'color: light-dark( #881391, #D48CE6)' }, '0: '], + ['object', { object: ['a'] }], + ], + }); + }); +}); diff --git a/website/src/worker/jsonml-types.ts b/website/src/worker/jsonml-types.ts index 4fe7b0c6b..1eadcef8e 100644 --- a/website/src/worker/jsonml-types.ts +++ b/website/src/worker/jsonml-types.ts @@ -10,20 +10,67 @@ // Basic types type TagName = string; type AttributeName = string; -type AttributeValue = string | number | boolean | null; +type AttributeValue = string | number | boolean | null | object; // Attributes // type Attribute = [AttributeName, AttributeValue]; // type AttributeList = Attribute[]; export type Attributes = Record; +type ElementWithAttributes = + | [TagName, Attributes, ...Element[]] // [tag-name, attributes, element-list] + | [TagName, Attributes]; // [tag-name, attributes] + // Elements export type Element = - | [TagName, Attributes, ...Element[]] // [tag-name, attributes, element-list] - | [TagName, Attributes] // [tag-name, attributes] + | ElementWithAttributes | [TagName, ...Element[]] // [tag-name, element-list] | [TagName] // [tag-name] | string; // string // Element list is just a list of elements -export type JsonMLElementList = Element[]; +export type JsonMLElementList = Array; + +export function isElement(maybeElement: unknown): maybeElement is Element { + return ( + typeof maybeElement === 'string' || + (Array.isArray(maybeElement) && + maybeElement.length >= 1 && + typeof maybeElement[0] === 'string') + ); +} + +function hasAttributes( + maybeElementWithAttributes: Element +): maybeElementWithAttributes is ElementWithAttributes { + return ( + Array.isArray(maybeElementWithAttributes) && + typeof maybeElementWithAttributes[1] === 'object' && + !Array.isArray(maybeElementWithAttributes[1]) + ); +} + +type ExplodedElement = { + tagName: TagName; + attributes?: Attributes; + children: Element[]; +}; + +export function explodeElement(element: Element): ExplodedElement { + if (typeof element === 'string') { + return { tagName: element, children: [] }; + } + + if (hasAttributes(element)) { + const [tagName, attributes, ...children] = element; + + return { tagName, attributes, children }; + } + + const [tagName, attributes, ...children] = element; + + return { + tagName, + children: [attributes, ...children].filter(isElement), + }; +} diff --git a/website/src/worker/normalizeResult.test.ts b/website/src/worker/normalizeResult.test.ts new file mode 100644 index 000000000..c46f51220 --- /dev/null +++ b/website/src/worker/normalizeResult.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from '@jest/globals'; +// @ts-expect-error immutable is loaded automatically +import * as Immutable from 'immutable'; +import normalizeResult from './normalizeResult'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- import does not work +const installDevTools = require('@jdeniau/immutable-devtools'); + +installDevTools(Immutable); + +// hack to get the formatters from immutable-devtools as they are not exported, but they modify the "global" variable +const immutableFormaters = globalThis.devtoolsFormatters; + +describe('normalizeResult', () => { + it('should return the correct object', () => { + const result = normalizeResult(immutableFormaters, { a: 1, b: 2 }); + + expect(result).toEqual(JSON.stringify({ a: 1, b: 2 })); + }); + + it('should return the correct object for a list', () => { + const result = normalizeResult(immutableFormaters, Immutable.List(['a'])); + + expect(result).toEqual([ + 'span', + [ + 'span', + [ + 'span', + { + style: + 'color: light-dark(rgb(232,98,0), rgb(255, 150, 50)); position: relative', + }, + 'List', + ], + ['span', '[1]'], + ], + [ + 'ol', + { + style: + 'list-style-type: none; padding: 0; margin: 0 0 0 12px; font-style: normal; position: relative', + }, + [ + 'li', + ['span', { style: 'color: light-dark( #881391, #D48CE6)' }, '0: '], + ['object', { object: 'a', config: undefined }], + ], + ], + ]); + }); + + it('should return the correct object for a deep list', () => { + const result = normalizeResult( + immutableFormaters, + Immutable.List([Immutable.List(['a'])]) + ); + + expect(result).toEqual([ + 'span', + [ + 'span', + [ + 'span', + { + style: + 'color: light-dark(rgb(232,98,0), rgb(255, 150, 50)); position: relative', + }, + 'List', + ], + ['span', '[1]'], + ], + [ + 'ol', + { + style: + 'list-style-type: none; padding: 0; margin: 0 0 0 12px; font-style: normal; position: relative', + }, + [ + 'li', + ['span', { style: 'color: light-dark( #881391, #D48CE6)' }, '0: '], + [ + 'span', + [ + 'span', + [ + 'span', + { + style: + 'color: light-dark(rgb(232,98,0), rgb(255, 150, 50)); position: relative', + }, + 'List', + ], + ['span', '[1]'], + ], + [ + 'ol', + { + style: + 'list-style-type: none; padding: 0; margin: 0 0 0 12px; font-style: normal; position: relative', + }, + [ + 'li', + [ + 'span', + { style: 'color: light-dark( #881391, #D48CE6)' }, + '0: ', + ], + ['object', { object: 'a', config: undefined }], + ], + ], + ], + ], + ], + ]); + }); +}); diff --git a/website/src/worker/normalizeResult.ts b/website/src/worker/normalizeResult.ts index 6ae1453ca..f341ed4de 100644 --- a/website/src/worker/normalizeResult.ts +++ b/website/src/worker/normalizeResult.ts @@ -1,4 +1,9 @@ -import { JsonMLElementList } from './jsonml-types'; +import { + Element, + explodeElement, + isElement, + JsonMLElementList, +} from './jsonml-types'; export interface DevToolsFormatter { header: (obj: unknown) => JsonMLElementList | null; @@ -6,31 +11,80 @@ export interface DevToolsFormatter { body: (obj: unknown) => JsonMLElementList | null; } -export interface ObjectForConsole { - header: JsonMLElementList | null; - body: JsonMLElementList | null; +function getFormatter( + immutableFormaters: Array, + result: unknown +) { + return immutableFormaters.find((formatter) => formatter.header(result)); } -// console.log(immutableFormaters) export default function normalizeResult( immutableFormaters: Array, result: unknown -): ObjectForConsole { - const formatter = immutableFormaters.find((formatter) => - formatter.header(result) - ); +): JsonMLElementList | Element { + const formatter = getFormatter(immutableFormaters, result); if (!formatter) { - return { - header: ['span', JSON.stringify(result)], - body: null, - }; + if (Array.isArray(result) && result[0] === 'object' && result[1]?.object) { + // handle special case for deep objects + const objectFormatter = getFormatter( + immutableFormaters, + result[1].object + ); + + if (objectFormatter) { + return normalizeResult(immutableFormaters, result[1].object); + } + } + + if (typeof result !== 'string' && isElement(result)) { + return normalizeElement(immutableFormaters, result); + } + + if (typeof result === 'string') { + return result; + } + + return JSON.stringify(result); + } + + const header = formatter.header(result) ?? []; + + let body: JsonMLElementList | null = formatter.hasBody(result) + ? formatter.body(result) + : null; + + if (body) { + body = body.map((item) => normalizeElement(immutableFormaters, item)); } - const body = formatter.hasBody(result) ? formatter.body(result) : null; + return ['span', header, body ?? []]; +} + +function normalizeElement( + immutableFormaters: Array, + item: Element | JsonMLElementList +): Element | JsonMLElementList { + if (!Array.isArray(item)) { + return item; + } + + if (!isElement(item)) { + return item; + } + + const explodedItem = explodeElement(item); + + const { tagName, attributes, children } = explodedItem; + + const normalizedChildren = children.map((child) => + normalizeResult(immutableFormaters, child) + ); + + if (attributes) { + // @ts-expect-error type is not perfect here because of self-reference + return [tagName, attributes, ...normalizedChildren]; + } - return { - header: formatter.header(result), - body, - }; + return [tagName, ...normalizedChildren]; }