facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
17.5k stars 1.45k forks source link

Question: How to render a saved JSON editor state as static HTML? #2020

Closed sithembiso closed 2 years ago

sithembiso commented 2 years ago

Is it possible to save the editor state from JSON.stringify(editorState.toJSON()); and rehydrate it later but as read-only HTML instead displaying an editor? For example, using Lexical as a blog editor and rendering the output as a static post.

sithembiso commented 2 years ago

I just realised that there's a readOnly setting here:

<LexicalComposer initialConfig={{readOnly: true}}>
  <RichTextPlugin
    contentEditable={
      <ContentEditable />
    }
  />
</LexicalComposer>

Still trying to find out more.

skar-helper commented 2 years ago

The resulting HTML can be used outside of React context (as an email template), please give a hint how to render the state to static HTML.

It should be able to read static HTML as well in the future? To continue editing? Right?

sithembiso commented 2 years ago

This sounds like it should be simple to accomplish, but I've been looking inside the codebase, no ideas yet. Any input will be highly appreciated.

erbsutil commented 2 years ago

Any news here? I also need.

I need to export HTML content because I will render this content on another page, as HTML.

StormVanDerPol commented 2 years ago

While no helper functions exist at the moment, you could write your own lexical nodeMap to HTML serializer function in the meantime!

nicksrandall commented 2 years ago

Here is a (quick) example on how one might make their own serializer from a nodeMap.

const serializeToHtml = (editorState) => {
  const nodeMap = editorState._nodeMap.reduce((map, [key, value]) => {
    map.set(key, value);
    return map;
  }, new Map());

  const renderText = (node) => {
    switch (node.__format) {
      case 1: // bold
        return `<strong>${node.__text}</strong>`;
      case 1 << 1: // italic
        return `<em>${node.__text}</em>`;
      case 1 << 2: // strikethrough
        return `<s>${node.__text}</s>`;
      case 1 << 3: // underline
        return `<u>${node.__text}</u>`;
      case 1 << 4: // code
        return `<code>${node.__text}</code>`;
      case 1 << 5: // subscript
        return `<sub>${node.__text}</sub>`;
      case 1 << 6: // superscript
        return `<sup>${node.__text}</sup>`;
      default:
        console.log("unknown text format", node.__format);
        return node.__text;
    }
  };

  const renderStyle = (format) => {
    switch (format) {
      case 1: // left
        return `text-align: left;`;
      case 2: // center
        return `text-align: center;`;
      case 3: // right
        return `text-align: right;`;
      case 4: // justify
        return `text-align: justify;`;
      default: // justify
        console.log("unknown text-align", format);
        return ``;
    }
  };

  const renderNode = (key) => {
    const node = nodeMap.get(key);
    switch (node.__type) {
      case "root":
        return node.__children.map((k) => renderNode(k)).join("");
      case "heading":
      case "list":
        return `<${node.__tag}>${node.__children
          .map((k) => renderNode(k))
          .join("")}</${node.__tag}>`;
      case "text":
        return renderText(node);
      case "quote":
        return `<blockquote>${node.__children
          .map((k) => renderNode(k))
          .join("")}</blockquote>`;
      case "paragraph":
        return `<p${
          node.__format ? ` style="${renderStyle(node.__format)}"` : ``
        }>${node.__children.map((k) => renderNode(k)).join("")}</p>`;
      case "listitem":
        return `<li>${node.__children
          .map((k) => renderNode(k))
          .join("")}</li>`;
      case "link":
        return `<a href="${node.__url}">${node.__children
          .map((k) => renderNode(k))
          .join("")}</a>`;
      case "hashtag":
        return `<a href="${node.__text}">${node.__text}</a>`;
      case "emoji":
        return `<span class="${node.__className}">${node.__text}</span>`;
      default:
        console.log("unknown type", node.__type);
        return "";
    }
  };

  renderNode("root");
};
trueadm commented 2 years ago

I'd definitely avoid using the private methods of the editor state directly. Instead you can do editorState.read(()=> …) and do the logic in there with the actual public APIs. Not only will it be faster, it will also be backward compatible.

nicksrandall commented 2 years ago

This is meant to be with deserialized JSON of editor state. Does that advice still hold true?

trueadm commented 2 years ago

This is meant to be with deserialized JSON of editor state. Does that advice still hold true?

I'd use the @lexical/headless package in this case, then you get the best if both worlds

sithembiso commented 2 years ago

Here is a (quick) example on how one might make their own serializer from a nodeMap.

