Mi configuración de Dev Container para desarrollar plugins de WordPress

Los que me seguís por aquí sabéis que este blog es mi bloc de notas. Aquí voy dejando todo lo que pruebo e implemento para poder volver a ello cuando lo necesite. Y durante mucho tiempo la única que lo consultaba era yo 😅. Pero últimamente eso ha cambiado: ahora también lo consultan mis agentes de IA. Cuando tengo que volver a implementar algo que ya hice, les paso el enlace al artículo y se ponen al día ellos solos.

El problema es que los agentes trabajan mucho mejor con Markdown que con HTML lleno de clases CSS, menús, sidebars y estructuras de tema. Así que me puse a crear un plugin de WordPress que convierte los posts y las páginas en Markdown limpio para que los agentes puedan consumirlos directamente. El plugin se llama Markdown View for AI Agents y a día de hoy todavía está en revisión por WordPress.org, así que ya os contaré más cuando esté publicado. Aunque si eres curios@ ya lo puedes ver funcionando en este mismo blog 👆🏻😇: en cada artículo verás un botón que pone View as Markdown y voilà, el contenido pasa a ser algo mucho más manejable para nuestros amigos los agentes.

Pero este artículo, más allá de mi plugin, de lo que va es del entorno que he utilizado para desarrollarlo, probarlo y verificar que cumple con todos los requisitos antes de hacer la submission. Y como no podía ser de otra manera, la respuesta fue: Dev Containers.

Si no sabes de qué van los Dev Containers te dejo este vídeo que grabé hace tiempo para mi canal de YouTube:

🎯 El problema

Desarrollar un plugin de WordPress no es como hacer un npm init y a correr. Necesitas:

  • Una instancia de WordPress funcionando
  • PHP con las extensiones correctas
  • MySQL/MariaDb como base de datos
  • WP-CLI para gestionar WordPress desde la terminal
  • Composer para las dependencias PHP
  • PHP_CodeSniffer con los estándares de WordPress

Configurar todo esto en tu máquina local es un dolor. Y si trabajas en más de un proyecto, los conflictos entre versiones son inevitables. Así que lo metí todo en un Dev Container 🎉

📦 La estructura

La configuración vive en la carpeta .devcontainer/ del repositorio:

.devcontainer/
├── devcontainer.json
├── Dockerfile
├── docker-compose.yml
└── setup.sh

Vamos parte por parte.

🐳 Docker Compose

En el docker compose que utilizará mi configuración de Dev Container para levantar los servicios incluí tres contenedores:

services:
  workspace:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ..:/workspaces/wp-markdown-for-agents:cached
      - composer-cache:/home/vscode/.composer/cache
      - wordpress-data:/srv/wordpress
      - plugins:/home/vscode/.local/share/plugins
      - plugins:/srv/wordpress/wp-content/plugins
      - ..:/srv/wordpress/wp-content/plugins/markdown-view-for-ai-agents:cached
    environment:
      COMPOSER_MEMORY_LIMIT: -1
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
    command: sleep infinity
    depends_on:
      - db
      - wordpress
  wordpress:
    image: wordpress:php8.3-apache
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
    volumes:
      - wordpress-data:/var/www/html
      - plugins:/var/www/html/wp-content/plugins
      - ..:/var/www/html/wp-content/plugins/markdown-view-for-ai-agents:cached
    depends_on:
      - db
  db:
    image: mariadb:11.4
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: wordpress
      MARIADB_ROOT_PASSWORD: root
    volumes:
      - mariadb-data:/var/lib/mysql
volumes:
  composer-cache:
  plugins:
  wordpress-data:
  mariadb-data:

El primero de ellos, workspace, es el contenedor principal, en el que estaré desarrollando mi plugin. Los otros dos son los contenedores que me facilitarán una instancia de WordPress, con su correspondiente base de datos, para poder probarlo de forma súper sencilla.

