Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/frameworks/wordpress/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { WizardRunOptions } from '@utils/types';

/**
* What kind of WordPress tree the wizard is pointed at. The three cases need
* different advice: a full site owns wp-config.php, a plugin or theme is a
* single directory that gets dropped into one.
*/
export enum WordPressProjectType {
SITE = 'site',
PLUGIN = 'plugin',
THEME = 'theme',
}

export function getWordPressProjectTypeName(
projectType: WordPressProjectType,
): string {
switch (projectType) {
case WordPressProjectType.SITE:
return 'WordPress site';
case WordPressProjectType.PLUGIN:
return 'WordPress plugin';
case WordPressProjectType.THEME:
return 'WordPress theme';
}
}

/** wp-config.php, or the sample that ships before a site is installed. */
export function hasWordPressCore(installDir: string): boolean {
return [
'wp-config.php',
'wp-config-sample.php',
'wp-load.php',
'wp-settings.php',
path.join('wp-includes', 'version.php'),
].some((rel) => fs.existsSync(path.join(installDir, rel)));
}

/**
* Composer-managed WordPress (Bedrock and friends) keeps core out of the
* project root, so the files above are absent — the giveaway is the core
* package in composer.json.
*/
export function hasComposerWordPress(installDir: string): boolean {
const composerPath = path.join(installDir, 'composer.json');
if (!fs.existsSync(composerPath)) {
return false;
}

try {
const composer = JSON.parse(fs.readFileSync(composerPath, 'utf-8')) as {
require?: Record<string, string>;
'require-dev'?: Record<string, string>;
};
const deps = { ...composer.require, ...composer['require-dev'] };
return Object.keys(deps).some(
(name) =>
name === 'johnpbloch/wordpress' ||
name === 'johnpbloch/wordpress-core' ||
name === 'roots/wordpress' ||
name === 'roots/bedrock-autoloader' ||
name.startsWith('wpackagist-'),
);
} catch {
return false;
}
}

/**
* A plugin declares itself with a `Plugin Name:` header in a PHP file at the
* root of its own directory. Only root-level files are read — the header is a
* plugin's entry point by definition, and globbing a whole site is expensive.
*/
export function findPluginHeaderFile(installDir: string): string | undefined {
let entries: string[];
try {
entries = fs.readdirSync(installDir);
} catch {
return undefined;
}

for (const entry of entries) {
if (!entry.endsWith('.php')) {
continue;
}

try {
const head = fs
.readFileSync(path.join(installDir, entry), 'utf-8')
.slice(0, 8192);
if (/^\s*\*?\s*Plugin Name:\s*\S/im.test(head)) {
return entry;
}
} catch {
// Unreadable file — keep looking.
}
}

return undefined;
}

/** A theme declares itself with a `Theme Name:` header in style.css. */
export function hasThemeHeader(installDir: string): boolean {
const stylePath = path.join(installDir, 'style.css');
if (!fs.existsSync(stylePath)) {
return false;
}

try {
const head = fs.readFileSync(stylePath, 'utf-8').slice(0, 8192);
return /^\s*\*?\s*Theme Name:\s*\S/im.test(head);
} catch {
return false;
}
}

export function getWordPressProjectType(
options: Pick<WizardRunOptions, 'installDir'>,
): WordPressProjectType {
const { installDir } = options;

if (hasWordPressCore(installDir) || hasComposerWordPress(installDir)) {
return WordPressProjectType.SITE;
}

if (findPluginHeaderFile(installDir)) {
return WordPressProjectType.PLUGIN;
}

return WordPressProjectType.THEME;
}

/** Reads `$wp_version` out of wp-includes/version.php. */
export function getWordPressVersion(
options: Pick<WizardRunOptions, 'installDir'>,
): string | undefined {
const versionPath = path.join(
options.installDir,
'wp-includes',
'version.php',
);

if (!fs.existsSync(versionPath)) {
return undefined;
}

try {
const content = fs.readFileSync(versionPath, 'utf-8');
return /\$wp_version\s*=\s*'([^']+)'/.exec(content)?.[1];
} catch {
return undefined;
}
}

/** "6.4.2" → "6.4.x", for analytics grouping. */
export function getWordPressVersionBucket(version: string): string {
const [major, minor] = version.split('.');
return major && minor ? `${major}.${minor}.x` : version;
}

/**
* Whether the plugins directory is writable — the wizard writes a plugin, and
* a managed host with a read-only filesystem needs different advice.
*/
export function findPluginsDir(installDir: string): string | undefined {
const candidates = [
path.join('wp-content', 'plugins'),
path.join('web', 'app', 'plugins'), // Bedrock
path.join('public', 'wp-content', 'plugins'),
];

return candidates.find((rel) => fs.existsSync(path.join(installDir, rel)));
}
152 changes: 152 additions & 0 deletions src/frameworks/wordpress/wordpress-wizard-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* WordPress wizard using posthog-agent with PostHog MCP */
import type { WizardRunOptions } from '@utils/types';
import type { FrameworkConfig } from '@lib/framework-config';
import { composerPackageManager } from '@lib/detection/package-manager';
import { Integration } from '@lib/constants';
import {
WordPressProjectType,
findPluginHeaderFile,
findPluginsDir,
getWordPressProjectType,
getWordPressProjectTypeName,
getWordPressVersion,
getWordPressVersionBucket,
hasComposerWordPress,
hasThemeHeader,
hasWordPressCore,
} from './utils';