const serializeToHtml = (editorState) => {
  const nodeMap = editorState._nodeMap.reduce((map, [key, value]) => {
    map.set(key, value);
    return map;
  }, new Map());

  const renderText = (node) => {
    switch (node.__format) {
      case 1: // bold
        return `<strong>${node.__text}</strong>`;
      case 1 << 1: // italic
        return `<em>${node.__text}</em>`;
      case 1 << 2: // strikethrough
        return `<s>${node.__text}</s>`;
      case 1 << 3: // underline
        return `<u>${node.__text}</u>`;
      case 1 << 4: // code
        return `<code>${node.__text}</code>`;
      case 1 << 5: // subscript
        return `<sub>${node.__text}</sub>`;
      case 1 << 6: // superscript
        return `<sup>${node.__text}</sup>`;
      default:
        console.log("unknown text format", node.__format);
        return node.__text;
    }
  };

  const renderStyle = (format) => {
    switch (format) {
      case 1: // left
        return `text-align: left;`;
      case 2: // center
        return `text-align: center;`;
      case 3: // right
        return `text-align: right;`;
      case 4: // justify
        return `text-align: justify;`;
      default: // justify
        console.log("unknown text-align", format);
        return ``;
    }
  };

  const renderNode = (key) => {
    const node = nodeMap.get(key);
    switch (node.__type) {
      case "root":
        return node.__children.map((k) => renderNode(k)).join("");
      case "heading":
      case "list":
        return `<${node.__tag}>${node.__children
          .map((k) => renderNode(k))
          .join("")}</${node.__tag}>`;
      case "text":
        return renderText(node);
      case "quote":
        return `<blockquote>${node.__children
          .map((k) => renderNode(k))
          .join("")}</blockquote>`;
      case "paragraph":
        return `<p${
          node.__format ? ` style="${renderStyle(node.__format)}"` : ``
        }>${node.__children.map((k) => renderNode(k)).join("")}</p>`;
      case "listitem":
        return `<li>${node.__children
          .map((k) => renderNode(k))
          .join("")}</li>`;
      case "link":
        return `<a href="${node.__url}">${node.__children
          .map((k) => renderNode(k))
          .join("")}</a>`;
      case "hashtag":
        return `<a href="${node.__text}">${node.__text}</a>`;
      case "emoji":
        return `<span class="${node.__className}">${node.__text}</span>`;
      default:
        console.log("unknown type", node.__type);
        return "";
    }
  };

  renderNode("root");
};

This helped a lot. Thank you @nicksrandall.

TestCK commented 2 years ago

D

Here is a (quick) example on how one might make their own serializer from a nodeMap.

const serializeToHtml = (editorState) => {
  const nodeMap = editorState._nodeMap.reduce((map, [key, value]) => {
    map.set(key, value);
    return map;
  }, new Map());

  const renderText = (node) => {
    switch (node.__format) {
      case 1: // bold
        return `<strong>${node.__text}</strong>`;
      case 1 << 1: // italic
        return `<em>${node.__text}</em>`;
      case 1 << 2: // strikethrough
        return `<s>${node.__text}</s>`;
      case 1 << 3: // underline
        return `<u>${node.__text}</u>`;
      case 1 << 4: // code
        return `<code>${node.__text}</code>`;
      case 1 << 5: // subscript
        return `<sub>${node.__text}</sub>`;
      case 1 << 6: // superscript
        return `<sup>${node.__text}</sup>`;
      default:
        console.log("unknown text format", node.__format);
        return node.__text;
    }
  };

  const renderStyle = (format) => {
    switch (format) {
      case 1: // left
        return `text-align: left;`;
      case 2: // center
        return `text-align: center;`;
      case 3: // right
        return `text-align: right;`;
      case 4: // justify
        return `text-align: justify;`;
      default: // justify
        console.log("unknown text-align", format);
        return ``;
    }
  };

  const renderNode = (key) => {
    const node = nodeMap.get(key);
    switch (node.__type) {
      case "root":
        return node.__children.map((k) => renderNode(k)).join("");
      case "heading":
      case "list":
        return `<${node.__tag}>${node.__children
          .map((k) => renderNode(k))
          .join("")}</${node.__tag}>`;
      case "text":
        return renderText(node);
      case "quote":
        return `<blockquote>${node.__children
          .map((k) => renderNode(k))
          .join("")}</blockquote>`;
      case "paragraph":
        return `<p${
          node.__format ? ` style="${renderStyle(node.__format)}"` : ``
        }>${node.__children.map((k) => renderNode(k)).join("")}</p>`;
      case "listitem":
        return `<li>${node.__children
          .map((k) => renderNode(k))
          .join("")}</li>`;
      case "link":
        return `<a href="${node.__url}">${node.__children
          .map((k) => renderNode(k))
          .join("")}</a>`;
      case "hashtag":
        return `<a href="${node.__text}">${node.__text}</a>`;
      case "emoji":
        return `<span class="${node.__className}">${node.__text}</span>`;
      default:
        console.log("unknown type", node.__type);
        return "";
    }
  };

  renderNode("root");
};

