Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created November 23, 2023 10:27
Show Gist options
  • Save mizchi/0f04e04509dfd92ea5667d22bf0dccc5 to your computer and use it in GitHub Desktop.
Save mizchi/0f04e04509dfd92ea5667d22bf0dccc5 to your computer and use it in GitHub Desktop.
How to run a minimal front-end stack in Single-File for prototyping.

Original(japanese) https://zenn.dev/mizchi/articles/standalone-html-frontend

Mostly translated by deepl


How to run a minimal front-end stack in Single-File for prototyping.

Note: Do not use in production, tailwind is running in CDN mode and esm.sh builds scripts dynamically, so performance is not good.

Assumption

It is a combination of NativeESM + importmaps + esm.sh, etc.

We are talking about combining this with the new v135 feature in esm.sh to bundle tsx.

https://github.com/esm-dev/esm.sh/releases/tag/v135

esm.sh/run

  <!-- esm.sh/run -->
  <script type="module" src="https://esm.sh/run" defer></script>

  <!-- your code -->
  <script type="text/babel">
    // code here
  </script>

It is the same as babel/standalone which has been around for a long time.

I can run tsx with text/tsx, but... I can run tsx with text/tsx... but <script type=text/tsx> is not supported in vscode, so I can't highlight it, and there is currently no way to run inline TypeScript with type checking.

If all you want to do with this file scope is to expand JSX, text/babel is recommended since vscode supports text/babel.

You can also try it in Playground.

https://code.esm.sh/?template=run

Babel + React + Tailwind

A simple example

Set up the CDN tailwind and importmaps to pull react.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    import { createRoot } from "react-dom/client";
    const App = () => {
      return <div className="bg-red-400">
        Hello World
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

Save it as index.html in a suitable location and open it as a local file in Chrome. (e.g. open index.html)

It works.

!

This seems to be enough for a quick test of the react library.

shadcn-ui/react

For a more complex real-world example, we embed the code generated by shadcn-ui/react, which contains Tailwind + radix-ui.

shadcn-ui/ui: Beautifully designed components built with Radix UI and Tailwind CSS.

Tailwind configuration is written in the global tailwind.config file equivalent to tailwind.config.js.

  <script>
    tailwind.config = {/**/}
  </script>

Try Tailwind CSS using the Play CDN - Tailwind CSS

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style type="text/tailwindcss">
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    @layer base {
      :root {
        --background: 0 0% 100%;
        --foreground: 222.2 84% 4.9%;

        --card: 0 0% 100%;
        --card-foreground: 222.2 84% 4.9%;
    
        --popover: 0 0% 100%;
        --popover-foreground: 222.2 84% 4.9%;
    
        --primary: 222.2 47.4% 11.2%;
        --primary-foreground: 210 40% 98%;
    
        --secondary: 210 40% 96.1%;
        --secondary-foreground: 222.2 47.4% 11.2%;
    
        --muted: 210 40% 96.1%;
        --muted-foreground: 215.4 16.3% 46.9%;
    
        --accent: 210 40% 96.1%;
        --accent-foreground: 222.2 47.4% 11.2%;
    
        --destructive: 0 84.2% 60.2%;
        --destructive-foreground: 210 40% 98%;

        --border: 214.3 31.8% 91.4%;
        --input: 214.3 31.8% 91.4%;
        --ring: 222.2 84% 4.9%;
    
        --radius: 0.5rem;
      }
    
      .dark {
        --background: 222.2 84% 4.9%;
        --foreground: 210 40% 98%;
    
        --card: 222.2 84% 4.9%;
        --card-foreground: 210 40% 98%;
    
        --popover: 222.2 84% 4.9%;
        --popover-foreground: 210 40% 98%;
    
        --primary: 210 40% 98%;
        --primary-foreground: 222.2 47.4% 11.2%;
        --secondary: 217.2 32.6% 17.5%;
        --secondary-foreground: 210 40% 98%;
        --muted: 217.2 32.6% 17.5%;
        --muted-foreground: 215 20.2% 65.1%;
        --accent: 217.2 32.6% 17.5%;
        --accent-foreground: 210 40% 98%;
        --destructive: 0 62.8% 30.6%;
        --destructive-foreground: 210 40% 98%;
        --border: 217.2 32.6% 17.5%;
        --input: 217.2 32.6% 17.5%;
        --ring: 212.7 26.8% 83.9%;
      }
    }
 
    @layer base {
      * {
        @apply border-border;
      }
      body {
        @apply bg-background text-foreground;
      }
    }
  </style>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: {
            border: "hsl(var(--border))",
            input: "hsl(var(--input))",
            ring: "hsl(var(--ring))",
            background: "hsl(var(--background))",
            foreground: "hsl(var(--foreground))",
            primary: {
              DEFAULT: "hsl(var(--primary))",
              foreground: "hsl(var(--primary-foreground))",
            },
            secondary: {
              DEFAULT: "hsl(var(--secondary))",
              foreground: "hsl(var(--secondary-foreground))",
            },
            destructive: {
              DEFAULT: "hsl(var(--destructive))",
              foreground: "hsl(var(--destructive-foreground))",
            },
            muted: {
              DEFAULT: "hsl(var(--muted))",
              foreground: "hsl(var(--muted-foreground))",
            },
            accent: {
              DEFAULT: "hsl(var(--accent))",
              foreground: "hsl(var(--accent-foreground))",
            },
            popover: {
              DEFAULT: "hsl(var(--popover))",
              foreground: "hsl(var(--popover-foreground))",
            },
            card: {
              DEFAULT: "hsl(var(--card))",
              foreground: "hsl(var(--card-foreground))",
            },
          },
          borderRadius: {
            lg: "var(--radius)",
            md: "calc(var(--radius) - 2px)",
            sm: "calc(var(--radius) - 4px)",
          },
          keyframes: {
            "accordion-down": {
              from: { height: 0 },
              to: { height: "var(--radix-accordion-content-height)" },
            },
            "accordion-up": {
              from: { height: "var(--radix-accordion-content-height)" },
              to: { height: 0 },
            },
          },
          animation: {
            "accordion-down": "accordion-down 0.2s ease-out",
            "accordion-up": "accordion-up 0.2s ease-out",
          },
        },
      }
    }
  </script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client",
        "class-variance-authority": "https://esm.sh/class-variance-authority",
        "clsx": "https://esm.sh/clsx",
        "tailwind-merge": "https://esm.sh/tailwind-merge"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>