La clave aquí está en el bind mount del plugin. Al montar el directorio del proyecto directamente en wp-content/plugins/, cualquier cambio que hago en el código se refleja inmediatamente en la instancia de WordPress. No hay que copiar archivos, ni reconstruir nada. Edito, guardo, refresco el navegador y listo.

🏗️ El Dockerfile

Para que el contenedor llamado workspace funcione correctamente está esperando poder generar una imagen de Docker, para la que va a usar el Dockerfile que se encuentra en el mismo directorio, .devcontainer:

FROM mcr.microsoft.com/devcontainers/php:3-8.3-trixie
RUN apt-get update \
	&& export DEBIAN_FRONTEND=noninteractive \
	&& apt-get install -y --no-install-recommends default-mysql-client libmariadb-dev librsvg2-bin \
	&& docker-php-ext-install mysqli pdo_mysql \
	&& curl -fsSL -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
	&& chmod +x /usr/local/bin/wp \
	&& mkdir -p /home/vscode/.local/share/plugins \
	&& chown -R vscode:vscode /home/vscode/.local \
	&& apt-get clean \
	&& rm -rf /var/lib/apt/lists/*

Con esto tengo:

  • PHP 8.3 en el contenedor donde realmente estoy desarrollando mi plugin
  • Cliente de MySQL
  • La librería librsvg2-bin, la cual me va a permitir de forma sencilla poder convertir imágenes svg a png.
  • WP-CLI para gestionar WordPress desde la terminal (instalar plugins, crear posts de prueba, activar/desactivar el plugin…). Porque no me vale tener el WordPress pelado sino que es mucho más rápido tener ya artículos con los que poder probar el botón/URL en mi caso.

⚙️ El devcontainer.json

Aquí es donde se conecta todo. El devcontainer.json le dice a VS Code qué servicio de Docker Compose usar, qué extensiones instalar, qué puertos reenviar y qué ejecutar después de crear el contenedor:

// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/php
{
	"name": "🧪 markdown-view-for-ai-agents",
	"dockerComposeFile": "compose.yml",
	"service": "workspace",
	"runServices": [
		"workspace",
		"wordpress",
		"db"
	],
	"workspaceFolder": "/workspaces/wp-markdown-for-agents",
	"shutdownAction": "stopCompose",
	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	"forwardPorts": [
		8080
	],
	"portsAttributes": {
		"8080": {
			"label": "📰 WordPress",
			"onAutoForward": "notify"
		}
	},
	"postCreateCommand": "./install-git-hooks.sh",
	"postStartCommand": "bash .devcontainer/post-start.sh",
	"customizations": {
		"vscode": {
			"settings": {
				"php.validate.executablePath": "/usr/local/bin/php",
				"files.associations": {
					"*.inc": "php"
				},
				"[php]": {
					"editor.formatOnSave": false
				},
				"intelephense.environment.phpVersion": "8.3.0",
				"intelephense.files.exclude": [
					"vendor/**"
				],
				"search.exclude": {
					"vendor": true
				}
			},
			"extensions": [
				"GitHub.vscode-github-actions",
				"bmewburn.vscode-intelephense-client",
				"DEVSENSE.composer-php-vscode",
				"xdebug.php-debug"
			]
		}
	},
	"features": {
		"ghcr.io/devcontainers/features/github-cli:1": {}
	}
}

Las extensiones que incluyo son las que uso para desarrollar en PHP con WordPress, pero aquí cada uno puede añadir las suyas. Lo importante es que al abrir el proyecto todo el mundo tiene el mismo entorno con las mismas herramientas.

🚀 El script de setup

Y aquí viene la magia para que el WordPress de pruebas esté configurado tal y como lo necesitas: El postCreateCommand ejecuta un script que se encarga de dejarlo todo listo:

#!/usr/bin/env bash
set -eu
wp_path="/srv/wordpress"
site_url="http://localhost:8080"
sample_seed_option="md_for_agents_sample_content_seeded"
plugin_check_dir="/home/vscode/.local/share/plugins/plugin-check"
plugin_check_main_file="$plugin_check_dir/plugin-check.php"
plugin_check_cli_file="$plugin_check_dir/cli.php"
export XDEBUG_MODE=off
wp_cli() {
	wp --path="$wp_path" "$@"
}
ensure_plugin_check_files() {
	mkdir -p "$plugin_check_dir"
	if [ -f "$plugin_check_main_file" ] && [ -f "$plugin_check_cli_file" ]; then
		return 0
	fi
	tmp_dir="$(mktemp -d)"
	archive="$tmp_dir/plugin-check.zip"
	extracted_dir="$tmp_dir/plugin-check"
	cleanup_plugin_check_tmp() {
		rm -rf "$tmp_dir"
	}
	trap cleanup_plugin_check_tmp RETURN
	curl -fsSL -o "$archive" "https://downloads.wordpress.org/plugin/plugin-check.latest-stable.zip"
	unzip -q "$archive" -d "$tmp_dir"
	find "$plugin_check_dir" -mindepth 1 -maxdepth 1 ! -name '.gitkeep' -exec rm -rf {} +
	cp -R "$extracted_dir"/. "$plugin_check_dir"/
	trap - RETURN
	cleanup_plugin_check_tmp
}
upsert_post() {
	post_type="$1"
	post_slug="$2"
	post_title="$3"
	post_content="$4"
	post_id="$(wp_cli post list --post_type="$post_type" --name="$post_slug" --field=ID 2>/dev/null | head -n 1 || true)"
	if [ -n "$post_id" ]; then
		wp_cli post update "$post_id" \
			--post_title="$post_title" \
			--post_name="$post_slug" \
			--post_content="$post_content" >/dev/null
		echo "$post_id"
		return 0
	fi
	wp_cli post create \
		--post_type="$post_type" \
		--post_status=publish \
		--post_title="$post_title" \
		--post_name="$post_slug" \
		--post_content="$post_content" \
		--porcelain
}
set_post_terms() {
	post_slug="$1"
	taxonomy="$2"
	terms_csv="$3"
	post_id="$(wp_cli post list --post_type=post --name="$post_slug" --field=ID 2>/dev/null | head -n 1 || true)"
	if [ -z "$post_id" ]; then
		return 0
	fi
	wp_cli post term set "$post_id" "$taxonomy" "$terms_csv" >/dev/null
}
seed_sample_content() {
	getting_started_page_id="$(upsert_post \
		page \
		getting-started-markdown-agents \
		'Getting Started with Markdown for Agents' \
		"<!-- wp:paragraph {\"fontSize\":\"large\"} --><p class=\"has-large-font-size\">This local page exists to test the plugin output quickly from a realistic WordPress page.</p><!-- /wp:paragraph --><!-- wp:heading {\"level\":2} --><h2>Checklist</h2><!-- /wp:heading --><!-- wp:list --><ul><li>WordPress installed through WP-CLI</li><li>Plugin activated automatically</li><li>Sample content seeded idempotently</li></ul><!-- /wp:list --><!-- wp:heading {\"level\":2} --><h2>Example prompt</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Summarize this page for an autonomous coding agent.</p><!-- /wp:paragraph -->")"
	upsert_post \
		post \
		release-notes-draft \
		'Release Notes Draft' \
		"<!-- wp:heading {\"level\":1} --><h1>Release Notes Draft</h1><!-- /wp:heading --><!-- wp:heading {\"level\":2} --><h2>Highlights</h2><!-- /wp:heading --><!-- wp:list --><ul><li>Automated WordPress bootstrap</li><li>Local plugin activation</li><li>Repeatable demo content</li></ul><!-- /wp:list --><!-- wp:heading {\"level\":2} --><h2>Pending</h2><!-- /wp:heading --><!-- wp:list {\"ordered\":true} --><ol><li>Verify markdown export output</li><li>Review button placement in the editor</li><li>Package the release zip</li></ol><!-- /wp:list -->" >/dev/null
	upsert_post \
		post \
		agent-qa-scenario \
		'Agent QA Scenario' \
		"<!-- wp:heading {\"level\":2} --><h2>Scenario</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Use this post to test how the plugin exposes headings, lists, and links for agents.</p><!-- /wp:paragraph --><!-- wp:list --><ul><li>Visit the generated markdown endpoint</li><li>Confirm headings remain stable</li><li>Confirm links remain absolute</li></ul><!-- /wp:list --><!-- wp:paragraph --><p>Related page ID: ${getting_started_page_id}</p><!-- /wp:paragraph -->" >/dev/null
	upsert_post \
		post \
		headings-lists-and-frontmatter \
		'Headings, Lists, and Frontmatter' \
		"<!-- wp:paragraph --><p>This post exists to check heading depth, ordered lists, unordered lists, and YAML frontmatter metadata.</p><!-- /wp:paragraph --><!-- wp:heading {\"level\":2} --><h2>Acceptance checklist</h2><!-- /wp:heading --><!-- wp:list --><ul><li>Heading levels remain stable</li><li>Bullet items stay as bullet items</li><li>Categories and tags appear in the frontmatter</li></ul><!-- /wp:list --><!-- wp:heading {\"level\":3} --><h3>Release steps</h3><!-- /wp:heading --><!-- wp:list {\"ordered\":true} --><ol><li>Run linting</li><li>Package the plugin</li><li>Smoke-test the markdown endpoint</li></ol><!-- /wp:list -->" >/dev/null
	upsert_post \
		post \
		links-quotes-and-inline-code \
		'Links, Quotes, and Inline Code' \
		"<!-- wp:paragraph --><p>Use this example to verify how inline links and inline code are represented in the markdown output.</p><!-- /wp:paragraph --><!-- wp:quote --><blockquote class=\"wp-block-quote\"><p>Agents work best when the source content is explicit, stable, and easy to parse.</p><cite>Local demo note</cite></blockquote><!-- /wp:quote --><!-- wp:paragraph --><p>Open the <a href=\"http://localhost:8080/getting-started-markdown-agents/\">getting started page</a> and compare the endpoint output to <code>wp post get</code>.</p><!-- /wp:paragraph -->" >/dev/null
	upsert_post \
		post \
		code-blocks-and-tables \
		'Code Blocks and Tables' \
		"<!-- wp:paragraph --><p>This sample covers fenced code blocks and HTML tables converted into markdown tables.</p><!-- /wp:paragraph --><!-- wp:code --><pre class=\"wp-block-code\"><code lang=\"bash\">wp plugin activate markdown-view-for-ai-agents --path=/srv/wordpress
wp option get siteurl --path=/srv/wordpress</code></pre><!-- /wp:code --><!-- wp:table --><figure class=\"wp-block-table\"><table><thead><tr><th>Check</th><th>Expected result</th></tr></thead><tbody><tr><td>Code fence</td><td>Preserved with bash language</td></tr><tr><td>Table headers</td><td>Rendered as markdown table header row</td></tr></tbody></table></figure><!-- /wp:table -->" >/dev/null
	upsert_post \
		post \
		images-and-mixed-formatting \
		'Images and Mixed Formatting' \
		"<!-- wp:paragraph --><p>This example mixes <strong>bold text</strong>, <em>emphasis</em>, and an image with alt text for markdown conversion checks.</p><!-- /wp:paragraph --><!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} --><figure class=\"wp-block-image size-large\"><img src=\"https://wordpress.org/style/images/wp-header-logo.png\" alt=\"WordPress logo\" /></figure><!-- /wp:image --><!-- wp:paragraph --><p>Verify that the generated markdown keeps the image URL and preserves inline emphasis correctly.</p><!-- /wp:paragraph -->" >/dev/null
	set_post_terms headings-lists-and-frontmatter category "Demo Content,Frontmatter"
	set_post_terms headings-lists-and-frontmatter post_tag "headings,lists,metadata"
	set_post_terms links-quotes-and-inline-code category "Demo Content,Links"
	set_post_terms links-quotes-and-inline-code post_tag "quotes,links,inline-code"
	set_post_terms code-blocks-and-tables category "Demo Content,Code"
	set_post_terms code-blocks-and-tables post_tag "code-blocks,tables"
	set_post_terms images-and-mixed-formatting category "Demo Content,Media"
	set_post_terms images-and-mixed-formatting post_tag "images,bold,italic"
	hello_world_id="$(wp_cli post list --post_type=post --name=hello-world --field=ID 2>/dev/null | head -n 1 || true)"
	if [ -n "$hello_world_id" ]; then
		wp_cli post delete "$hello_world_id" --force >/dev/null
	fi
	sample_page_id="$(wp_cli post list --post_type=page --name=sample-page --field=ID 2>/dev/null | head -n 1 || true)"
	if [ -n "$sample_page_id" ]; then
		wp_cli post delete "$sample_page_id" --force >/dev/null
	fi
	privacy_policy_id="$(wp_cli post list --post_type=page --name=privacy-policy --field=ID 2>/dev/null | head -n 1 || true)"
	if [ -n "$privacy_policy_id" ]; then
		wp_cli option delete wp_page_for_privacy_policy >/dev/null 2>&1 || true
		wp_cli post delete "$privacy_policy_id" --force >/dev/null
	fi
	wp_cli option update show_on_front posts >/dev/null
	wp_cli option delete page_on_front >/dev/null 2>&1 || true
	wp_cli option delete page_for_posts >/dev/null 2>&1 || true
	if wp_cli option get "$sample_seed_option" >/dev/null 2>&1; then
		wp_cli option update "$sample_seed_option" 1 >/dev/null
	else
		wp_cli option add "$sample_seed_option" 1 >/dev/null
	fi
}
if [ ! -d "$wp_path" ]; then
	exit 0
fi
attempt=1
max_attempts=30
while [ "$attempt" -le "$max_attempts" ]; do
	if [ -f "$wp_path/wp-config.php" ]; then
		break
	fi
	sleep 2
	attempt=$((attempt + 1))
done
if [ ! -f "$wp_path/wp-config.php" ]; then
	if [ -n "${WORDPRESS_DB_HOST:-}" ] && [ -n "${WORDPRESS_DB_NAME:-}" ] && [ -n "${WORDPRESS_DB_USER:-}" ] && [ -n "${WORDPRESS_DB_PASSWORD:-}" ]; then
		wp config create \
			--path="$wp_path" \
			--dbname="$WORDPRESS_DB_NAME" \
			--dbuser="$WORDPRESS_DB_USER" \
			--dbpass="$WORDPRESS_DB_PASSWORD" \
			--dbhost="$WORDPRESS_DB_HOST" \
			--skip-check \
			--force >/dev/null 2>&1 || true
	fi
	if [ ! -f "$wp_path/wp-config.php" ]; then
		echo "WordPress config is not ready yet; skipping local bootstrap."
		exit 0
	fi
fi
installed=0
attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
	if wp_cli core is-installed >/dev/null 2>&1; then
		installed=1
		break
	fi
	if wp_cli core install \
		--url="$site_url" \
		--title="Markdown View for AI Agents" \
		--admin_user="admin" \
		--admin_password="admin" \
		--admin_email="admin@example.com" \
		--skip-email >/dev/null 2>&1; then
		installed=1
		break
	fi
	sleep 2
	attempt=$((attempt + 1))
done
if [ "$installed" -eq 0 ]; then
	echo "WordPress database bootstrap is not ready yet; skipping local site installation."
	exit 0
fi
ensure_plugin_check_files
wp_cli option update blog_public 0 >/dev/null 2>&1 || true
wp_cli rewrite structure '/%postname%/' >/dev/null 2>&1 || true
wp_cli rewrite flush --hard >/dev/null 2>&1 || true
wp_cli theme activate twentytwentythree >/dev/null 2>&1 || true
wp_cli plugin activate markdown-view-for-ai-agents >/dev/null 2>&1 || true
wp_cli plugin activate plugin-check >/dev/null 2>&1 || true
seed_sample_content

Si, lo sé, es un poco largo y un poco complicado leerlo directamente desde el artículo, pero es básicamente el que me va a permitir que cuando abro el proyecto, en unos segundos tengo un WordPress funcionando con el plugin activado y contenido de prueba para poder probarlo. No tengo que hacer nada manual.

🔍 PHP_CodeSniffer y WordPress Coding Standards

Si quieres subir un plugin al directorio oficial de WordPress.org, el proceso de revisión es bastante estricto. El código tiene que seguir los WordPress Coding Standards. Y para eso, PHP_CodeSniffer es imprescindible.

El proyecto usa Composer para gestionar estas dependencias:

{
  "name": "0gis0/markdown-view-for-agents",
  "description": "Development tooling for the Markdown View for AI Agents WordPress plugin.",
  "type": "project",
  "require": {},
  "require-dev": {
    "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
    "phpcompatibility/phpcompatibility-wp": "^2.1",
    "squizlabs/php_codesniffer": "^3.10",
    "wp-coding-standards/wpcs": "^3.1"
  },
  "config": {
    "allow-plugins": {
      "dealerdirect/phpcodesniffer-composer-installer": true
    },
    "sort-packages": true
  },
  "scripts": {
    "lint": "phpcs",
    "lint:php": "phpcs --standard=phpcs.xml.dist"
  }
}

Y el archivo phpcs.xml.dist define las reglas que se aplican al proyecto:

<?xml version="1.0"?>
<ruleset name="Markdown View for AI Agents">
  <description>PHP quality and security checks for the plugin.</description>
  <file>markdown-view-for-ai-agents.php</file>
  <file>includes</file>
  <exclude-pattern>vendor/*</exclude-pattern>
  <exclude-pattern>dist/*</exclude-pattern>
  <exclude-pattern>.wordpress-org/*</exclude-pattern>
  <exclude-pattern>.github/*</exclude-pattern>
  <arg name="basepath" value="."/>
  <arg name="extensions" value="php"/>
  <arg value="sp"/>
  <config name="testVersion" value="7.4-"/>
  <rule ref="WordPress-Core"/>
  <rule ref="WordPress-Docs"/>
  <rule ref="WordPress-Extra"/>
  <rule ref="WordPress.Security"/>
  <rule ref="PHPCompatibilityWP"/>
  <rule ref="WordPress.NamingConventions.PrefixAllGlobals">
    <properties>
      <property name="prefixes" type="array">
        <element value="md_for_agents"/>
      </property>
    </properties>
  </rule>
  <rule ref="WordPress.WP.EnqueuedResourceParameters.NotInFooter">
    <exclude name="WordPress.WP.EnqueuedResourceParameters.MissingVersion"/>
  </rule>
</ruleset>

Al principio es frustrante porque te marca cosas que parecen menores (como el formato de los comentarios o los espacios dentro de los paréntesis), pero te fuerza a escribir código limpio y consistente. Y sobre todo, te ahorra que te rechacen el plugin en la revisión 😅.

Todo esto se instala con composer install que forma parte del postCreateCommand del Dev Container. Así que al abrir el proyecto ya tienes el linter configurado y listo para usar.

🎣 Git Hooks

Igual que en el artículo sobre pre-commit hooks en Java, aquí también he configurado un pre-commit hook que ejecuta PHP_CodeSniffer automáticamente antes de cada commit. Si el código no pasa el linter, el commit se bloquea:

#!/usr/bin/env bash
set -euo pipefail
if ! command -v php >/dev/null 2>&1; then
  echo "PHP is not installed. Skipping PHP pre-commit checks."
  exit 0
fi
staged_php_files="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)"
if [[ -z "$staged_php_files" ]]; then
  echo "No staged PHP files."
  exit 0
fi
echo "Running PHP syntax checks on staged files..."
while IFS= read -r file; do
  [[ -z "$file" ]] && continue
  php -l "$file" >/dev/null
done <<< "$staged_php_files"
if [[ -x "vendor/bin/phpcs" ]]; then
  echo "Running PHPCS on staged files..."
  vendor/bin/phpcs --standard=phpcs.xml.dist $staged_php_files
else
  echo "PHPCS is not installed. Run 'composer install' to enable quality and security checks."
fi

El hook vive en .githooks/ y se instala con el script install-git-hooks.sh, que también se ejecuta como parte del setup del Dev Container. Así, al clonar y abrir el proyecto, los hooks ya están activos. Los desarrolladores no tienen que hacer nada manual.

🔄 GitHub Actions

Pero no se trata solo de validar en local. Para mí es igual de importante que estas mismas reglas se ejecuten en el CI, de forma que todo el código que llegue al repositorio siga exactamente el mismo estilo y formato. Si el lint pasa en tu máquina pero no se valida en el pipeline, al final cada uno acaba formateando como quiere y las reglas se convierten en papel mojado.

Así que el repositorio también tiene un workflow de CI en GitHub Actions que ejecuta PHP_CodeSniffer en cada push y pull request. Las mismas reglas, el mismo resultado.

💡 Lo que aprendí

Algunas cosas que me llevo:

WP-CLI es imprescindible en el Dev Container. Poder instalar WordPress, activar plugins, crear contenido de prueba y configurar permalinks desde un script automatizado hace que el setup sea completamente hands-off.

El bind mount del plugin es la clave. Montar el directorio del proyecto directamente en wp-content/plugins/ elimina cualquier fricción entre el código que editas y lo que WordPress ejecuta.

El readme.txt de WordPress.org no es un README normal. Tiene un formato específico con secciones predefinidas que el directorio parsea para mostrar la información del plugin. Es diferente del README.md de GitHub, así que necesitas mantener los dos.

PHP_CodeSniffer con WordPress Coding Standards es estricto pero necesario. Te fuerza a escribir código limpio y consistente desde el primer momento. Y tenerlo integrado en el Dev Container, en los git hooks y en el CI hace que no haya escapatoria 😄.

🚀 Cómo probarlo

Si quieres usar esta misma configuración para tu propio plugin de WordPress, puedes echar un vistazo a cómo lo tengo montado en el repositorio:

👉 github.com/0GiS0/markdown-view-for-agents

Clona el repo, ábrelo en VS Code y te preguntará si quieres reabrir el proyecto en el contenedor. Acepta y en unos minutos tendrás un WordPress funcionando con el plugin listo para probar. También puedes abrirlo directamente en GitHub Codespaces desde el repositorio, sin necesidad de tener Docker instalado en tu máquina. Si quieres saber más sobre Codespaces aquí tienes otro vídeo de mi canal de YouTube 😊

Y ahora crucemos los dedos a ver si me aceptan el plugin 🤞🏻

¡Nos vemos 👋🏻!

2 Comments

  1. gerardoparrajc agosto 18, 2026 at 1:25 am

    Hola, Gisela. Hace tiempo vi tu vídeo para montar el entorno de desarrollo con Dev Containers y, tras haber conseguido con éxito montar uno para Java Spring Boot, y otros para aplicaciones Angular, ahora me encuentro con la necesidad de crear uno para WordPress. Leyendo tu post, veo que lo haces con archivos de forma «manual». ¿Se puede hacer todo esto con el asistente de VSCode, de la misma forma que lo haces en el vídeo con una API en .Net?

    Muchas gracias.

    Reply
    1. Gisela Torres agosto 18, 2026 at 5:32 pm

      ¡Hola Gerardo 👋🏻! Muchas gracias por tu comentario! No hay una plantilla para WordPress como tal que podamos utilizar 🥺, al menos como yo lo planteo aquí.
      Por eso me pareció interesante compartirlo 😃 Saludos 👋🏻

      Reply

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.