nathanjhood / ts-esbuild-react

A React starter project template with esbuild-scripts.
https://nathanjhood.github.io/ts-esbuild-react/
Other
0 stars 0 forks source link

HTML variable replacement #16

Closed nathanjhood closed 3 weeks ago

nathanjhood commented 2 months ago

I need an InterpolateHtmlPlugin for esbuild...

The is the Webpack plugin definition, written in CommonJS, that ships with react-scripts, which handles the var replacements (%PUBLIC_URL%) inside the public/index.html file of a standard React/Webpack project:

const escapeStringRegexp = require('escape-string-regexp');

class InterpolateHtmlPlugin {
  constructor(htmlWebpackPlugin, replacements) {
    this.htmlWebpackPlugin = htmlWebpackPlugin;
    this.replacements = replacements;
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('InterpolateHtmlPlugin', compilation => {
      this.htmlWebpackPlugin
        .getHooks(compilation)
        .afterTemplateExecution.tap('InterpolateHtmlPlugin', data => {
          // Run HTML through a series of user-specified string replacements.
          Object.keys(this.replacements).forEach(key => {
            const value = this.replacements[key];
            data.html = data.html.replace(
              new RegExp('%' + escapeStringRegexp(key) + '%', 'g'),
              value
            );
          });
        });
    });
  }
}

I need to adapt this to an esbuild plugin, written in Typescript to be transpiled to JS for distro.

Sure enough we can do a slow fs.read/fs.write of some sort, and even mark it as async. I'm wondering - influenced by an idea from expo - if perhaps the HTML file could be parsed as JSX, and then the transformation can be done in some sort of JS function on the interpreted JSX version of the HTML file...

The parsed JSX version of the HTML would look something like:

import * as React from "react";
import * as ReactDOM from "react-dom";

export const Index = () => {
  return (
    <html lang="en">
      <head>
      <meta charset="utf-8" />
      <link rel="icon" href={ process.env.PUBLIC_URL + "favicon.ico" } />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="theme-color" content="#000000" />
      <meta name="description" content="Web site created using ts-esbuild-react" />
      <link rel="apple-touch-icon" href={ process.env.PUBLIC_URL + "logo192.png" } />
      {
        /**
         * manifest.json provides metadata used when your web app is installed
         * on a user's mobile device or desktop.
         * See https://developers.google.com/web/fundamentals/web-app-manifest/
         */
      }
      <link rel="manifest" href={ process.env.PUBLIC_URL + "manifest.json" } />
      {
        /**
         * Notice the use of %PUBLIC_URL% in the tags above.
         * It will be replaced with the URL of the `public` folder during the build.
         * Only files inside the `public` folder can be referenced from the HTML.
         *
         * Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
         * work correctly both with client-side routing and a non-root public URL.
         * Learn how to configure a non-root public URL by running `npm run build`.
         */
      }
      <meta name="referrer" content="no-referrer" />
      <link rel="stylesheet" href={ process.env.PUBLIC_URL + "index.jcss" } />
      <title>React App</title>
    </head>
    <body>
      <noscript>
        We're sorry but this application doesn't work properly without Javascript
        enabled. Please enable it to continue.
      </noscript>
      <div id="root"></div>
        {
          /**
           * This HTML file is a template.
           * If you open it directly in the browser, you will see an empty page.
           *
           * You can add webfonts, meta tags, or analytics to this file.
           * The build step will place the bundled scripts into the <body> tag.
           *
           * To begin the development, run `npm start` or `yarn start`.
           * To create a production bundle, use `npm run build` or `yarn build`.
           */
        }

      <script type="module" src={ process.env.PUBLIC_URL + "index.js" }></script>
    </body>
  </html>
  )
}

export default Index;

Another option using the above JSX idea, is to just import the assets and pass them to the JSX, and let esbuild's loaders config work it out:

import * as React from "react";
import * as ReactDOM from "react-dom";

// defined in 'react-app-env.d.ts':
declare module "*.png" {
  const src: string;
  export default src;
}

// import assets...
import appleTouchIcon from './logo192.png';

const Index = () => {
  return (
    <html>
      <head>
        <!-- ... -->
        <link rel="apple-touch-icon" href={appleTouchIcon} />
        <!-- ... -->
      </head>
    </html>
  )
}

In either case, it would be useful opportunity to inject chunk hash names if necessary. Thinking about doing so makes me wonder if the JSX is probably the better idea, rather than parsing the HTML file.

Well this should be fun.

nathanjhood commented 2 months ago

found in the esbuild docs about the CSS loader:

If the generated output names are not straightforward (for example if you have added [hash] to the [entry names](https://esbuild.github.io/api/#entry-names) setting and the output file names have content hashes) then you will likely want to look up the generated output names in the metafile. To do this, first find the JS file by looking for the output with the matching entryPoint property. This file goes in the \<script> tag. The associated CSS file can then be found using the cssBundle property. This file goes in the \<link> tag.

nathanjhood commented 3 weeks ago

draft, in testing on esbuild-scripts:

(() => {
  return {
    name: 'html',
    setup(build) {
      const escapeStringRegexp = (str: string) => {
        str
          .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
          .replace(/-/g, '\\x2d');
      }
      // Run HTML through a series of user-specified string replacements.
      build.onLoad({ filter: /\.html$/ }, async (args) => {
        let html = await fs.promises.readFile(args.path, 'utf8');
        Object.keys(proc.env).forEach((key) => {
          const value = proc.env[key];
          if(value) html = html.replace(
            new RegExp('%' + escapeStringRegexp(key) + '%', 'g'),
            value
          );
        });

        return {
          contents: html,
          loader: 'file',
        };
      });
    },
  };
})()
nathanjhood commented 3 weeks ago

It's done:

const buildHTML = (options: { appHtml: string; appBuild: string }) => {
    let html = fs.readFileSync(options.appHtml, { encoding: 'utf8' });
    Object.keys(proc.env).forEach((key) => {
      const escapeStringRegexp = (str: string) => {
        return str
          .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
          .replace(/-/g, '\\x2d');
      };
      const value = proc.env[key];
      const htmlsrc = new RegExp('%' + escapeStringRegexp(key) + '%', 'g');

      if (value) html = html.replaceAll(htmlsrc, value);
    });
    return fs.writeFileSync(path.resolve(options.appBuild, 'index.html'), html);
  };

Now, I need to update this project to consume esbuild-scripts instead of carrying it's own scripts.

nathanjhood commented 3 weeks ago

Solved.