<body>
  <div id="root"></div>
  <script type="text/babel">
    import { forwardRef } from "react";
    import { createRoot } from "react-dom/client";
    import { cva } from "class-variance-authority";
    import { Slot } from "@radix-ui/react-slot";
    import { clsx } from "clsx";
    import { twMerge } from "tailwind-merge";

    export function cn(...inputs) {
      return twMerge(clsx(inputs));
    }

    const buttonVariants = cva(
      "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
      {
        variants: {
          variant: {
            default: "bg-primary text-primary-foreground hover:bg-primary/90",
            destructive:
              "bg-destructive text-destructive-foreground hover:bg-destructive/90",
            outline:
              "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
            secondary:
              "bg-secondary text-secondary-foreground hover:bg-secondary/80",
            ghost: "hover:bg-accent hover:text-accent-foreground",
            link: "text-primary underline-offset-4 hover:underline",
          },
          size: {
            default: "h-10 px-4 py-2",
            sm: "h-9 rounded-md px-3",
            lg: "h-11 rounded-md px-8",
            icon: "h-10 w-10",
          },
        },
        defaultVariants: {
          variant: "default",
          size: "default",
        },
      },
    );

    export const Button = forwardRef(
      ({ className, variant, size, asChild = false, ...props }, ref) => {
        const Comp = asChild ? Slot : "button";
        return (
          <Comp
            className={cn(buttonVariants({ variant, size, className }))}
            ref={ref}
            asChild={asChild}
            {...props}
          />
        );
      },
    );
    Button.displayName = "Button";

    const App = () => {
      return <div>
        <Button variant="outline">
          Hello World
        </Button>
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

It works.

It was quite complicated, but it still works. However, the preview speed is not that fast as far as I can see in the playground.

I had no problem this time, but there is no way for external libraries specified in plugins in tailwind.config.js to represent loading.

Devtools Source

Now that we are here, we want to complete the development environment in the browser.

Mount yourself in the Source tab of Devtools.

Done. Rewrite it and update it manually by reloading. (Can you auto-reload with self-monitoring if you try hard enough?)

Unfortunately, the Devtools built-in editor supports <script type="text/typescript">, but not <script type="text/tsx">.

<script type="text/tsx" src=". /main.tsx"></script> was also no good. If I had this, I could write in tsx (except for nested import destinations)...

Thx jexia_ (author of esm.sh)

I threw it on Twitter that I made a part other than esm.sh/run and the author of esm.sh told me about it. Thanks.

https://twitter.com/jexia_/status/1727069200072741122

Markdown のコードブロックでLSPを動かす VSCode 拡張を作った

Now if only I can find a way to make text/tsx highlighting not work in vscode, a way to make LSP work, and maybe even a way to format it, I can make the experience a little better. I think I can use my previous implementation of LSP in markdown code blocks.

I made a VSCode extension to run LSP in markdown code blocks

You may be able to use your own implementation of esm.sh/run to run LSP.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment