-
+
{file ? (
<>
@@ -123,6 +129,7 @@ export const pre = ({
{title || languageNames[language]}
)}
+ {autofill &&
}
);
+
+ // Wrap with autofill component if enabled
+ if (autofill && code) {
+ return
{codeBlock};
+ }
+
+ return codeBlock;
};
export { pre as Code };
diff --git a/site/src/components/v2/CopyCodeButton.tsx b/site/src/components/v2/CopyCodeButton.tsx
index 2e978913ca..a4768ff373 100644
--- a/site/src/components/v2/CopyCodeButton.tsx
+++ b/site/src/components/v2/CopyCodeButton.tsx
@@ -3,10 +3,21 @@ import { Slot, toast } from "@rivet-gg/components";
export function CopyCodeTrigger({ children }) {
const handleClick = (event) => {
- const code =
- event.currentTarget.parentNode.parentNode.querySelector(
- ".code",
- ).innerText;
+ // Check if this is an autofill code block with processed code
+ const autofillContainer = event.currentTarget.closest('[data-autofill-code]');
+ let code: string;
+
+ if (autofillContainer) {
+ // Use the autofilled code from the data attribute
+ code = autofillContainer.getAttribute('data-autofill-code') || '';
+ } else {
+ // Use the original behavior - get code from innerText
+ code =
+ event.currentTarget.parentNode.parentNode.querySelector(
+ ".code",
+ ).innerText;
+ }
+
navigator.clipboard.writeText(code);
toast.success("Copied to clipboard");
};
diff --git a/site/src/components/v2/TemplateVariable.tsx b/site/src/components/v2/TemplateVariable.tsx
new file mode 100644
index 0000000000..58efa0e027
--- /dev/null
+++ b/site/src/components/v2/TemplateVariable.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import {
+ Button,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@rivet-gg/components";
+import { faRightToBracket, Icon } from "@rivet-gg/icons";
+import { useEffect, useMemo, useState } from "react";
+import { useAutofillStore } from "@/stores/autofill-store";
+
+interface TemplateVariableProps {
+ variable: string;
+ defaultValue?: string;
+}
+
+const CLOUD_LOGIN_URL =
+ process.env.NEXT_PUBLIC_CLOUD_URL || "https://cloud.rivet.gg";
+
+export function TemplateVariable({ variable, defaultValue }: TemplateVariableProps) {
+ const [open, setOpen] = useState(false);
+ const {
+ user,
+ projects,
+ namespaces,
+ selectedProjectId,
+ selectedNamespaceId,
+ isLoading,
+ isLoadingNamespaces,
+ initialize,
+ selectProject,
+ selectNamespace,
+ getTemplateVariables,
+ } = useAutofillStore();
+
+ useEffect(() => {
+ initialize();
+ }, [initialize]);
+
+ const selectedProject = useMemo(
+ () => projects.find((p) => p.id === selectedProjectId),
+ [projects, selectedProjectId],
+ );
+
+ const selectedNamespace = useMemo(
+ () => namespaces.find((n) => n.id === selectedNamespaceId),
+ [namespaces, selectedNamespaceId],
+ );
+
+ const value = useMemo(() => {
+ const variables = getTemplateVariables();
+ // Use actual value if available, fallback to default, then template placeholder
+ return variables[variable] || defaultValue || `{{${variable}}}`;
+ }, [variable, defaultValue, getTemplateVariables]);
+
+ // Not authenticated
+ if (!isLoading && !user) {
+ return (
+
+
+
+
+
+
+
+
Sign in required
+
+ Connect your Rivet account to autofill this
+ value
+
+
+
+
+
+
+ );
+ }
+
+ // Loading
+ if (isLoading) {
+ return (
+
{defaultValue || `{{${variable}}}`}
+ );
+ }
+
+ // No projects
+ if (projects.length === 0) {
+ return (
+
+
+
+
+
+
+
No projects found
+
+ Create a project on Rivet Cloud to use autofill
+
+
+
+
+ );
+ }
+
+ // Has value - show it with click to change
+ return (
+
+
+
+
+
+
+
+
Configure autofill
+
+ Select your project and namespace
+
+
+
+ {/* Project Selector */}
+
+
+
+
+
+ {/* Namespace Selector */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/site/src/content/docs/examples/autofill-example.mdx b/site/src/content/docs/examples/autofill-example.mdx
new file mode 100644
index 0000000000..bd952ebd0a
--- /dev/null
+++ b/site/src/content/docs/examples/autofill-example.mdx
@@ -0,0 +1,75 @@
+# Code Autofill Example
+
+This page demonstrates the code autofill feature with template variables.
+
+## Basic Example
+
+The following code snippet uses autofill to populate your project and namespace information. Click on any blue highlighted value to change your selection:
+
+```ts {{"autofill": true}}
+import { createClient } from "rivetkit";
+
+const client = createClient({
+ token: "{{namespace.token}}",
+ endpoint: "{{engine.url}}"
+});
+
+console.log("Connected to project: {{project.slug}}");
+console.log("Using namespace: {{namespace.slug}}");
+```
+
+## Using Default Values
+
+You can specify default values that will be shown before the user selects a project. Format: `{{variable.name:"default-value"}}`
+
+```ts {{"autofill": true}}
+import { createClient } from "rivetkit";
+
+const client = createClient({
+ // These will show default values until user selects a project
+ token: "{{namespace.token:'YOUR_TOKEN_HERE'}}",
+ endpoint: "{{engine.url:'https://engine.rivet.gg'}}"
+});
+
+const config = {
+ project: "{{project.slug:'my-project'}}",
+ namespace: "{{namespace.slug:'production'}}",
+};
+```
+
+## Multiple Template Variables
+
+You can use various template variables in your code:
+
+```ts {{"autofill": true}}
+const config = {
+ // Organization info
+ organization: "{{organization.slug}}",
+
+ // Project info
+ project: {
+ slug: "{{project.slug}}",
+ name: "{{project.name}}",
+ },
+
+ // Namespace info
+ namespace: {
+ slug: "{{namespace.slug}}",
+ name: "{{namespace.name}}",
+ token: "{{namespace.token}}",
+ },
+
+ // Connection
+ engineUrl: "{{engine.url}}",
+};
+```
+
+## Regular Code Block (No Autofill)
+
+This code block doesn't use autofill:
+
+```ts
+// This is a regular code block without autofill
+const greeting = "Hello, World!";
+console.log(greeting);
+```
diff --git a/site/src/lib/cloud-api/client.ts b/site/src/lib/cloud-api/client.ts
new file mode 100644
index 0000000000..60056d4d8e
--- /dev/null
+++ b/site/src/lib/cloud-api/client.ts
@@ -0,0 +1,64 @@
+// Cloud API client for fetching autofill data
+import type {
+ CloudBootstrapResponse,
+ CloudNamespace,
+ CloudNamespaceToken,
+} from "./types";
+
+const CLOUD_API_URL =
+ process.env.NEXT_PUBLIC_CLOUD_API_URL || "https://cloud.rivet.gg";
+
+class CloudApiClient {
+ private baseUrl: string;
+
+ constructor(baseUrl: string = CLOUD_API_URL) {
+ this.baseUrl = baseUrl;
+ }
+
+ async bootstrap(): Promise
{
+ const response = await fetch(`${this.baseUrl}/cloud/bootstrap`, {
+ credentials: "include", // Include cookies for auth
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch bootstrap data: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+
+ async getNamespaces(projectId: string): Promise {
+ const response = await fetch(
+ `${this.baseUrl}/cloud/projects/${projectId}/namespaces`,
+ {
+ credentials: "include",
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch namespaces: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+
+ async getNamespaceToken(
+ projectId: string,
+ namespaceId: string,
+ ): Promise {
+ const response = await fetch(
+ `${this.baseUrl}/cloud/projects/${projectId}/namespaces/${namespaceId}/token`,
+ {
+ credentials: "include",
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch namespace token: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+}
+
+export const cloudApi = new CloudApiClient();
diff --git a/site/src/lib/cloud-api/index.ts b/site/src/lib/cloud-api/index.ts
new file mode 100644
index 0000000000..d2ec2302c2
--- /dev/null
+++ b/site/src/lib/cloud-api/index.ts
@@ -0,0 +1,2 @@
+export * from "./client";
+export * from "./types";
diff --git a/site/src/lib/cloud-api/types.ts b/site/src/lib/cloud-api/types.ts
new file mode 100644
index 0000000000..725eda8347
--- /dev/null
+++ b/site/src/lib/cloud-api/types.ts
@@ -0,0 +1,31 @@
+// Cloud API types for autofill functionality
+
+export interface CloudBootstrapResponse {
+ user: CloudUser | null;
+ projects: CloudProject[];
+}
+
+export interface CloudUser {
+ id: string;
+ username: string;
+}
+
+export interface CloudProject {
+ id: string;
+ slug: string;
+ name: string;
+ organizationId: string;
+ organizationSlug: string;
+}
+
+export interface CloudNamespace {
+ id: string;
+ slug: string;
+ name: string;
+ projectId: string;
+}
+
+export interface CloudNamespaceToken {
+ token: string;
+ engineUrl: string;
+}
diff --git a/site/src/mdx/rehype.ts b/site/src/mdx/rehype.ts
index a8f1b77cbb..62397b63e1 100644
--- a/site/src/mdx/rehype.ts
+++ b/site/src/mdx/rehype.ts
@@ -7,6 +7,7 @@ import rehypeMdxTitle from "rehype-mdx-title";
import * as shiki from "shiki";
import { visit } from "unist-util-visit";
import theme from "../lib/textmate-code-theme";
+import { transformerTemplateVariables } from "./transformers";
function rehypeParseCodeBlocks() {
return (tree) => {
@@ -30,6 +31,12 @@ function rehypeParseCodeBlocks() {
annotations = { title: annotations.trim() };
}
+ // Autofill is handled client-side in AutofillCodeBlock.tsx
+ // Just pass through the autofill flag
+ if (annotations.autofill) {
+ parentNode.properties.autofill = true;
+ }
+
for (const key in annotations) {
parentNode.properties[key] = annotations[key];
}
@@ -69,7 +76,7 @@ function rehypeShiki() {
],
});
- visit(tree, "element", (node) => {
+ visit(tree, "element", (node, _index, parentNode) => {
if (
node.tagName === "pre" &&
node.children[0]?.tagName === "code"
@@ -80,10 +87,20 @@ function rehypeShiki() {
node.properties.code = textNode.value;
if (node.properties.language) {
+ const transformers = [transformerNotationFocus()];
+
+ // Add template variable transformer for autofill blocks
+ if (
+ node.properties?.autofill ||
+ parentNode.properties?.autofill
+ ) {
+ transformers.push(transformerTemplateVariables());
+ }
+
textNode.value = highlighter.codeToHtml(textNode.value, {
lang: node.properties.language,
theme: theme.name,
- transformers: [transformerNotationFocus()],
+ transformers,
});
}
}
diff --git a/site/src/mdx/transformers.ts b/site/src/mdx/transformers.ts
new file mode 100644
index 0000000000..fd7ad8c42c
--- /dev/null
+++ b/site/src/mdx/transformers.ts
@@ -0,0 +1,134 @@
+import type { ShikiTransformer, ThemedToken } from "shiki";
+
+interface TemplateVariable {
+ variable: string;
+ defaultValue?: string;
+}
+
+/**
+ * Parses a template variable string and returns info about it
+ * Supports: {{variable.name}} or {{variable.name:"default-value"}}
+ */
+function parseTemplateVariable(fullMatch: string): TemplateVariable | null {
+ // Remove {{ and }}
+ const content = fullMatch.slice(2, -2).trim();
+ const colonIndex = content.indexOf(":");
+
+ if (colonIndex > -1) {
+ const variable = content.substring(0, colonIndex).trim();
+ const defaultPart = content.substring(colonIndex + 1).trim();
+ // Remove quotes from default value
+ const defaultValue = defaultPart.replace(/^["']|["']$/g, "");
+ return { variable, defaultValue };
+ }
+
+ return { variable: content };
+}
+
+/**
+ * Shiki transformer that detects template variables in code and splits them
+ * into separate tokens with data attributes so they can be made interactive.
+ *
+ * Template variables are in the format:
+ * - {{variable.name}} - basic variable
+ * - {{variable.name:"default-value"}} - variable with default value
+ */
+export function transformerTemplateVariables(): ShikiTransformer {
+ console.log("Using template variable transformer");
+ return {
+ name: "template-variables",
+ tokens(tokens) {
+ console.log("Processing tokens with template variable transformer");
+ const newLines: ThemedToken[][] = [];
+
+ for (const line of tokens) {
+ const newTokens: ThemedToken[] = [];
+
+ for (const token of line) {
+ const splitTokens = splitTokenForTemplateVariables(token);
+ newTokens.push(...splitTokens);
+ }
+
+ newLines.push(newTokens);
+ }
+
+ return newLines;
+ },
+ };
+}
+
+/**
+ * Splits a single token that may contain template variables into multiple tokens
+ */
+function splitTokenForTemplateVariables(token: ThemedToken): ThemedToken[] {
+ const templateVarRegex = /\{\{[^}]+\}\}/g;
+ const matches = Array.from(token.content.matchAll(templateVarRegex));
+
+ if (matches.length === 0) {
+ // No template variables, return original token
+ return [token];
+ }
+
+ console.log(
+ "Found template variable matches:",
+ matches.map((m) => m[0]),
+ );
+
+ const resultTokens: ThemedToken[] = [];
+ let lastIndex = 0;
+
+ for (const match of matches) {
+ if (match.index === undefined) continue;
+ const matchIndex = match.index;
+
+ // Add text before the match as a regular token
+ if (matchIndex > lastIndex) {
+ resultTokens.push({
+ ...token,
+ content: token.content.substring(lastIndex, matchIndex),
+ offset: token.offset + lastIndex,
+ });
+ }
+
+ // Parse the template variable
+ const parsed = parseTemplateVariable(match[0]);
+
+ if (parsed) {
+ // Create a new token for the template variable with special attributes
+ resultTokens.push({
+ ...token,
+ content: parsed.defaultValue || match[0],
+ offset: token.offset + matchIndex,
+ htmlAttrs: {
+ ...(token.htmlAttrs || {}),
+ "data-template-var": parsed.variable,
+ ...(parsed.defaultValue
+ ? {
+ "data-template-default": parsed.defaultValue,
+ }
+ : {}),
+ },
+ });
+ } else {
+ // If parsing failed, keep original content
+ resultTokens.push({
+ ...token,
+ content: match[0],
+ offset: token.offset + matchIndex,
+ });
+ }
+
+ lastIndex = matchIndex + match[0].length;
+ }
+
+ // Add remaining text after last match
+ if (lastIndex < token.content.length) {
+ resultTokens.push({
+ ...token,
+ content: token.content.substring(lastIndex),
+ offset: token.offset + lastIndex,
+ });
+ }
+
+ return resultTokens;
+}
diff --git a/site/src/stores/autofill-store.ts b/site/src/stores/autofill-store.ts
new file mode 100644
index 0000000000..dad39e9813
--- /dev/null
+++ b/site/src/stores/autofill-store.ts
@@ -0,0 +1,151 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type {
+ CloudBootstrapResponse,
+ CloudNamespace,
+ CloudNamespaceToken,
+ CloudProject,
+} from "@/lib/cloud-api/types";
+import { cloudApi } from "@/lib/cloud-api/client";
+
+interface AutofillState {
+ // Data
+ user: CloudBootstrapResponse["user"] | null;
+ projects: CloudProject[];
+ namespaces: CloudNamespace[];
+ selectedProjectId: string | null;
+ selectedNamespaceId: string | null;
+ tokenData: CloudNamespaceToken | null;
+
+ // Loading states
+ isLoading: boolean;
+ isLoadingNamespaces: boolean;
+ isLoadingToken: boolean;
+
+ // Actions
+ initialize: () => Promise;
+ selectProject: (projectId: string) => Promise;
+ selectNamespace: (namespaceId: string) => Promise;
+ getTemplateVariables: () => Record;
+}
+
+export const useAutofillStore = create()(
+ persist(
+ (set, get) => ({
+ // Initial state
+ user: null,
+ projects: [],
+ namespaces: [],
+ selectedProjectId: null,
+ selectedNamespaceId: null,
+ tokenData: null,
+ isLoading: false,
+ isLoadingNamespaces: false,
+ isLoadingToken: false,
+
+ // Initialize: fetch bootstrap data
+ initialize: async () => {
+ set({ isLoading: true });
+ try {
+ const data = await cloudApi.bootstrap();
+ set({
+ user: data.user,
+ projects: data.projects,
+ isLoading: false,
+ });
+
+ // Auto-select first project if available and no selection exists
+ const state = get();
+ if (
+ data.projects.length > 0 &&
+ !state.selectedProjectId
+ ) {
+ await get().selectProject(data.projects[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to initialize autofill:", error);
+ set({ isLoading: false });
+ }
+ },
+
+ // Select a project and load its namespaces
+ selectProject: async (projectId: string) => {
+ set({
+ selectedProjectId: projectId,
+ isLoadingNamespaces: true,
+ selectedNamespaceId: null,
+ tokenData: null,
+ });
+
+ try {
+ const namespaces = await cloudApi.getNamespaces(projectId);
+ set({
+ namespaces,
+ isLoadingNamespaces: false,
+ });
+
+ // Auto-select first namespace
+ if (namespaces.length > 0) {
+ await get().selectNamespace(namespaces[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to load namespaces:", error);
+ set({ isLoadingNamespaces: false });
+ }
+ },
+
+ // Select a namespace and fetch its token
+ selectNamespace: async (namespaceId: string) => {
+ const { selectedProjectId } = get();
+ if (!selectedProjectId) return;
+
+ set({
+ selectedNamespaceId: namespaceId,
+ isLoadingToken: true,
+ });
+
+ try {
+ const tokenData = await cloudApi.getNamespaceToken(
+ selectedProjectId,
+ namespaceId,
+ );
+ set({
+ tokenData,
+ isLoadingToken: false,
+ });
+ } catch (error) {
+ console.error("Failed to load token:", error);
+ set({ isLoadingToken: false });
+ }
+ },
+
+ // Get all template variables for replacement
+ getTemplateVariables: () => {
+ const state = get();
+ const project = state.projects.find(
+ (p) => p.id === state.selectedProjectId,
+ );
+ const namespace = state.namespaces.find(
+ (n) => n.id === state.selectedNamespaceId,
+ );
+
+ return {
+ "project.slug": project?.slug || "",
+ "project.name": project?.name || "",
+ "organization.slug": project?.organizationSlug || "",
+ "namespace.slug": namespace?.slug || "",
+ "namespace.name": namespace?.name || "",
+ "namespace.token": state.tokenData?.token || "",
+ "engine.url": state.tokenData?.engineUrl || "",
+ };
+ },
+ }),
+ {
+ name: "rivet-autofill-storage",
+ partialize: (state) => ({
+ selectedProjectId: state.selectedProjectId,
+ selectedNamespaceId: state.selectedNamespaceId,
+ }),
+ },
+ ),
+);