From 96a82ff316f821a58fbdce059e17adadc65cef1c Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 23 Apr 2025 13:04:41 +0000 Subject: [PATCH 1/3] working draft of deep formatter --- eslint.config.mjs | 2 +- jest.config.mjs | 2 +- package-lock.json | 14 +++ package.json | 1 + website/src/repl/FormatterOutput.tsx | 23 +---- website/src/worker/jsonml-types.test.ts | 104 +++++++++++++++++++++ website/src/worker/jsonml-types.ts | 55 ++++++++++- website/src/worker/normalizeResult.test.ts | 90 ++++++++++++++++++ website/src/worker/normalizeResult.ts | 81 ++++++++++++---- 9 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 website/src/worker/jsonml-types.test.ts create mode 100644 website/src/worker/normalizeResult.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f9dcd489b..f75f25580e 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 1aaace83c6..4bbd44198b 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 f2b6158e85..3fd7f4fc65 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 baa0b4ab79..64c465f074 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 3d96966ec1..5e3681d0a3 100644 --- a/website/src/repl/FormatterOutput.tsx +++ b/website/src/repl/FormatterOutput.tsx @@ -8,17 +8,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: Array; }; 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 +22,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/worker/jsonml-types.test.ts b/website/src/worker/jsonml-types.test.ts new file mode 100644 index 0000000000..07426d8c42 --- /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 4fe7b0c6bb..1eadcef8e6 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 0000000000..897d3c67dc --- /dev/null +++ b/website/src/worker/normalizeResult.test.ts @@ -0,0 +1,90 @@ +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'); + +console.log(installDevTools); + +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({ + header: ['span', JSON.stringify({ a: 1, b: 2 })], + body: null, + }); + }); + + it('should return the correct object for a list', () => { + const result = normalizeResult(immutableFormaters, Immutable.List(['a'])); + + expect(result).toEqual({ + header: [ + 'span', + [ + 'span', + { + style: + 'color: light-dark(rgb(232,98,0), rgb(255, 150, 50)); position: relative', + }, + 'List', + ], + ['span', '[1]'], + ], + body: [ + '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({ + header: [ + 'span', + [ + 'span', + { + style: + 'color: light-dark(rgb(232,98,0), rgb(255, 150, 50)); position: relative', + }, + 'List', + ], + ['span', '[1]'], + ], + body: [ + '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 6ae1453cad..5e918bb9af 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,73 @@ export interface DevToolsFormatter { body: (obj: unknown) => JsonMLElementList | null; } -export interface ObjectForConsole { - header: JsonMLElementList | null; - body: JsonMLElementList | null; -} - // console.log(immutableFormaters) export default function normalizeResult( immutableFormaters: Array, - result: unknown -): ObjectForConsole { + result: unknown, + fromObject: boolean = false +): JsonMLElementList | Element { const formatter = immutableFormaters.find((formatter) => formatter.header(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 + + return normalizeResult(immutableFormaters, result[1].object, true); + } + + if (typeof result !== 'string' && isElement(result)) { + return normalizeElement(immutableFormaters, result); + } + + if (!fromObject) { + return result; + } + + // nothing is found, let's return the same object that had been unpacked + // let jsonml-html handle it + return ['object', { object: 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)); + } + + 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 body = formatter.hasBody(result) ? formatter.body(result) : null; + const explodedItem = explodeElement(item); + + const { tagName, attributes, children } = explodedItem; + // console.log(explodedItem); + + const normalizedChildren = children.map((child) => + normalizeResult(immutableFormaters, child) + ); + + if (attributes) { + return [tagName, attributes, ...normalizedChildren]; + } - return { - header: formatter.header(result), - body, - }; + return [tagName, ...normalizedChildren]; } From 145ad49d5a00505cdb2eff82167828289a039558 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 23 Apr 2025 13:06:21 +0000 Subject: [PATCH 2/3] avoid passing param --- website/src/worker/normalizeResult.ts | 32 +++++++++++++++------------ 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/website/src/worker/normalizeResult.ts b/website/src/worker/normalizeResult.ts index 5e918bb9af..321f787485 100644 --- a/website/src/worker/normalizeResult.ts +++ b/website/src/worker/normalizeResult.ts @@ -11,34 +11,38 @@ export interface DevToolsFormatter { body: (obj: unknown) => 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, - fromObject: boolean = false + result: unknown ): JsonMLElementList | Element { - const formatter = immutableFormaters.find((formatter) => - formatter.header(result) - ); + const formatter = getFormatter(immutableFormaters, result); if (!formatter) { if (Array.isArray(result) && result[0] === 'object' && result[1]?.object) { // handle special case for deep objects - - return normalizeResult(immutableFormaters, result[1].object, true); + 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 (!fromObject) { - return result; - } - - // nothing is found, let's return the same object that had been unpacked - // let jsonml-html handle it - return ['object', { object: result }]; + return result; } const header = formatter.header(result) ?? []; From f3fdce94d20bfa2a859ff38c55e51649b4f946c7 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Mon, 28 Apr 2025 06:44:36 +0000 Subject: [PATCH 3/3] Fix typescript --- website/src/repl/FormatterOutput.tsx | 3 +- website/src/repl/Repl.tsx | 16 +++--- website/src/worker/normalizeResult.test.ts | 57 ++++++++++++++++------ website/src/worker/normalizeResult.ts | 9 ++-- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/website/src/repl/FormatterOutput.tsx b/website/src/repl/FormatterOutput.tsx index 5e3681d0a3..a27b9d44d1 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,7 +9,7 @@ import { useEffect, useRef, type JSX } from 'react'; * The `jsonml-html` package can convert jsonml to HTML. */ type Props = { - output: Array; + output: JsonMLElementList | Element; }; export default function FormatterOutput({ output }: Props): JSX.Element { diff --git a/website/src/repl/Repl.tsx b/website/src/repl/Repl.tsx index 8b3c29d2b9..dc5f7868ec 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/normalizeResult.test.ts b/website/src/worker/normalizeResult.test.ts index 897d3c67dc..c46f512204 100644 --- a/website/src/worker/normalizeResult.test.ts +++ b/website/src/worker/normalizeResult.test.ts @@ -6,8 +6,6 @@ import normalizeResult from './normalizeResult'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- import does not work const installDevTools = require('@jdeniau/immutable-devtools'); -console.log(installDevTools); - installDevTools(Immutable); // hack to get the formatters from immutable-devtools as they are not exported, but they modify the "global" variable @@ -17,17 +15,15 @@ describe('normalizeResult', () => { it('should return the correct object', () => { const result = normalizeResult(immutableFormaters, { a: 1, b: 2 }); - expect(result).toEqual({ - header: ['span', JSON.stringify({ a: 1, b: 2 })], - body: null, - }); + 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({ - header: [ + expect(result).toEqual([ + 'span', + [ 'span', [ 'span', @@ -39,7 +35,7 @@ describe('normalizeResult', () => { ], ['span', '[1]'], ], - body: [ + [ 'ol', { style: @@ -51,7 +47,7 @@ describe('normalizeResult', () => { ['object', { object: 'a', config: undefined }], ], ], - }); + ]); }); it('should return the correct object for a deep list', () => { @@ -60,8 +56,9 @@ describe('normalizeResult', () => { Immutable.List([Immutable.List(['a'])]) ); - expect(result).toEqual({ - header: [ + expect(result).toEqual([ + 'span', + [ 'span', [ 'span', @@ -73,7 +70,7 @@ describe('normalizeResult', () => { ], ['span', '[1]'], ], - body: [ + [ 'ol', { style: @@ -82,9 +79,39 @@ describe('normalizeResult', () => { [ 'li', ['span', { style: 'color: light-dark( #881391, #D48CE6)' }, '0: '], - ['object', { object: 'a', config: undefined }], + [ + '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 321f787485..f341ed4de7 100644 --- a/website/src/worker/normalizeResult.ts +++ b/website/src/worker/normalizeResult.ts @@ -18,7 +18,6 @@ function getFormatter( return immutableFormaters.find((formatter) => formatter.header(result)); } -// console.log(immutableFormaters) export default function normalizeResult( immutableFormaters: Array, result: unknown @@ -42,7 +41,11 @@ export default function normalizeResult( return normalizeElement(immutableFormaters, result); } - return result; + if (typeof result === 'string') { + return result; + } + + return JSON.stringify(result); } const header = formatter.header(result) ?? []; @@ -73,13 +76,13 @@ function normalizeElement( const explodedItem = explodeElement(item); const { tagName, attributes, children } = explodedItem; - // console.log(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]; }