TryGhost/Ghost · error · ValidationError

Failed to convert HTML to Lexical

Error message

Failed to convert HTML to Lexical

What it means

Same HTML-to-Lexical conversion guard as pages, thrown by the posts input serializer when `source=html` and `htmlToLexicalConverter` throws. The post's HTML is converted to a Lexical JSON string; on failure a ValidationError wraps the underlying error. Used for HTML import into posts.

Source

Thrown at ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js:215

            defaultRelations(frame);
        }
    },

    add(apiConfig, frame, options = {add: true}) {
        debug('add');

        if (_.get(frame,'options.source')) {
            const html = frame.data.posts[0].html;

            if (frame.options.source === 'html' && !_.isEmpty(html)) {
                if (process.env.CI) {
                    console.time('htmlToLexicalConverter (post)'); // eslint-disable-line no-console
                }

                try {
                    frame.data.posts[0].lexical = JSON.stringify(lexical.htmlToLexicalConverter(html));
                } catch (err) {
                    throw new ValidationError({
                        message: tpl(messages.failedHtmlToLexical),
                        err
                    });
                }

                if (process.env.CI) {
                    console.timeEnd('htmlToLexicalConverter (post)'); // eslint-disable-line no-console
                }
            }
        }

        frame.data.posts[0] = url.forPost(Object.assign({}, frame.data.posts[0]), frame.options);

        // @NOTE: force adding post
        if (options.add) {
            frame.data.posts[0].type = 'post';
        }

View on GitHub (pinned to 47d8b0e2ad)

Solutions

  1. Pre-clean the HTML with a sanitizer before submitting the post.
  2. Reduce the HTML to a minimal subset to isolate the offending element.
  3. Examine the nested `err` returned by the API for the precise parse failure.
  4. Send Lexical JSON directly (omit `source=html`) if you control the editor format.

Example fix

// before
await api.posts.add({posts: [{html: rawHtml}], options: {source: 'html'}});

// after
const lexical = convertHtmlToLexicalClientSide(rawHtml); // or sanitize first
await api.posts.add({posts: [{lexical: JSON.stringify(lexical)}]})
Defensive patterns

Strategy: validation

Validate before calling

function isValidHtmlForConversion(html: string): boolean {
    try {
        new DOMParser().parseFromString(html, 'text/html');
        return true;
    } catch { return false; }
}

Try / catch

try {
    await api.posts.add({posts: [{html}], options: {source: 'html'}});
} catch (err) {
    if (/Failed to convert HTML to Lexical/i.test(JSON.stringify((err as any).response?.body))) {
        html = sanitizeHtml(html);
        await api.posts.add({posts: [{html}], options: {source: 'html'}});
    } else { throw err; }
}

Prevention

When it happens

Trigger: A post add/edit request carries `source=html` with a non-empty `html` field that the Lexical converter rejects. The serializer catches the converter exception and rethrows as a 422 ValidationError.

Common situations: Importing posts from external sources with malformed HTML; embedding unsupported markup (iframes, scripts stripped by the converter); encoding artifacts; converter library regression after an upgrade.

Related errors


AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13). Data as JSON: /api/errors/dcb192ed1adf7029. Report an issue: GitHub.