This helped a lot. Thank you @nicksrandall.

This not working anymore, keep getting _nodeMap.reduce is not a function

cplepage commented 2 years ago

@TestCK, the _nodeMap is already a Map now so just change

const nodeMap = editorState._nodeMap.reduce((map, [key, value]) => {
    map.set(key, value);
    return map;
  }, new Map());

for

const nodeMap = editorState._nodeMap;

and you should be good!

Huge thanks @nicksrandall for the snippet!

Here's a currently working update :

const serializeToHtml = (editorState) => {
    if(!editorState)
        return "";

    const nodeMap = editorState._nodeMap;

    const renderText = (node) => {
        switch (node.__format) {
            case 1: // bold
                return `<strong>${node.__text}</strong>`;
            case 1 << 1: // italic
                return `<em>${node.__text}</em>`;
            case 1 << 2: // strikethrough
                return `<s>${node.__text}</s>`;
            case 1 << 3: // underline
                return `<u>${node.__text}</u>`;
            case 1 << 4: // code
                return `<code>${node.__text}</code>`;
            case 1 << 5: // subscript
                return `<sub>${node.__text}</sub>`;
            case 1 << 6: // superscript
                return `<sup>${node.__text}</sup>`;
            default:
                console.log("unknown text format", node.__format);
                return node.__text;
        }
    };

    const renderStyle = (format) => {
        switch (format) {
            case 1: // left
                return `text-align: left;`;
            case 2: // center
                return `text-align: center;`;
            case 3: // right
                return `text-align: right;`;
            case 4: // justify
                return `text-align: justify;`;
            default: // justify
                console.log("unknown text-align", format);
                return ``;
        }
    };

    const renderNode = (key) => {
        const node = nodeMap.get(key);
        switch (node.__type) {
            case "root":
                return node.__children.map((k) => renderNode(k)).join("");
            case "heading":
            case "list":
                return `<${node.__tag}>${node.__children
                    .map((k) => renderNode(k))
                    .join("")}</${node.__tag}>`;
            case "text":
                return renderText(node);
            case "quote":
                return `<blockquote>${node.__children
                    .map((k) => renderNode(k))
                    .join("")}</blockquote>`;
            case "paragraph":
                return `<p${
                    node.__format ? ` style="${renderStyle(node.__format)}"` : ``
                }>${node.__children.map((k) => renderNode(k)).join("")}</p>`;
            case "listitem":
                return `<li>${node.__children
                    .map((k) => renderNode(k))
                    .join("")}</li>`;
            case "link":
                return `<a href="${node.__url}">${node.__children
                    .map((k) => renderNode(k))
                    .join("")}</a>`;
            case "hashtag":
                return `<a href="${node.__text}">${node.__text}</a>`;
            case "emoji":
                return `<span class="${node.__className}">${node.__text}</span>`;
            default:
                console.log("unknown type", node.__type);
                return "";
        }
    };

    return renderNode("root");
};
trueadm commented 2 years ago

Please don't use the private parts of EditorState, especially _nodeMap, it might change in the future and is intended to be an implementation detail. It's far better to walk through your tree with editorState.read(() => … ).

cplepage commented 2 years ago

@trueadm Ok i'm getting the hang of it!

Here's a better snippet for the others

function serializeToHtml(editorState) {
    const renderText = (node) => {
        switch (node.getFormat()) {
            case 1: // bold
                return `<strong>${node.getTextContent()}</strong>`;
            case 1 << 1: // italic
                return `<em>${node.getTextContent()}</em>`;
            case 1 << 2: // strikethrough
                return `<s>${node.getTextContent()}</s>`;
            case 1 << 3: // underline
                return `<u>${node.getTextContent()}</u>`;
            case 1 << 4: // code
                return `<code>${node.getTextContent()}</code>`;
            case 1 << 5: // subscript
                return `<sub>${node.getTextContent()}</sub>`;
            case 1 << 6: // superscript
                return `<sup>${node.getTextContent()}</sup>`;
            default:
                return node.getTextContent();
        }
    };

    const renderStyle = (format) => {
        switch (format) {
            case 1: // left
                return `text-align: left;`;
            case 2: // center
                return `text-align: center;`;
            case 3: // right
                return `text-align: right;`;
            case 4: // justify
                return `text-align: justify;`;
            default: // justify
                console.log("unknown text-align", format);
                return ``;
        }
    };

    const renderNode = (node) => {
        switch (node.getType()) {
            case "root":
                return (node as RootNode).getChildren().map((k) => renderNode(k)).join("");
            case "heading":
                const headingNode = (node as HeadingNode);
                return `<${headingNode.getTag()}>${headingNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${headingNode.getTag()}>`;
            case "list":
                const listNode = (node as ListNode)
                return `<${listNode.getTag()}>${listNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${listNode.getTag()}>`;
            case "text":
                return renderText(node);
            case "quote":
                const quoteNode = (node as QuoteNode);
                return `<blockquote>${quoteNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</blockquote>`;
            case "paragraph":
                const paragraphNode = (node as ParagraphNode);
                return `<p${
                    paragraphNode.getFormat() ? ` style="${renderStyle(paragraphNode.getFormat())}"` : ``
                }>${paragraphNode.getChildren().map((k) => renderNode(k)).join("")}</p>`;
            case "listitem":
                const listItemNode = (node as ListItemNode)
                return `<li>${listItemNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</li>`;
            case "link":
                const linkNode = (node as LinkNode)
                return `<a href="${linkNode.getURL()}">${linkNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</a>`;
            default:
                console.log("unknown type", node.getType());
                return "";
        }
    };

    return new Promise(resolve => {
        editorState.read(() => {
           resolve(renderNode($getRoot()));
        });
    });
}