type WordPressContext = {
projectType?: WordPressProjectType;
pluginsDir?: string;
pluginHeaderFile?: string;
};

export const WORDPRESS_AGENT_CONFIG: FrameworkConfig<WordPressContext> = {
metadata: {
name: 'WordPress',
integration: Integration.wordpress,
docsUrl: 'https://posthog.com/docs/libraries/wordpress',
unsupportedVersionDocsUrl: 'https://posthog.com/docs/libraries/php',
gatherContext: (options: WizardRunOptions) => {
const projectType = getWordPressProjectType(options);

return Promise.resolve({
projectType,
pluginsDir: findPluginsDir(options.installDir),
pluginHeaderFile: findPluginHeaderFile(options.installDir),
});
},
},

detection: {
packageName: 'posthog/posthog-php',
packageDisplayName: 'WordPress',
usesPackageJson: false,
getVersion: () => undefined,
getVersionBucket: getWordPressVersionBucket,
getInstalledVersion: (options: WizardRunOptions) =>
Promise.resolve(getWordPressVersion(options)),
detect: (options) => {
const { installDir } = options;

// A full site: wp-config.php and friends, or Composer-managed core.
if (hasWordPressCore(installDir) || hasComposerWordPress(installDir)) {
return Promise.resolve(true);
}

// A single plugin or theme directory, worked on outside a site tree.
if (findPluginHeaderFile(installDir) || hasThemeHeader(installDir)) {
return Promise.resolve(true);
}

return Promise.resolve(false);
},
detectPackageManager: composerPackageManager,
},

environment: {
uploadToHosting: false,
getEnvVars: (apiKey: string, host: string) => ({
POSTHOG_PROJECT_TOKEN: apiKey,
POSTHOG_HOST: host,
}),
},

analytics: {
getTags: (context) => ({
projectType: context.projectType || 'unknown',
}),
},

prompts: {
projectTypeDetection:
'This is a WordPress project. Look for wp-config.php, wp-content/, or a PHP file with a `Plugin Name:` header to confirm. Composer-managed installs (Bedrock) keep core out of the root — check composer.json for johnpbloch/wordpress or roots/wordpress.',
packageInstallation:
'Use Composer to install packages. Run `composer require posthog/posthog-php` without pinning a specific version, from inside the plugin directory that will own the dependency — not the site root.',
getAdditionalContextLines: (context) => {
const projectTypeName = context.projectType
? getWordPressProjectTypeName(context.projectType)
: 'unknown';

// Integration rules (plugin over functions.php, ABSPATH, esc_js, flush)
// live in context-mill's wordpress commandments and reach the agent with
// the skill. Only project-shape facts the skill cannot know belong here.
const lines = [
`Project type: ${projectTypeName}`,
`Framework docs ID: php (use posthog://docs/frameworks/php for documentation)`,
];

if (context.pluginsDir) {
lines.push(`Plugins directory: ${context.pluginsDir}`);
}

if (context.pluginHeaderFile) {
lines.push(
`Existing plugin entry file: ${context.pluginHeaderFile} (add to this plugin rather than creating a new one)`,
);
}

if (context.projectType === WordPressProjectType.THEME) {
lines.push(
'This directory is a theme. Prefer creating a companion plugin next to it so tracking survives a theme change; only fall back to the theme if the user insists.',
);
}

return lines;
},
},

ui: {
successMessage: 'PostHog integration complete',
estimatedDurationMinutes: 5,
getOutroChanges: (context) => {
const projectTypeName = context.projectType
? getWordPressProjectTypeName(context.projectType)
: 'WordPress project';

const changes = [
`Analyzed your ${projectTypeName}`,
'Installed the PostHog PHP package via Composer',
];

if (context.projectType === WordPressProjectType.SITE) {
changes.push('Added a PostHog plugin under wp-content/plugins');
} else {
changes.push('Added PostHog initialization to your plugin entry file');
}

changes.push(
'Wired client-side autocapture on wp_head and a server-side capture on a WordPress action',
);

return changes;
},
getOutroNextSteps: () => [
'Activate the PostHog plugin from Plugins in wp-admin',
'Load any page on the site, then check your PostHog dashboard for incoming events',
'Use PostHog::capture() inside WordPress actions to track server-side events',
'Move the project token into a wp-config.php constant before deploying',
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const INTEGRATION_ENTRIES = [
},
{ id: 'integration-tanstack-start', framework: 'tanstack-start' },
{ id: 'integration-laravel', framework: 'laravel' },
{ id: 'integration-wordpress', framework: 'wordpress' },
{ id: 'integration-php' },
{ id: 'integration-ruby-on-rails', framework: 'rails' },
{ id: 'integration-android', framework: 'android' },
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export enum Integration {
flask = 'flask',
fastapi = 'fastapi',
laravel = 'laravel',
wordpress = 'wordpress',
sveltekit = 'sveltekit',
kmp = 'kmp',
swift = 'swift',
Expand Down
Loading