Move content with HTMLRewriter delete and append?

Hi everybody,

I am dynamically showing and hiding content using HTMLRewriter.

Now I am trying to move content from the top of the page to the bottom. Is that possible?

The below code is not working, the appended element is either undefined (with the commented out append_content = element) or [object Object] (with append_content = text from buffer):

                let buffer = ""
                let append_content
                class ChangeContent {
                    constructor(contentName) {
                      this.contentName = contentName
                    }
                    text(text) {
                        if (this.contentName === "remove") {
                            buffer += text.text
                            if (text.lastInTextNode) {
                                append_content = text
                            buffer = ''
                            }
                        }
                    }
                    element(element) {
                        if (this.contentName === "remove") {
                            // append_content = element
                            element.remove();
                        }
                        ...
                        else if (this.contentName === "append") {
                            element.append(append_content, { html: true })
                        }
                    }
                }

                response = new HTMLRewriter()
                      .on('div[class~="remove"]', new ChangeContent('remove'))
                      .on('div[class~="append"]', new ChangeContent('append'))
                      .transform(response)

Has anybody tried this before or even solved it? :slight_smile: Shouldn’t the buffer contain a string with the HTML code? If yes, how can I add it back into the document?

Don’t use globals to keep state for class instances (not just for this purpose, but ever). You get a data object on the request for the purpose of passing state around.

export async function onRequest(context) {
    const { request, env, data } = context;
    data.buffer = '';

    const res = await env.ASSETS.fetch(request);
    return new HTMLRewriter()
        .on('[class~="remove"]', new ChangeContent('remove', data))
        .on('[class~="append"]', new ChangeContent('append', data))
        .transform(res);
}

class ChangeContent {
    constructor(contentName, data) {
        this.data = data;
        this.contentName = contentName;
    }
    text(text) {
        if (this.contentName === "remove") {
            this.data.buffer += text.text;
            if (text.lastInTextNode) {
                this.data.append_content = this.data.buffer;
                this.data.buffer = '';
            }
        }
    }
    element(element) {
        if (this.contentName === "remove") {
            element.remove();
        }
        else if (this.contentName === "append") {
            element.append(this.data.append_content, { html: true })
        }
    }
}

I put the HTMLRewriter code in an onRequest handler so I could run it in Pages Functions, but the ChangeContent class should work as-is. Just take that data object, initialize the buffer string so you can append to it without throwing an error, and pass it to your handlers.

NOTE: Your append element must come after your remove element in the document, which I think you already knew but I just wanted to make sure. Also, as-is, you really can only have one set of remove and append elements, so you could just use the buffer directly. This is left as an exercise for the reader.

1 Like

Thank you very much for your response and the great advice, @i40west! :slight_smile:

I changed my code to the data object but unfortunately, data.append_content never seems to contain anything, not even an object.

Like you, I am using Cloudflare Pages. I actually have everything in a global _worker.js:

export default {
    async fetch(request, env, data) {
        data.buffer = ""
        const url = new URL(request.url)
        const path = url.pathname.slice(1)
        let response = await env.ASSETS.fetch(url)
        ...
        switch (true) {
            case(/.../.test(path): {
            ...
                class ChangeContent {
                    constructor(contentName, data) {
                      this.contentName = contentName
                      this.data = data
                    }
                    text(text) {
                        if (this.contentName === "remove") {
                            this.data.buffer += text.text;
                            if (text.lastInTextNode) {
                                this.data.append_content = this.data.buffer;
                                this.data.buffer = '';
                            }
                        }
                    }
                    element(element) {
                        if (this.contentName === "remove") {
                            element.remove();
                        }
                        else if (this.contentName === "append") {
                            element.append("<div><!--test-->" + this.data.append_content + "</div>", { html: true })
                        }
                    }
                }

                return new HTMLRewriter()
                      .on('div[class~="remove"]', new RemoveContent('remove', data))
                      .on('div[class~="append"]', new RemoveContent('append', data))
                      .transform(response)

I tried to use the playground to recreate your example but in the below code data seems to remain undefined:

addEventListener('fetch', event => {
  event.respondWith(onRequest(event.request))
})

async function onRequest(context) {
    const { request, env, data } = context;
    data.buffer = '';

    const res = '<html><body><div class="remove">moving to bottom</div><div class="append">append</div></body></html>'
    return new HTMLRewriter()
        .on('[class~="remove"]', new ChangeContent('remove', data))
        .on('[class~="append"]', new ChangeContent('append', data))
        .transform(res);
}

class ChangeContent {
    constructor(contentName, data) {
        this.data = data;
        this.contentName = contentName;
    }
    text(text) {
        if (this.contentName === "remove") {
            this.data.buffer += text.text;
            if (text.lastInTextNode) {
                this.data.append_content = this.data.buffer;
                this.data.buffer = '';
            }
        }
    }
    element(element) {
        if (this.contentName === "remove") {
            element.remove();
        }
        else if (this.contentName === "append") {
            element.after(this.data.append_content, { html: true })
        }
    }
}

I am therefore wondering if that could be the issue for my Pages function as well. Sorry if there is a simple reason for this issue, just starting out with Cloudflare functions and still a bit confused about the differences between the regular workers and Cloudflare Pages functions.

Oh, I think this is just a difference between Functions and plain Workers, and you’re using the plain Worker syntax in Pages, so I don’t think you get data.

You can create it on env to ensure you get the same one in possibly multiple calls to your handlers:

    env.data = {};
    env.data.buffer = '';

But in this simple case it should even work to just create it locally, inside your request handler, since you’re never passing it to a middleware or whatever:

    const data = {};
    data.buffer = '';

Also, .transform() wants a Response, not HTML text. So:

addEventListener('fetch', event => {
  event.respondWith(onRequest(event.request))
})

async function onRequest(request) {
    const data = {};
    data.buffer = '';

    const html = '<html><body><div class="remove">moving to bottom</div><div class="append">append</div></body></html>'
    const res = new Response(html);
    return new HTMLRewriter()
        .on('[class~="remove"]', new ChangeContent('remove', data))
        .on('[class~="append"]', new ChangeContent('append', data))
        .transform(res);
}

class ChangeContent {
    constructor(contentName, data) {
        this.data = data;
        this.contentName = contentName;
    }
    text(text) {
        if (this.contentName === "remove") {
            this.data.buffer += text.text;
            if (text.lastInTextNode) {
                this.data.append_content = this.data.buffer;
                this.data.buffer = '';
            }
        }
    }
    element(element) {
        if (this.contentName === "remove") {
            element.remove();
        }
        else if (this.contentName === "append") {
            element.after(this.data.append_content, { html: true })
        }
    }
}

This works in the Workers playground thingy for me.

1 Like

Thank you so much, @i40west, and apologies for the late response, I only had time to work again on this today.

I am using Module Worker syntax due to using _worker.js.

I ended up implementing env.data and hope I implemented it as intended:

export default {
    async fetch(request, env) {
        env.data = {}
        env.data.buffer = '';
        const url = new URL(request.url)
        ...
        let response = await env.ASSETS.fetch(url)
        ...
        switch (true) {
            case /.../.test(path): {
                class ChangeContent {
                    constructor(contentName, envdata) {
                      this.envdata = envdata;
                      this.contentName = contentName;
                    }
                    text(text) {
                        if (this.contentName === "remove") {
                            this.envdata.buffer += text.text;
                            if (text.lastInTextNode) {
                              this.envdata.more_content = this.envdata.buffer;
                                this.envdata.buffer = '';
                            }
                        }
                    }
                    element(element) {
                        if (this.contentName === "remove") {
                            element.remove();
                        }
                        else if (this.contentName === "append") {
                            element.after(this.envdata.more_content, { html: true })
                        }
                    }
                }
                return new HTMLRewriter()
                      .on('div[class~="remove"]', new ChangeContent('remove', env.data))
                      .on('div[class~="append"]', new ChangeContent('append', env.data))
                      .transform(response)

I got it to work on a simple test route but not on the actual route, unfortunately, although I don’t see any difference (but there must be one, of course).

However, I believe that, in order to move HTML code, I would have to iterate through the entire DOM and recreate it. But because I also have the option to simply duplicate the content in the origin and only use remove, I will probably do that for now and will try to over-engineer it further down the road. :smiley:

1 Like

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.