// usage
document.getElementById("output").innerHTML = await serializeToHtml(editorState)
rmindo commented 10 months ago

I made a PR about this serialization from root to HTML but the feature actually exists with $generateHtmlFromNodes without a second argument which is the selection (thanks to @acywatson).

it is the same case with JSON to HTML but you have to parse it after modifying the JSON then call the read method from parsed JSON with $generateHtmlFromNodes in it.

editorState.read(() => {
  const {root} = editorState.toJSON()

  delete root.children[0]
  const parsed = editor.parseEditorState(JSON.stringify({
    root: {
      ...root,
      children: root.children.filter((v: any) => v)
    }
  }))

  parsed.read(() => {
    console.log($generateHtmlFromNodes(editor))
  })
})

Hope this help

ljs19923 commented 10 months ago

@trueadm Ok i'm getting the hang of it!

Here's a better snippet for the others

function serializeToHtml(editorState) {
    const renderText = (node) => {
        switch (node.getFormat()) {
            case 1: // bold
                return `<strong>${node.getTextContent()}</strong>`;
            case 1 << 1: // italic
                return `<em>${node.getTextContent()}</em>`;
            case 1 << 2: // strikethrough
                return `<s>${node.getTextContent()}</s>`;
            case 1 << 3: // underline
                return `<u>${node.getTextContent()}</u>`;
            case 1 << 4: // code
                return `<code>${node.getTextContent()}</code>`;
            case 1 << 5: // subscript
                return `<sub>${node.getTextContent()}</sub>`;
            case 1 << 6: // superscript
                return `<sup>${node.getTextContent()}</sup>`;
            default:
                return node.getTextContent();
        }
    };

    const renderStyle = (format) => {
        switch (format) {
            case 1: // left
                return `text-align: left;`;
            case 2: // center
                return `text-align: center;`;
            case 3: // right
                return `text-align: right;`;
            case 4: // justify
                return `text-align: justify;`;
            default: // justify
                console.log("unknown text-align", format);
                return ``;
        }
    };

    const renderNode = (node) => {
        switch (node.getType()) {
            case "root":
                return (node as RootNode).getChildren().map((k) => renderNode(k)).join("");
            case "heading":
                const headingNode = (node as HeadingNode);
                return `<${headingNode.getTag()}>${headingNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${headingNode.getTag()}>`;
            case "list":
                const listNode = (node as ListNode)
                return `<${listNode.getTag()}>${listNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${listNode.getTag()}>`;
            case "text":
                return renderText(node);
            case "quote":
                const quoteNode = (node as QuoteNode);
                return `<blockquote>${quoteNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</blockquote>`;
            case "paragraph":
                const paragraphNode = (node as ParagraphNode);
                return `<p${
                    paragraphNode.getFormat() ? ` style="${renderStyle(paragraphNode.getFormat())}"` : ``
                }>${paragraphNode.getChildren().map((k) => renderNode(k)).join("")}</p>`;
            case "listitem":
                const listItemNode = (node as ListItemNode)
                return `<li>${listItemNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</li>`;
            case "link":
                const linkNode = (node as LinkNode)
                return `<a href="${linkNode.getURL()}">${linkNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</a>`;
            default:
                console.log("unknown type", node.getType());
                return "";
        }
    };

    return new Promise(resolve => {
        editorState.read(() => {
           resolve(renderNode($getRoot()));
        });
    });
}

// usage
document.getElementById("output").innerHTML = await serializeToHtml(editorState)

So this is the final solution? I would like to find a method who allow myself to display my JSON created from Lexical saved in my DB, to generate my pages. Why is it an async method? Thanks

ljs19923 commented 10 months ago

@sithembiso what did you do finally because we have the same problematic

