{ Adrian.Matías Quezada }

Generate HTML pages from TSX with Deno

I've been working on a new version of this site with a clear idea:

And after some months of work I found myself taking the same unspoken assuption that I recently wrote for my friend @Shakira who helped me with this beautiful design 🙌

I will animate the header somehow but with CSS only, no room for JS in the site of JS guy. That's the message 🤣

That's it.

The post can end here.

But I have more to write:

That means...

And... I need to try [Deno], it looks amazing, it sounds amazing and you know you'll find issues when you start using it but what may those be?

Here's the trip to generate HTML pages from TSX with Deno:

// Versions included for important reasons! 🥲
import React from 'https://esm.sh/[email protected]'
import { renderToStaticMarkup } from 'https://esm.sh/[email protected]/server';

// This is our only component
function MyComponent() {
  return <p>This is my page!<p>
}

// renderToStaticMarkup does the magic
const html = renderToStaticMarkup(<MyComponent />);

// send the resulting HTML to the program output
console.log(html);

"But Matias! want this to read from / written to the disk! not from the code itself!"

// main.tsx
import React from 'https://esm.sh/[email protected]'
import { renderToStaticMarkup } from 'https://esm.sh/[email protected]/server';

// we read arguments
const [input, output] = Deno.args;

// input/output may be relative to current working directory
const cwd = `file://${Deno.cwd()}/`;

const input_fullpath = new URL(input, cwd).pathname
const output_fullpath = new URL(output, cwd).pathname

// Import .tsx file containing the page
const inputModule = await import(input_fullpath);
const Page = inputModule.default;

const html = renderToStaticMarkup(<Page randomProp="yay" />);

await Deno.writeTextFile(output_fullpath, html);
// MyPage.tsx
import React from 'https://esm.sh/[email protected]'

export default () => <p>This is my page!<p>
deno run \
  --allow-read=. \
  --allow-write=. \
  --allow-net=https://esm.sh \
  ./main.tsx \
  ./MyPage.tsx ./MyPage.html

Permissions explained:

Ok but we can make this better, we don't want to pass file by file, we want to give it a folder and for it to generate another one with same structure, if only we had a...

export async function getFilesRecursively(currentPath: string) {
  const names: string[] = [];

  for await (const dirEntry of Deno.readDir(currentPath)) {
    const entryPath = `${root}/${dirEntry.name}`;

    if (dirEntry.isDirectory) {
      names.push(...(await getFilesRecursively(entryPath)).sort());
    } else {
      names.push(entryPath);
    }
  }

  return names;
}

I'm sure that's part of esm.sh/std somewhere. Now let's update the main.tsx we created above:

// main.tsx
import React from 'https://esm.sh/[email protected]'
import { renderToStaticMarkup } from 'https://esm.sh/[email protected]/server';

// Import function to get the name of the directory of a file
import { dirname } from 'https://deno.land/[email protected]/path/mod.ts';

const [input, output] = Deno.args;

// quick helper function is there something in the deno.land/std like this?
const relativeToCwd = (target: string) => new URL(target, `file://${Deno.cwd()}/`);

const input_dir = relativeToCwd(Deno.args[0]);
const output_dir = relativeToCwd(Deno.args[1]);

for (const file of await getFilesRecursively(input_dir)) {
  // Import .tsx file containing the page
  const inputModule = await import(input_fullpath);
  const Page = inputModule.default;

  const html = renderToStaticMarkup(<Page randomProp="yay" />);

  // ./input/mydir/myfile.tsx becomes
  // ./output/mydir/myfile.html
  const destination = file
    .replace(input_dir, output_dir)
    .replace(/.tsx$/, '.html');

  // This creates all folders required
  // Also if the folder is already there it doesn't throw an exception :)
  await Deno.mkdir(dirname(destination), { recursive: true });

  // Write generated HTML to disk
  await Deno.writeTextFile(destination, html);
}

At times you may find it useful to remove the output directory before you start writing to it... I do this which is not beautiful but does the job 🤷‍♀️

try {
  // This fuction does throw an error even with `{ recursive: true }` which kind of makes sense
  await Deno.remove(output, { recursive: true });
// deno-lint-ignore no-empty
} catch {}

From this you can start creating your site with JS knowing no JS will be rendered in the browser.

// input/index.tsx
import React from 'https://esm.sh/[email protected]'

// css set aside in a variable because { } would mess with React's JSX
const styles = `
  .body { margin: 0 }
`

export default () => (
  <html lang="en">
    <head>
      <meta charSet="UTF-8" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>My website!</title>
      <style>{styles}</style>
    </head>
    <body>
      <p>This is my index!<p>
    </body>
  </html>
)
deno run \
  --allow-read=. \
  --allow-write=. \
  --allow-net=https://esm.sh \
  ./main.tsx \
  ./input ./output

And that would generate a file like this:

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My website!</title>
    <style>
      .body { margin: 0 }
    </style>
  </head>
  <body>
    <p>This is my index!<p>
  </body>
</html>

"But Matias, you went through all that husle to get a slightly more complex way of writing HTML?"

Yes, that's exactly it, thanks for noticing. Now we have components and can componentise the sht out of those pages and since we're actually importing the files through import() all dependencies would be properly managed by Deno! Not just that, a Javascript environment means we can do all sort of things like processing Markdown before the generation of the page, but that's another story.

Other things that I learned along the way and would love to write about: