-
-
- Playground
-
+
@@ -158,3 +179,52 @@ const ThemeSelector: FC = () => {
);
};
+
+const ShareButton: FC = () => {
+ const $code = useStore((state) => state.code);
+ const [isCopied, setIsCopied] = useState(() => false);
+ const timeoutId = useRef>(undefined);
+
+ const onShare = useCallback(async () => {
+ try {
+ const { id } = await rpc.parameters
+ .$post({ json: { code: $code } })
+ .then((res) => res.json());
+
+ const { protocol, host } = window.location;
+ window.navigator.clipboard.writeText(
+ `${protocol}//${host}/parameters/${id}`,
+ );
+
+ setIsCopied(() => true);
+ } catch (e) {
+ console.error(e);
+ }
+ }, [$code]);
+
+ useEffect(() => {
+ if (!isCopied) {
+ return;
+ }
+
+ clearTimeout(timeoutId.current);
+ const id = setTimeout(() => {
+ setIsCopied(() => false);
+ }, 1000);
+ timeoutId.current = id;
+
+ return () => clearTimeout(timeoutId.current);
+ }, [isCopied]);
+
+ return (
+
+
+
+
+ Copied to clipboard
+
+ );
+};
diff --git a/src/Editor.tsx b/src/client/Editor.tsx
similarity index 81%
rename from src/Editor.tsx
rename to src/client/Editor.tsx
index a5a953e..5a3e5af 100644
--- a/src/Editor.tsx
+++ b/src/client/Editor.tsx
@@ -1,15 +1,19 @@
-import { Button } from "@/components/Button";
+import { Button } from "@/client/components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
-} from "@/components/DropdownMenu";
-import { ResizablePanel } from "@/components/Resizable";
-import * as Tabs from "@/components/Tabs";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip";
-import { useStore } from "@/store";
+} from "@/client/components/DropdownMenu";
+import { ResizablePanel } from "@/client/components/Resizable";
+import * as Tabs from "@/client/components/Tabs";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/client/components/Tooltip";
+import { useStore } from "@/client/store";
import {
BookIcon,
CheckIcon,
@@ -31,6 +35,8 @@ import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-hcl";
import "prismjs/themes/prism.css";
import { cn } from "@/utils/cn";
+import { rpc } from "@/utils/rpc";
+import { useParams } from "react-router";
// Adds line numbers to the highlight.
const hightlightWithLineNumbers = (input: string, language: unknown) =>
@@ -43,6 +49,7 @@ const hightlightWithLineNumbers = (input: string, language: unknown) =>
.join("\n");
export const Editor: FC = () => {
+ const params = useParams();
const $code = useStore((state) => state.code);
const $setCode = useStore((state) => state.setCode);
@@ -58,6 +65,28 @@ export const Editor: FC = () => {
setCodeCopied(() => true);
};
+ useEffect(() => {
+ const loadCode = async () => {
+ const { id } = params;
+ if (!id) {
+ return;
+ }
+
+ try {
+ const res = await rpc.parameters[":id"].$get({ param: { id } });
+ if (res.ok) {
+ const { code } = await res.json();
+ $setCode(code);
+ }
+ } catch (e) {
+ console.error(`Error loading playground: ${e}`);
+ return;
+ }
+ };
+
+ loadCode();
+ }, [params, $setCode]);
+
useEffect(() => {
if (!codeCopied) {
return;
@@ -136,7 +165,12 @@ export const Editor: FC = () => {
tab !== "code" && "hidden",
)}
>
-
diff --git a/src/Preview.tsx b/src/client/Preview.tsx
similarity index 97%
rename from src/Preview.tsx
rename to src/client/Preview.tsx
index 54e5dd1..7dc8cdd 100644
--- a/src/Preview.tsx
+++ b/src/client/Preview.tsx
@@ -1,18 +1,18 @@
-import { Button } from "@/components/Button";
+import { Button } from "@/client/components/Button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
-} from "@/components/Resizable";
-import * as Tabs from "@/components/Tabs";
+} from "@/client/components/Resizable";
+import * as Tabs from "@/client/components/Tabs";
import {
type Diagnostic,
type InternalDiagnostic,
outputToDiagnostics,
-} from "@/diagnostics";
+} from "@/client/diagnostics";
import type { ParserLog, PreviewOutput } from "@/gen/types";
-import { useDebouncedValue } from "@/hooks/debounce";
-import { useStore } from "@/store";
+import { useDebouncedValue } from "@/client/hooks/debounce";
+import { useStore } from "@/client/store";
import { cn } from "@/utils/cn";
import * as Dialog from "@radix-ui/react-dialog";
import {
@@ -29,7 +29,7 @@ import { AnimatePresence, motion } from "motion/react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import ReactJsonView from "@microlink/react-json-view";
-import { useTheme } from "@/contexts/theme";
+import { useTheme } from "@/client/contexts/theme";
export const Preview: FC = () => {
const $wasmState = useStore((state) => state.wasmState);
diff --git a/src/components/Button.tsx b/src/client/components/Button.tsx
similarity index 98%
rename from src/components/Button.tsx
rename to src/client/components/Button.tsx
index e8890f3..79441af 100644
--- a/src/components/Button.tsx
+++ b/src/client/components/Button.tsx
@@ -5,7 +5,7 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { forwardRef } from "react";
-import { cn } from "../utils/cn";
+import { cn } from "@/utils/cn";
export const buttonVariants = cva(
`inline-flex items-center justify-center gap-1 whitespace-nowrap
diff --git a/src/components/DropdownMenu.tsx b/src/client/components/DropdownMenu.tsx
similarity index 100%
rename from src/components/DropdownMenu.tsx
rename to src/client/components/DropdownMenu.tsx
diff --git a/src/components/Logo.tsx b/src/client/components/Logo.tsx
similarity index 100%
rename from src/components/Logo.tsx
rename to src/client/components/Logo.tsx
diff --git a/src/components/Resizable.tsx b/src/client/components/Resizable.tsx
similarity index 100%
rename from src/components/Resizable.tsx
rename to src/client/components/Resizable.tsx
diff --git a/src/components/Tabs.tsx b/src/client/components/Tabs.tsx
similarity index 100%
rename from src/components/Tabs.tsx
rename to src/client/components/Tabs.tsx
diff --git a/src/components/Tooltip.tsx b/src/client/components/Tooltip.tsx
similarity index 100%
rename from src/components/Tooltip.tsx
rename to src/client/components/Tooltip.tsx
diff --git a/src/contexts/theme.tsx b/src/client/contexts/theme.tsx
similarity index 100%
rename from src/contexts/theme.tsx
rename to src/client/contexts/theme.tsx
diff --git a/src/diagnostics.ts b/src/client/diagnostics.ts
similarity index 98%
rename from src/diagnostics.ts
rename to src/client/diagnostics.ts
index 5fb23fa..acdabdc 100644
--- a/src/diagnostics.ts
+++ b/src/client/diagnostics.ts
@@ -3,7 +3,7 @@ import type {
Parameter,
ParserLog,
PreviewOutput,
-} from "./gen/types";
+} from "@/gen/types";
type FriendlyDiagnosticWithoutKind = Omit
;
diff --git a/src/hooks/debounce.tsx b/src/client/hooks/debounce.tsx
similarity index 100%
rename from src/hooks/debounce.tsx
rename to src/client/hooks/debounce.tsx
diff --git a/src/index.css b/src/client/index.css
similarity index 92%
rename from src/index.css
rename to src/client/index.css
index 5d6d9b5..44289e4 100644
--- a/src/index.css
+++ b/src/client/index.css
@@ -3,6 +3,9 @@
Related issue: https://github.com/shadcn-ui/ui/issues/805#issuecomment-1616021820
*/
+@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
+
@tailwind base;
@tailwind components;
@tailwind utilities;
diff --git a/src/client/index.tsx b/src/client/index.tsx
new file mode 100644
index 0000000..44c60be
--- /dev/null
+++ b/src/client/index.tsx
@@ -0,0 +1,36 @@
+import { TooltipProvider } from "@/client/components/Tooltip";
+import { ThemeProvider } from "@/client/contexts/theme.tsx";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { RouterProvider, createBrowserRouter, redirect } from "react-router";
+import { App } from "./App.tsx";
+import "@/client/index.css";
+
+const router = createBrowserRouter([
+ {
+ path: "/parameters/:id?",
+ Component: App,
+ },
+ {
+ path: "*",
+ loader: () => {
+ return redirect("/parameters");
+ },
+ },
+]);
+
+const root = document.getElementById("root");
+
+if (!root) {
+ console.error("An element with the id `root` does not exist");
+} else {
+ createRoot(root).render(
+
+
+
+
+
+
+ ,
+ );
+}
diff --git a/src/store.tsx b/src/client/store.tsx
similarity index 95%
rename from src/store.tsx
rename to src/client/store.tsx
index 9ab5487..df2ea06 100644
--- a/src/store.tsx
+++ b/src/client/store.tsx
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import type { Diagnostic } from "@/diagnostics";
+import type { Diagnostic } from "@/client/diagnostics";
const defaultCode = `terraform {
required_providers {
diff --git a/src/main.tsx b/src/main.tsx
deleted file mode 100644
index d8807d4..0000000
--- a/src/main.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import "@fontsource-variable/inter";
-import "@fontsource/dm-mono";
-import { TooltipProvider } from "@/components/Tooltip";
-import { ThemeProvider } from "@/contexts/theme.tsx";
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import { BrowserRouter } from "react-router";
-import { App } from "./App.tsx";
-
-const root = document.getElementById("root");
-
-if (!root) {
- console.error("An element with the id `root` does not exist");
-} else {
- createRoot(root).render(
-
-
-
-
-
-
-
-
- ,
- );
-}
diff --git a/src/server/api.ts b/src/server/api.ts
new file mode 100644
index 0000000..fc06ed6
--- /dev/null
+++ b/src/server/api.ts
@@ -0,0 +1,51 @@
+import { vValidator } from "@hono/valibot-validator";
+import { head, put } from "@vercel/blob";
+import { Hono } from "hono";
+import { nanoid } from "nanoid";
+import * as v from "valibot";
+
+const BLOG_PATH = "parameters/share";
+
+const parameters = new Hono()
+ .get("/:id", async (c) => {
+ const { id } = c.req.param();
+ try {
+ const { url } = await head(`${BLOG_PATH}/${id}.txt`);
+ const res = await fetch(url);
+ const code = new TextDecoder().decode(await res.arrayBuffer());
+
+ return c.json({ code });
+ } catch (e) {
+ console.error(`Failed to load playground with id ${id}: ${e}`);
+ return c.json({ code: "" }, 404);
+ }
+ })
+ .post(
+ "/",
+ vValidator(
+ "json",
+ v.object({
+ code: v.string(),
+ }),
+ ),
+ async (c) => {
+ const { code } = c.req.valid("json");
+ const bytes = new TextEncoder().encode(code);
+
+ if (bytes.length < 1024 * 10) {
+ // throw new Error
+ }
+
+ const id = nanoid();
+ await put(`${BLOG_PATH}/${id}.txt`, code, {
+ addRandomSuffix: false,
+ access: "public",
+ });
+
+ return c.json({ id });
+ },
+ );
+
+export const api = new Hono().route("/parameters", parameters);
+
+export type ApiType = typeof api;
diff --git a/src/server/index.tsx b/src/server/index.tsx
new file mode 100644
index 0000000..4d4dc17
--- /dev/null
+++ b/src/server/index.tsx
@@ -0,0 +1,64 @@
+import { api } from "@/server/api";
+import { Hono } from "hono";
+import { renderToString } from "react-dom/server";
+
+// This must be exported for the dev server to work
+export const app = new Hono();
+
+app.route("/api", api);
+
+// Serves the main web application. This must come after the API route.
+app.get("*", (c) => {
+ // Along with the vite React plugin this enables HMR within react while
+ // running the dev server.
+ const { url } = c.req;
+ const { origin } = new URL(url);
+ const injectClientScript = `
+ import RefreshRuntime from "${origin}/@react-refresh";
+ RefreshRuntime.injectIntoGlobalHook(window);
+ window.$RefreshReg$ = () => {};
+ window.$RefreshSig$ = () => (type) => type;
+ window.__vite_plugin_react_preamble_installed__ = true;
+ `;
+ const hmrScript = import.meta.env.DEV ? (
+
+ ) : null;
+
+ // Sets the correct path for static assets based on the environment.
+ // The production paths are hard coded based on the output of the build script.
+ const cssPath = import.meta.env.PROD
+ ? "/assets/index.css"
+ : "/src/client/index.css";
+ const clientScriptPath = import.meta.env.PROD
+ ? "/assets/client.js"
+ : "/src/client/index.tsx";
+ const wasmExecScriptPath = import.meta.env.PROD
+ ? "/assets/wasm_exec.js"
+ : "/wasm_exec.js";
+
+ return c.html(
+ [
+ "",
+ renderToString(
+
+
+
+
+
+ Paramaters Playground
+
+ {hmrScript}
+
+
+
+
+
+
+ ,
+ ),
+ ].join("\n"),
+ );
+});
diff --git a/src/utils/rpc.ts b/src/utils/rpc.ts
new file mode 100644
index 0000000..7aecde6
--- /dev/null
+++ b/src/utils/rpc.ts
@@ -0,0 +1,4 @@
+import type { ApiType } from "@/server/api";
+import { hc } from "hono/client";
+
+export const rpc = hc("/api");
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..a08026d
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/tailwind.config.js b/tailwind.config.js
index 64b1d76..defe980 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -9,7 +9,7 @@ module.exports = {
theme: {
extend: {
fontFamily: {
- sans: `"Inter Variable", system-ui, sans-serif`,
+ sans: `"Inter", system-ui, sans-serif`,
mono: `"DM Mono", monospace`,
},
size: {
diff --git a/tsconfig.json b/tsconfig.json
index 20ff94b..a3f25db 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"files": [],
+ "erasableSyntaxOnly": true,
"references": [
{
"path": "./tsconfig.app.json"
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..2a78e5c
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "rewrites": [
+ {
+ "source": "(.*)",
+ "destination": "/"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 5fc9e6d..8d2617b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,24 +1,239 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-import basicSsl from "@vitejs/plugin-basic-ssl";
+import fs from "node:fs/promises";
import path from "node:path";
+import devServer from "@hono/vite-dev-server";
+import basicSsl from "@vitejs/plugin-basic-ssl";
+import react from "@vitejs/plugin-react";
+import { defineConfig, mergeConfig, type Plugin } from "vite";
+
+const OUT_DIR = ".vercel";
+
+/**
+ * Load .env files in dev.
+ * This is naive implementation but I wanted to avoid adding a dependency.
+ */
+const dotenv = (envFile?: string | string[]): Plugin => ({
+ name: "dotenv",
+ async buildStart() {
+ const files = () => {
+ if (!envFile) {
+ return [".env.local"];
+ }
+
+ if (typeof envFile === "string") {
+ return [envFile];
+ }
+
+ return envFile;
+ };
-// https://vite.dev/config/
-export default defineConfig({
- base: "/",
- server: {
- // For dev purposes when using Coder Connect, and ngrok
- allowedHosts: [".coder", ".ngrok"],
+ const contents = await Promise.all(
+ files().flatMap((path) => fs.readFile(path, "utf-8").catch(() => "")),
+ );
+ contents
+ .flatMap((line) => line.split("\n"))
+ .map((line) => line.trim())
+ .filter((line) => !line.startsWith("#") && line !== "")
+ .map((line) => line.split("="))
+ .map(([key, value]) => {
+ if (key.startsWith("VITE_")) {
+ return;
+ }
+ process.env[key] = value.replaceAll('"', "");
+ });
},
- plugins: [
- react(),
- basicSsl({
- name: "test",
- }),
- ],
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "./src"),
- },
+});
+
+/**
+ * Create the [config.json][1] and [vc-config.json][2] files required in the final output.
+ *
+ * [1]:
+ * [2]:
+ */
+const vercelConfigPlugin = () => ({
+ name: "write-vercel-config",
+ // Write config
+ writeBundle: async () => {
+ const distPath = path.resolve(__dirname, OUT_DIR, "output");
+
+ await fs.writeFile(
+ path.join(distPath, "config.json"),
+ JSON.stringify({ version: 3 }),
+ );
+
+ await fs.writeFile(
+ path.join(distPath, "functions", "index.func", ".vc-config.json"),
+ JSON.stringify({
+ runtime: "nodejs20.x",
+ handler: "index.js",
+ launcherType: "Nodejs",
+ shouldAddHelpers: true,
+ }),
+ );
},
});
+
+/**
+ * Generate the vercel specific code within the server entry file.
+ *
+ * ```ts
+ * import { handle } from "hono/vercel";
+ *
+ * ...
+ *
+ * const handler = handle(app);
+ * export const GET = handler;
+ * export const POST = handler;
+ * export const PATCH = handler;
+ * export const PUT = handler;
+ * export const OPTIONS = handler;
+ * ```
+ *
+ */
+const vercelEntryPlugin = (): Plugin => {
+ let entry: string;
+ let resolvedEntryPath: string;
+ let projectRoot: string;
+
+ return {
+ name: "vercel-entry",
+ configResolved(config) {
+ if (config.build.lib) {
+ const e = config.build.lib.entry;
+ if (typeof e === "string") {
+ entry = e;
+ } else {
+ throw new Error("Entry must be a string path");
+ }
+ }
+
+ projectRoot = config.root;
+ resolvedEntryPath = path.normalize(path.resolve(projectRoot, entry));
+ },
+ async load(id) {
+ const normalizedId = path.normalize(path.resolve(projectRoot, id));
+
+ if (normalizedId === resolvedEntryPath) {
+ try {
+ const content = await fs.readFile(resolvedEntryPath, "utf-8");
+ const transformedContent = [
+ 'import { handle } from "hono/vercel";',
+ content,
+ "const handler = handle(app);",
+ "export const GET = handler;",
+ "export const POST = handler;",
+ "export const PATCH = handler;",
+ "export const PUT = handler;",
+ "export const OPTIONS = handler;",
+ ].join("\n");
+
+ return transformedContent;
+ } catch (e) {
+ this.error(`Failed to process entry file ${entry}: ${e}`);
+ }
+ }
+
+ return null;
+ },
+ };
+};
+
+/**
+ * Vite is handling both the building of our final assets and also running the
+ * dev server which gives us HMR for both SSR'd templates and client React code.
+ *
+ * **Build Details**
+ *
+ * We're deploying to Vercel which requires very sepecific project outputs in
+ * order to deploy properly [build structure][1]:
+ *
+ * .vercel/
+ * └── output/
+ * ├── config.json
+ * ├── functions/
+ * │ └── index.func/
+ * │ ├── .vs-config.json
+ * │ └── index.js <- Server code
+ * └── static/
+ * └── assets/
+ * ├── client.js
+ * └──
+ *
+ * The current build setup is hard coded to expect files at their current
+ * paths within the code. This is something that could be improved to make
+ * the build script less brittle.
+ *
+ * [1]:
+ */
+export default defineConfig(({ mode, command }) => {
+ const baseConfig = {
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ };
+
+ const clientBuildConfig = {
+ build: {
+ outDir: path.resolve(OUT_DIR, "output", "static", "assets"),
+ manifest: true,
+ minify: true,
+ rollupOptions: {
+ input: ["./src/client/index.tsx"],
+ output: {
+ entryFileNames: "client.js",
+ chunkFileNames: "[name]-[hash].js",
+ assetFileNames: "[name].[ext]",
+ },
+ },
+ emptyOutDir: false,
+ copyPublicDir: true,
+ },
+ };
+
+ const serverBuildConfig = {
+ build: {
+ copyPublicDir: false,
+ outDir: OUT_DIR,
+ minify: true,
+ lib: {
+ entry: "src/server/index.tsx",
+ name: "server",
+ formats: ["umd"],
+ },
+ rollupOptions: {
+ output: {
+ entryFileNames: "output/functions/index.func/index.js",
+ },
+ },
+ },
+ plugins: [vercelEntryPlugin(), vercelConfigPlugin()],
+ };
+
+ const devConfig = {
+ server: {
+ // For dev purposes when using Coder Connect, and ngrok
+ allowedHosts: [".coder", ".ngrok"],
+ },
+ plugins: [
+ dotenv(),
+ react(),
+ devServer({
+ entry: "./src/server/index.tsx",
+ export: "app",
+ }),
+ basicSsl({
+ name: "dev",
+ }),
+ ],
+ };
+
+ if (command === "build") {
+ if (mode === "client") {
+ return mergeConfig(baseConfig, clientBuildConfig);
+ }
+ return mergeConfig(baseConfig, serverBuildConfig);
+ }
+
+ return mergeConfig(baseConfig, devConfig);
+});