ljs19923 commented 10 months ago

@erbsutil too if you found a solution :)

nikitastryuk commented 9 months ago

hey, anyone found good solution?

erikmartinessanches commented 4 months ago

This is meant to be with deserialized JSON of editor state. Does that advice still hold true?

I'd use the @lexical/headless package in this case, then you get the best if both worlds

Is that reasonable to use client side?

erikmartinessanches commented 3 months ago

@trueadm Ok i'm getting the hang of it! Here's a better snippet for the others

function serializeToHtml(editorState) {
    const renderText = (node) => {
        switch (node.getFormat()) {
            case 1: // bold
                return `<strong>${node.getTextContent()}</strong>`;
            case 1 << 1: // italic
                return `<em>${node.getTextContent()}</em>`;
            case 1 << 2: // strikethrough
                return `<s>${node.getTextContent()}</s>`;
            case 1 << 3: // underline
                return `<u>${node.getTextContent()}</u>`;
            case 1 << 4: // code
                return `<code>${node.getTextContent()}</code>`;
            case 1 << 5: // subscript
                return `<sub>${node.getTextContent()}</sub>`;
            case 1 << 6: // superscript
                return `<sup>${node.getTextContent()}</sup>`;
            default:
                return node.getTextContent();
        }
    };

    const renderStyle = (format) => {
        switch (format) {
            case 1: // left
                return `text-align: left;`;
            case 2: // center
                return `text-align: center;`;
            case 3: // right
                return `text-align: right;`;
            case 4: // justify
                return `text-align: justify;`;
            default: // justify
                console.log("unknown text-align", format);
                return ``;
        }
    };

    const renderNode = (node) => {
        switch (node.getType()) {
            case "root":
                return (node as RootNode).getChildren().map((k) => renderNode(k)).join("");
            case "heading":
                const headingNode = (node as HeadingNode);
                return `<${headingNode.getTag()}>${headingNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${headingNode.getTag()}>`;
            case "list":
                const listNode = (node as ListNode)
                return `<${listNode.getTag()}>${listNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</${listNode.getTag()}>`;
            case "text":
                return renderText(node);
            case "quote":
                const quoteNode = (node as QuoteNode);
                return `<blockquote>${quoteNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</blockquote>`;
            case "paragraph":
                const paragraphNode = (node as ParagraphNode);
                return `<p${
                    paragraphNode.getFormat() ? ` style="${renderStyle(paragraphNode.getFormat())}"` : ``
                }>${paragraphNode.getChildren().map((k) => renderNode(k)).join("")}</p>`;
            case "listitem":
                const listItemNode = (node as ListItemNode)
                return `<li>${listItemNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</li>`;
            case "link":
                const linkNode = (node as LinkNode)
                return `<a href="${linkNode.getURL()}">${linkNode.getChildren()
                    .map((k) => renderNode(k))
                    .join("")}</a>`;
            default:
                console.log("unknown type", node.getType());
                return "";
        }
    };

    return new Promise(resolve => {
        editorState.read(() => {
           resolve(renderNode($getRoot()));
        });
    });
}

// usage
document.getElementById("output").innerHTML = await serializeToHtml(editorState)

So this is the final solution? I would like to find a method who allow myself to display my JSON created from Lexical saved in my DB, to generate my pages. Why is it an async method? Thanks

Cool but this gives editorState.read is not a function is I pass in a stringifyed JSON. Or is the idea to pass in Nodes?

Chigozie-Gerald commented 1 month ago

Here's how I got mine working

I'd like to start with what I had in mind and what I eventually did

Here it goes (what I initially had in mind before finding a solution):

Client Side

Problems I faced (on the server side) and how I solved them

Solution: Created a headless editor

const initialConfig = {
    editorState: undefined,
    namespace: "Namespace name",
    nodes: [...Nodes],
    onError: (error: Error) => {
      throw error;
    },
    theme: EditorTheme,
};

const editor = createHeadlessEditor(initialConfig);

Solution: Turn your project into a workspace so you can import from the clientside code or use Lerna

Solution: Take the logic for handling HTML email template string extract into another folder that will be handled by webpack, then make output a library via the output.library option in webpack

const configurations = {
  // Other webpack configurations ...
  output: {
    // Other `webpack.output` configurations ...
    library: {
      name: "Any Name",
      type: "umd",
    },
  },
};

Solution: I used a loader: MiniCssExtractPlugin.loader which outputs CSS files and also it's associated plugin

const configurations = {
  // Other webpack configurations ...
  module: {
    rules: [
    // Other `module.rules` configurations ...
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader"
        ],
      }
    ],
  },
  plugins: [
    // Other `plugins` configurations ...
    new MiniCssExtractPlugin()
  ]
};

Solution: Configure MiniCssExtractPlugin to output only one CSS file via the optimization.splitChunks option

