diff --git a/.gitignore b/.gitignore
index 2c123c0b78..f0a36ab11f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,6 +52,9 @@ testem.log
packages/playground/website/cypress/downloads
vite.config.ts.timestamp-*.mjs
+# Environment files - keep defaults but ignore local overrides
+.env.local
+
# System Files
.DS_Store
Thumbs.db
diff --git a/README.md b/README.md
index c46ba313cd..9522f96384 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,10 @@ A browser should open and take you to your very own client-side WordPress at [ht
Any changes you make to `.ts` files will be live-reloaded. Changes to `Dockerfile` require a full rebuild.
+## Self-hosting WordPress Playground
+
+When self-hosting WordPress Playground, you may want to customize certain aspects like analytics tracking. See the [configuration documentation](packages/playground/website/CONFIGURATION.md) for details on available options and how to apply them.
+
From here, the [documentation](https://wordpress.github.io/wordpress-playground/) will help you learn how WordPress Playground works and how to use it to build amazing things!
And here's a few more interesting CLI commands you can run in this repo:
diff --git a/packages/playground/website/.env.example b/packages/playground/website/.env.example
new file mode 100644
index 0000000000..6f35cff547
--- /dev/null
+++ b/packages/playground/website/.env.example
@@ -0,0 +1,6 @@
+# WordPress Playground configuration
+# Copy this file to .env to customize your local deployment
+
+# Google Analytics/GTM Configuration
+# Leave empty to disable analytics
+VITE_GOOGLE_ANALYTICS_ID=G-SVTNFCP8T7
\ No newline at end of file
diff --git a/packages/playground/website/CONFIGURATION.md b/packages/playground/website/CONFIGURATION.md
new file mode 100644
index 0000000000..fed0c1371c
--- /dev/null
+++ b/packages/playground/website/CONFIGURATION.md
@@ -0,0 +1,71 @@
+# WordPress Playground Configuration
+
+This document outlines how to configure your self-hosted WordPress Playground instance.
+
+## Environment Variables
+
+WordPress Playground uses environment variables for configuration. These can be set in the following files:
+
+- `.env` - Default configuration (included in repository)
+- `.env.local` - Local overrides (not committed to Git)
+
+## Available Configuration Options
+
+### Google Analytics
+
+The Google Analytics/GTM integration can be configured using:
+
+```
+VITE_GOOGLE_ANALYTICS_ID=your-ga4-id
+```
+
+To disable Google Analytics completely, set the value to an empty string:
+
+```
+VITE_GOOGLE_ANALYTICS_ID=
+```
+
+The Google Analytics script is automatically injected into the `
` section of all HTML pages during the build process. If the environment variable is not set or is empty, no analytics code will be included in the final HTML output, improving privacy and performance for self-hosted instances that don't require tracking.
+
+This configuration applies to:
+
+- The main application (`index.html`)
+- The WordPress PR previewer (`public/wordpress.html`)
+- The Gutenberg PR previewer (`public/gutenberg.html`)
+- All demo and builder HTML files
+
+## How to Configure Your Self-Hosted Instance
+
+1. Clone the repository
+2. Create a `.env.local` file with your custom configuration
+3. Build the project according to the main README instructions
+
+Example `.env.local` file:
+
+```
+# Custom Google Analytics ID for my self-hosted instance
+VITE_GOOGLE_ANALYTICS_ID=G-MYANALYTICS123
+```
+
+## Building With Custom Configuration
+
+The environment variables are applied at build time. Make sure your custom `.env.local` file is in place before running:
+
+```bash
+# Standard build
+npm run build:website
+
+# Verbose build with analytics logging
+npm run build:website -- --verbose
+```
+
+## Technical Implementation
+
+The analytics integration uses a custom Vite plugin that inserts the Google Analytics script at the end of the `` section in all HTML files during the build process. This approach:
+
+1. Keeps analytics configuration separate from the code
+2. Ensures no analytics code is included in the HTML when disabled
+3. Requires no placeholder comments in the HTML source files
+4. Provides a clean way to customize analytics for self-hosted instances
+5. Maintains clean indentation and formatting in the output HTML
+6. Operates silently by default (logs can be enabled with `--verbose`)
diff --git a/packages/playground/website/index.html b/packages/playground/website/index.html
index 91806487eb..1228b5f530 100644
--- a/packages/playground/website/index.html
+++ b/packages/playground/website/index.html
@@ -29,18 +29,6 @@
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
rel="stylesheet"
/>
-
-
diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts
index 329cb09769..52134ed818 100644
--- a/packages/playground/website/playwright/e2e/website-ui.spec.ts
+++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts
@@ -262,3 +262,16 @@ test('should keep query arguments when updating settings', async ({
await wordpress.locator('body').evaluate((body) => body.baseURI)
).toMatch('/wp-admin/');
});
+
+test('should not load GTM code when VITE_GOOGLE_ANALYTICS_ID is missing', async ({
+ website,
+}) => {
+ await website.goto('./');
+
+ // By default, the VITE_GOOGLE_ANALYTICS_ID is not set, so GTM should not be loaded
+ // Check if GTM script is not present in the head
+ const gtmScript = await website.page
+ .locator('script[src*="googletagmanager.com"]')
+ .count();
+ expect(gtmScript).toBe(0);
+});
diff --git a/packages/playground/website/public/gutenberg.html b/packages/playground/website/public/gutenberg.html
index 4fbdf15c5c..35078ca979 100644
--- a/packages/playground/website/public/gutenberg.html
+++ b/packages/playground/website/public/gutenberg.html
@@ -22,18 +22,6 @@
href="https://fonts.googleapis.com/css?family=Noto+Serif:400,700"
/>
-
-
@@ -164,7 +152,7 @@
step: 'login',
username: 'admin',
password: 'password',
- }
+ },
],
};
// If there's a import-site query parameter, pass that to the blueprint
diff --git a/packages/playground/website/public/wordpress.html b/packages/playground/website/public/wordpress.html
index a005b3ea1d..ff7e0e9754 100644
--- a/packages/playground/website/public/wordpress.html
+++ b/packages/playground/website/public/wordpress.html
@@ -21,18 +21,6 @@
href="https://fonts.googleapis.com/css?family=Noto+Serif:400,700"
/>
-
-
diff --git a/packages/playground/website/vite-analytics-plugin.ts b/packages/playground/website/vite-analytics-plugin.ts
new file mode 100644
index 0000000000..6a3a8f390e
--- /dev/null
+++ b/packages/playground/website/vite-analytics-plugin.ts
@@ -0,0 +1,135 @@
+/* eslint-disable no-console */
+import { Plugin } from 'vite';
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+// Plugin options interface
+interface AnalyticsPluginOptions {
+ verbose?: boolean;
+}
+
+/**
+ * Vite plugin to inject Google Analytics into the head tag of HTML files
+ *
+ * @param options Plugin options
+ * @returns {Plugin} A Vite plugin that processes HTML files during build
+ */
+export function analyticsInjectionPlugin(
+ options: AnalyticsPluginOptions = {}
+): Plugin {
+ // Default options
+ const { verbose = false } = options;
+
+ // Shared analytics script template
+ const getAnalyticsScript = (id: string) => {
+ return `
+
+
+`;
+ };
+
+ // Helper function for conditional logging
+ const log = (msg: string, isError = false) => {
+ // Only log if it's an error or verbose mode is enabled
+ if (isError || verbose) {
+ isError ? console.error(msg) : console.log(msg);
+ }
+ };
+
+ return {
+ name: 'vite-plugin-analytics-injection',
+ apply: 'build', // Only apply during build, not dev
+
+ writeBundle(options, bundle) {
+ const googleAnalyticsId = process.env.VITE_GOOGLE_ANALYTICS_ID;
+
+ if (!googleAnalyticsId) {
+ log(
+ 'Google Analytics disabled - no tracking will be added to HTML files.'
+ );
+ return;
+ }
+
+ log('Processing HTML files for Google Analytics injection...');
+
+ // Files to process - include all HTML files that need analytics
+ const htmlFiles = [
+ 'index.html',
+ 'wordpress.html',
+ 'gutenberg.html',
+ ];
+ const outputDir = options.dir || '';
+
+ let processedCount = 0;
+ let skippedCount = 0;
+ let notFoundCount = 0;
+
+ htmlFiles.forEach((htmlFile) => {
+ const outputPath = join(outputDir, htmlFile);
+
+ if (existsSync(outputPath)) {
+ log(`Processing ${htmlFile} for analytics...`);
+
+ try {
+ // Read file
+ const content = readFileSync(outputPath, 'utf8');
+
+ // Check if the file already has analytics (to avoid duplicate injection)
+ if (
+ content.includes(
+ `gtag('config', '${googleAnalyticsId}')`
+ )
+ ) {
+ log(
+ `Analytics already present in ${htmlFile}, skipping.`
+ );
+ skippedCount++;
+ return;
+ }
+
+ // Find the closing head tag
+ const headCloseIndex = content.indexOf('');
+ if (headCloseIndex === -1) {
+ log(
+ `Could not find tag in ${htmlFile}, skipping.`,
+ true
+ );
+ skippedCount++;
+ return;
+ }
+
+ // Insert the analytics script right before the closing head tag
+ const analyticsScript =
+ getAnalyticsScript(googleAnalyticsId);
+ const updatedContent =
+ content.substring(0, headCloseIndex) +
+ analyticsScript +
+ content.substring(headCloseIndex);
+
+ // Write back
+ writeFileSync(outputPath, updatedContent, 'utf8');
+ log(`Successfully injected analytics into ${htmlFile}`);
+ processedCount++;
+ } catch (error) {
+ log(`Error processing ${htmlFile}: ${error}`, true);
+ }
+ } else {
+ log(`File not found in build directory: ${outputPath}`);
+ notFoundCount++;
+ }
+ });
+
+ // Always show summary even in non-verbose mode
+ if (processedCount > 0) {
+ log(
+ `Analytics injection: ${processedCount} files processed, ${skippedCount} skipped, ${notFoundCount} not found.`
+ );
+ }
+ },
+ };
+}
diff --git a/packages/playground/website/vite.config.ts b/packages/playground/website/vite.config.ts
index b973bb0c06..532ab8e125 100644
--- a/packages/playground/website/vite.config.ts
+++ b/packages/playground/website/vite.config.ts
@@ -22,6 +22,7 @@ import { buildVersionPlugin } from '../../vite-extensions/vite-build-version';
import { listAssetsRequiredForOfflineMode } from '../../vite-extensions/vite-list-assets-required-for-offline-mode';
import { addManifestJson } from '../../vite-extensions/vite-manifest';
import virtualModule from '../../vite-extensions/vite-virtual-module';
+import { analyticsInjectionPlugin } from './vite-analytics-plugin';
const proxy: CommonServerOptions['proxy'] = {
'^/plugin-proxy': {
@@ -40,6 +41,9 @@ export default defineConfig(({ command, mode }) => {
? 'https://wordpress-playground-cors-proxy.net/?'
: 'http://127.0.0.1:5263/cors-proxy.php?';
+ // Check for verbose mode
+ const isVerbose = process.argv.includes('--verbose');
+
return {
// Split traffic from this server on dev so that the iframe content and
// outer content can be served from the same origin. In production it's
@@ -77,6 +81,7 @@ export default defineConfig(({ command, mode }) => {
},
},
plugins: [
+ analyticsInjectionPlugin({ verbose: isVerbose }),
react({
jsxRuntime: 'automatic',
}),