Skip to content

Commit

Permalink
feat: Refactor compile bun macro for efficiency
Browse files Browse the repository at this point in the history
Bun v1.0.20 now supports passing a string directly into `HTMLRewriter().transform`, which we use to avoid a bunch of unnecessary overhead.

Also reduce some var allocations.
  • Loading branch information
maxmilton committed Dec 25, 2023
1 parent 797c175 commit c06cd1e
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 84 deletions.
4 changes: 2 additions & 2 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export {};
console.time('build');

// Minified browser bundle which includes "regular mode" functions, utils, and store.
const out = await Bun.build({
const out1 = await Bun.build({
entrypoints: ['src/browser.ts'],
outdir: 'dist',
target: 'browser',
Expand Down Expand Up @@ -49,4 +49,4 @@ const out5 = await Bun.build({
});

console.timeEnd('build');
console.log(out, out2, out3, out4, out5);
console.log(out1, out2, out3, out4, out5);
210 changes: 128 additions & 82 deletions src/runtime/macro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,105 +26,151 @@ export interface CompileOptions {
* @param template - HTML template string.
* @param options - Compile options.
*/
export async function compile(
export function compile(
template: string,
{ keepComments, keepSpaces }: CompileOptions = {},
// @ts-expect-error - Bun macros always result in synchronous inlined data.
): { html: string; k: readonly string[]; d: readonly number[] } {
const rewriter = new HTMLRewriter();
const k: string[] = [];
const d: number[] = [];
let distance = 0;
let whitespaceSensitiveBlock = false;
let root: boolean | undefined;

rewriter.on('*', {
element(node) {
if (!root) {
if (root === undefined) {
root = true;
node.onEndTag(() => {
root = false;
});
} else {
// eslint-disable-next-line no-console
console.error(
'Expected template to have a single root element:',
template,
);
const html = new HTMLRewriter()
.on('*', {
element(node) {
if (!root) {
if (root === undefined) {
root = true;
node.onEndTag(() => {
root = false;
});
} else {
// eslint-disable-next-line no-console
console.error(
'Expected template to have a single root element:',
template,
);
}
}
}

if (node.tagName === 'pre' || node.tagName === 'code') {
whitespaceSensitiveBlock = true;
node.onEndTag(() => {
whitespaceSensitiveBlock = false;
});
}
for (const [name] of node.attributes) {
if (name[0] === '@') {
k.push(name.slice(1));
d.push(distance);
distance = 0;
node.removeAttribute(name);
break;
if (node.tagName === 'pre' || node.tagName === 'code') {
whitespaceSensitiveBlock = true;
node.onEndTag(() => {
whitespaceSensitiveBlock = false;
});
}
}
distance++;
},
// This text handler is invoked twice for each Text node: first with the
// actual text, then with an empty last chunk. This behaviour stems from
// the fact that the Response provided to HTMLRewriter.transform() is not
// streamed; otherwise, there could be multiple chunks before the last one.
text(chunk) {
if (!chunk.lastInTextNode) {
const text = chunk.text.trim();
if (!text) {
if (!whitespaceSensitiveBlock) {
chunk.remove();
for (const [name] of node.attributes) {
if (name[0] === '@') {
k.push(name.slice(1));
d.push(distance);
distance = 0;
node.removeAttribute(name);
break;
}
return;
}
if (text[0] === '@') {
k.push(text.slice(1));
d.push(distance);
distance = 0;
// replace with single space which renders a Text node at runtime
chunk.replace(' ', { html: true });
} else if (!whitespaceSensitiveBlock) {
// reduce any whitespace to a single space
chunk.replace((keepSpaces ? chunk.text : text).replace(/\s+/g, ' '), {
html: true,
});
}
distance++;
}
},
comments(node) {
if (keepComments) {
// TODO: Add documentation that the build/runtime mode also supports
// using comments as refs. Requires the keepComments option to be true.
const text = node.text.trim();
if (text[0] === '@') {
k.push(text.slice(1));
d.push(distance);
distance = 0;
// TODO: Use empty comment once lol-html supports it (less alloc than node.replace)
// node.text = '';
// TODO: use node.replace() once lol-html fixes it for comments
// node.replace('<!---->', { html: true });
},
// This text handler is invoked twice for each Text node: first with the
// actual text, then with an empty last chunk. This behaviour stems from
// the fact that the data provided to HTMLRewriter.transform() can be
// streamed; where the last empty chunk signals the end of the text.
text(chunk) {
if (!chunk.lastInTextNode) {
const text = chunk.text.trim();
if (!text) {
if (!whitespaceSensitiveBlock) {
chunk.remove();
}
return;
}
if (text[0] === '@') {
k.push(text.slice(1));
d.push(distance);
distance = 0;
// replace with single space which renders a Text node at runtime
chunk.replace(' ', { html: true });
} else if (!whitespaceSensitiveBlock) {
// reduce any whitespace to a single space
chunk.replace(
(keepSpaces ? chunk.text : text).replace(/\s+/g, ' '),
{
html: true,
},
);
}
distance++;
}
},
comments(node) {
if (keepComments) {
// TODO: Add documentation that the build/runtime mode also supports
// using comments as refs. Requires the keepComments option to be true.
const text = node.text.trim();
if (text[0] === '@') {
k.push(text.slice(1));
d.push(distance);
distance = 0;
// TODO: Use empty comment once lol-html supports it (less alloc than node.replace)
// node.text = '';
// TODO: use node.replace() once lol-html fixes it for comments
// node.replace('<!---->', { html: true });
node.remove();
node.after('<!---->', { html: true });
}
distance++;
} else {
node.remove();
node.after('<!---->', { html: true });
}
distance++;
} else {
node.remove();
}
},
});

const res = rewriter.transform(new Response(template.trim()));
const html = await res.text();
},
})
.transform(template.trim());

return { html, k, d };
}

// FIXME: Decide whether to keep the minifyHTML macro or not.

// /**
// * Bun macro which minifies whitespace in a HTML string at build-time.
// * @param html - Static HTML code string.
// * @param options - Compile options.
// */
// export function minifyHTML(
// html: string,
// { keepComments, keepSpaces }: CompileOptions = {},
// ): string {
// let whitespaceSensitiveBlock = false;
//
// return new HTMLRewriter()
// .on('*', {
// element(node) {
// if (node.tagName === 'pre' || node.tagName === 'code') {
// whitespaceSensitiveBlock = true;
// node.onEndTag(() => {
// whitespaceSensitiveBlock = false;
// });
// }
// },
// text(chunk) {
// // see above explanation
// if (!chunk.lastInTextNode) {
// const text = chunk.text.trim();
// if (!text && !whitespaceSensitiveBlock) {
// chunk.remove();
// } else if (!whitespaceSensitiveBlock) {
// // reduce any whitespace to a single space
// chunk.replace(
// (keepSpaces ? chunk.text : text).replace(/\s+/g, ' '),
// );
// }
// }
// },
// comments(node) {
// if (!keepComments) {
// node.remove();
// }
// },
// })
// .transform(html.trim());
// }

0 comments on commit c06cd1e

Please sign in to comment.