const configurations = {
  // Other webpack configurations ...
  plugins: [
    // Output one file named main.css
    new MiniCssExtractPlugin({filename: "main.css)
  ],
  // Group all CSS files into a single file
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: "styles",
          type: "css/mini-extract",
          chunks: "all",
          enforce: true,
        },
      },
    },
  }
};

Solution: Attach the htmlString generated from $generateHTMLFromNode to a JSDOM instance, read the CSS file using fs module in Nodejs, add the CSS content to a style tag in the document head and return document.documentElement.outerHTML to get the stringified HTML document. All these are done utilizing JSDOM methods

import { JSDOM } from "jsdom";
import fs from "fs";
import { resolve } from "path";

const makeHTMLTemplate = (html: string) => `
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>${html}</body>
</html>`;

const getCssStyles = async (document: Document) => {
    try {
      // File path relative to the package where this is to be called
      const cssFilePath = resolve("..file/path");
      const data = fs.readFileSync(cssFilePath, "utf8");
      const styleElement = document.createElement("style");
      styleElement.textContent = data;
      document.head.appendChild(styleElement);
    } catch (err) {
      console.error("Error reading CSS file:", err);
    }
  };

  const getHTMLString = async (
    htmlString: string | undefined
  ): Promise<string | undefined> => {
    let htmlStringWithStyle: string | undefined = undefined;
    try {
      if (htmlString) {
        htmlString = makeHTMLTemplate(htmlString);
        const dom = new JSDOM(htmlString);
        const document = dom.window.document;

        // Append styles to head
        getCssStyles(document);

        // Get the HTML document in stringified format
        htmlStringWithStyle = document.documentElement.outerHTML;

        return htmlStringWithStyle;
      } else {
        return htmlStringWithStyle;
      }
    } catch (err) {
      return htmlStringWithStyle;
    }
  };

  export const retrieveHTML = async (savedTemplateEditorState: string) => {
    try {
      const initialConfig = {
        editorState: undefined,
        namespace: "Namespace",
        // @ts-ignore
        nodes: [...Nodes],
        onError: (error: Error) => {
          throw error;
        },
        theme: EditorTheme,
      };

      const editor = createHeadlessEditor(initialConfig);
      const editorState = editor.parseEditorState(savedTemplateEditorState);
      editor.setEditorState(editorState);

      let htmlString: string | undefined = undefined;

      const dom = new JSDOM();
      // @ts-ignore
      global.window = dom.window;
      global.document = dom.window.document;
      global.DocumentFragment = dom.window.DocumentFragment;
      editorState.read(() => {
        htmlString = $generateHtmlFromNodes(editor, null);
      });

      const template = await getHTMLString(htmlString);
      return template;
    } catch (e) {
      console.log(e);
      throw e;
    }
  };

Solution: Somehow make the styles in the style tag to be parsed to inline css in the body of the HTML document

Solution: Use the npm package inline-css to convert the styles in the style tag to inline CSS via this simple method const html = await inlineCss(htmlStringWithStyle, { url: "." })

const getHTMLString = async (
    htmlString: string | undefined
  ): Promise<string | undefined> => {
    let htmlStringWithStyle: string | undefined = undefined;
    try {
      if (htmlString) {
        htmlString = makeHTMLTemplate(htmlString);
        const dom = new JSDOM(htmlString);
        const document = dom.window.document;

        // Append styles to head
        getCssStyles(document);

        // Get the HTML document in stringified format
        htmlStringWithStyle = document.documentElement.outerHTML;

        // Newly added snippet for inlining CSS >> Start
        // Inline the styles currently located in the style tag in the head to individual tags
        const html = await inlineCss(htmlStringWithStyle, { url: "." });

        // Return stringified HTML with inline styles
        htmlStringWithStyle = html;
        const docWithStyle = new JSDOM(htmlStringWithStyle).window.document;
        // Get all <style> elements in the head
        const styleElements = docWithStyle.head.querySelectorAll("style");

        // Loop through and remove each style element
        styleElements.forEach((element) => {
          if (element.parentNode) {
            element.parentNode.removeChild(element);
          }
        });

        htmlStringWithStyle = docWithStyle.documentElement.outerHTML;
        // Newly added snippet for inlining CSS >> Stop

        return htmlStringWithStyle;
      } else {
        return htmlStringWithStyle;
      }
    } catch (err) {
      return htmlStringWithStyle;
    }
  };

Here's what my webpack config file looks like (A huge chunk of the configuration was copied from Reactjs's config. I just made the tweaks that were relevant to me)

"use strict";

const fs = require("fs");
const path = require("path");
const webpack = require("webpack");
const resolve = require("resolve");
const PnpWebpackPlugin = require("pnp-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
const ESLintPlugin = require("eslint-webpack-plugin");
const ModuleNotFoundPlugin = require("react-dev-utils/ModuleNotFoundPlugin");
const ForkTsCheckerWebpackPlugin = require("react-dev-utils/ForkTsCheckerWebpackPlugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

const postcssNormalize = require("postcss-normalize");

const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";

const emitErrorsAsWarnings = process.env.ESLINT_NO_DEV_ERRORS === "true";
const disableESLintPlugin = process.env.DISABLE_ESLINT_PLUGIN === "true";

const imageInlineSizeLimit = parseInt(
  process.env.IMAGE_INLINE_SIZE_LIMIT || "10000"
);

// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
    const extension = moduleFileExtensions.find((extension) =>
      fs.existsSync(resolveFn(`${filePath}.${extension}`))
    );

    if (extension) {
      return resolveFn(`${filePath}.${extension}`);
    }

    return resolveFn(`${filePath}.js`);
};

const paths = {
    appPath: path.resolve("."),
    appBuild: path.resolve(buildPath),
    appIndexJs: resolveModule(path.resolve, "./index"),
    appSrc: path.resolve("."),
    appTsConfig: path.resolve("./tsconfig.json"),
    appNodeModules: path.resolve("../../node_modules"),
    publicUrlOrPath: "your/public/path",
  }

const useTypeScript = fs.existsSync(paths.appTsConfig);

// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

const hasJsxRuntime = (() => {
  if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") {
    return false;
  }

  try {
    require.resolve("react/jsx-runtime");
    return true;
  } catch (e) {
    return false;
  }
})();

const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    MiniCssExtractPlugin.loader,
    {
      loader: require.resolve("css-loader"),
      options: cssOptions,
    },
    {
      loader: require.resolve("postcss-loader"),
      options: {
        ident: "postcss",
        plugins: () => [
          require("postcss-flexbugs-fixes"),
          require("postcss-preset-env")({
            autoprefixer: {
              flexbox: "no-2009",
            },
            stage: 3,
          }),
          postcssNormalize(),
        ],
        sourceMap: true,
      },
    },
  ].filter(Boolean);
  if (preProcessor) {
    loaders.push(
      {
        loader: require.resolve("resolve-url-loader"),
        options: {
          sourceMap: true,
          root: paths.appSrc,
        },
      },
      {
        loader: require.resolve(preProcessor),
        options: {
          sourceMap: true,
        },
      }
    );
  }
  return loaders;
};

/** @type {import('webpack').Configuration} */
const configurations = {
  mode: "development",
  node: {
    global: true,
  },
  devtool: false,
  target: "node",
  entry: paths.appIndexJs,
  externals: [
    "lexical",
    "next",
    "react",
    "jsdom",
    /^@lexical\/.+$/,
    /^next\/.+$/,
    "fs",
    "inline-css",
  ],
  output: {
    path: paths.appBuild,
    pathinfo: true,
    filename: "static/js/bundle.js",
    globalObject: "this",
    library: {
      name: "Library Name",
      type: "umd",
    },
    umdNamedDefine: true,
    publicPath: paths.publicUrlOrPath,
    devtoolModuleFilenameTemplate: (info) =>
      path.resolve(info.absoluteResourcePath).replace(/\\/g, "/"),
  },
  infrastructureLogging: {
    level: "verbose",
    appendOnly: true,
  },
  resolve: {
    symlinks: true,
    modules: ["node_modules", paths.appNodeModules],
    extensions: paths.moduleFileExtensions
      .map((ext) => `.${ext}`)
      .filter((ext) => useTypeScript || !ext.includes("ts")),
    alias: {
      "@": "../..",
    },
    plugins: [
      PnpWebpackPlugin,
    ],
  },
  module: {
    strictExportPresence: true,
    rules: [
      { parser: { requireEnsure: false } },
      {
        oneOf: [
          {
            test: [/\.avif$/],
            loader: require.resolve("url-loader"),
            options: {
              limit: imageInlineSizeLimit,
              mimetype: "image/avif",
              name: "static/media/[name].[hash:8].[ext]",
            },
          },
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            loader: require.resolve("url-loader"),
            options: {
              limit: imageInlineSizeLimit,
              name: "static/media/[name].[hash:8].[ext]",
            },
          },
          {
            test: /\.(js|mjs|jsx|ts|tsx)$/,
            loader: require.resolve("babel-loader"),
            options: {
              customize: require.resolve(
                "babel-preset-react-app/webpack-overrides"
              ),
              presets: [
                [
                  require.resolve("babel-preset-react-app"),
                  {
                    runtime: hasJsxRuntime ? "automatic" : "classic",
                  },
                ],
              ],

              plugins: [
                [
                  require.resolve("babel-plugin-named-asset-import"),
                  {
                    loaderMap: {
                      svg: {
                        ReactComponent:
                          "@svgr/webpack?-svgo,+titleProp,+ref![path]",
                      },
                    },
                  },
                ],
              ].filter(Boolean),
              cacheDirectory: true,
              cacheCompression: false,
            },
          },
          {
            test: /\.(js|mjs)$/,
            exclude: /@babel(?:\/|\\{1,2})runtime/,
            loader: require.resolve("babel-loader"),
            options: {
              babelrc: false,
              configFile: false,
              compact: false,
              presets: [
                [
                  require.resolve("babel-preset-react-app/dependencies"),
                  { helpers: true },
                ],
              ],
              sourceType: "unambiguous",
              cacheDirectory: true,
              cacheCompression: false,
              sourceMaps: shouldUseSourceMap,
              inputSourceMap: shouldUseSourceMap,
            },
          },
          {
            test: cssRegex,
            exclude: cssModuleRegex,
            use: getStyleLoaders({
              importLoaders: 1,
              sourceMap: true,
            }),
            sideEffects: true,
          },
          {
            test: cssModuleRegex,
            use: getStyleLoaders({
              importLoaders: 1,
              sourceMap: true,
              modules: {
                getLocalIdent: getCSSModuleLocalIdent,
              },
            }),
          },
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: getStyleLoaders(
              {
                importLoaders: 3,
                sourceMap: true,
              },
              "sass-loader"
            ),
            sideEffects: true,
          },
          {
            test: sassModuleRegex,
            use: getStyleLoaders(
              {
                importLoaders: 3,
                sourceMap: true,
                modules: {
                  getLocalIdent: getCSSModuleLocalIdent,
                },
              },
              "sass-loader"
            ),
          },
          {
            test: /\b_interop\w*\.cjs\b/,
            use: [
              {
                loader: require.resolve("babel-loader"),
                options: {
                  sourceType: "module",
                },
              },
            ],
          },
          {
            loader: require.resolve("file-loader"),
            exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
            options: {
              name: "static/media/[name].[hash:8].[ext]",
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: "main.css", ignoreOrder: true }),
    new ModuleNotFoundPlugin(paths.appPath),
    new webpack.ProvidePlugin({
        /**
         * Create these files and export window, document and navigator from them respectively
         * 
         * For example in WINDOW.js:
         * const window = new JSDOM().window
         * module.exports = window
         * 
         * In DOCUMENT.js
         * const window = require("./WINDOW.js")
         * module.exports = window.document
         * 
         * In NAVIGATOR.js
         * const window = require("./WINDOW.js")
         * module.exports = window.navigator
         */
      window: path.resolve(__dirname, "jsdom/WINDOW.js"),
      document: path.resolve(__dirname, "jsdom/DOCUMENT.js"),
      navigator: path.resolve(__dirname, "jsdom/NAVIGATOR.js"),
    }),
    new ForkTsCheckerWebpackPlugin({
      typescript: resolve.sync("typescript", {
        basedir: paths.appNodeModules,
      }),
      async: true,
      checkSyntacticErrors: true,
      resolveModuleNameModule: process.versions.pnp
        ? `${__dirname}/pnpTs.js`
        : undefined,
      resolveTypeReferenceDirectiveModule: process.versions.pnp
        ? `${__dirname}/pnpTs.js`
        : undefined,
      tsconfig: paths.appTsConfig,
      reportFiles: [],
      silent: true,
    }),
    !disableESLintPlugin &&
      new ESLintPlugin({
        extensions: ["js", "mjs", "jsx", "ts", "tsx"],
        formatter: require.resolve("react-dev-utils/eslintFormatter"),
        eslintPath: require.resolve("eslint"),
        failOnError: !emitErrorsAsWarnings,
        context: paths.appSrc,
        cache: true,
        cacheLocation: path.resolve(
          paths.appNodeModules,
          ".cache/.eslintcache"
        ),
        cwd: paths.appPath,
        resolvePluginsRelativeTo: __dirname,
        baseConfig: {
          extends: [require.resolve("eslint-config-react-app/base")],
          rules: {
            ...(!hasJsxRuntime && {
              "react/react-in-jsx-scope": "error",
            }),
          },
        },
      }),
    new CleanWebpackPlugin(),
  ].filter(Boolean),
  performance: false,
  cache: {
    type: "filesystem",
  },
  optimization: {
    minimizer: [new CssMinimizerPlugin()],
    minimize: true,
    splitChunks: {
      cacheGroups: {
        styles: {
          name: "styles",
          type: "css/mini-extract",
          chunks: "all",
          enforce: true,
        },
      },
    },
  },
};

module.exports = configurations;
satbirkira commented 1 month ago

Official tutorial for Lexical -> HTML