Skip to content

Instantly share code, notes, and snippets.

@Braunson
Created January 31, 2025 19:30
Show Gist options
  • Save Braunson/544a4b9784c30992182bf71a079950df to your computer and use it in GitHub Desktop.
Save Braunson/544a4b9784c30992182bf71a079950df to your computer and use it in GitHub Desktop.
Twill CMS Block exporter to JSON (WIP) - Export Twill blocks both custom and default to JSON with their respective field types and names
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionMethod;
use Symfony\Component\Finder\Finder;
class ExportTwillBlocks extends Command
{
protected $signature = 'twill:export-blocks
{--output=storage/app/twill-blocks.json : Output file path}
{--page= : Export blocks for specific page only}';
protected $description = 'Export all Twill block configurations including nested and page-specific blocks';
protected $baseBlockPath = 'App\\View\\Components\\Twill\\Blocks';
protected $baseViewPath = 'views/components/twill/blocks';
public function handle()
{
$blocks = [
'default' => $this->getDefaultBlocks(),
'custom' => $this->getCustomBlocks(),
];
// Export to JSON
$output = $this->option('output');
File::put($output, json_encode($blocks, JSON_PRETTY_PRINT));
$customBlockCount = $this->countNestedBlocks($blocks['custom']);
$defaultBlockCount = count($blocks['default']);
$this->info("Blocks exported to: {$output}");
$this->info("Total custom blocks: " . $customBlockCount);
$this->info("Total default blocks: " . $defaultBlockCount);
$this->info("Total blocks: " . ($customBlockCount + $defaultBlockCount));
}
protected function countNestedBlocks(array $blocks): int
{
$count = 0;
foreach ($blocks as $key => $value) {
if (is_array($value)) {
if (isset($value['class'])) {
// This is a block
$count++;
} else {
// This is a nested structure
$count += $this->countNestedBlocks($value);
}
}
}
return $count;
}
protected function getDefaultBlocks(): array
{
$defaultBlocks = [];
// Core Twill blocks with standardized structure
$coreBlocks = [
'text' => [
'class' => 'A17\Twill\View\Components\Blocks\TwillBlockComponent',
'title' => 'Text Block',
'component' => 'a17-block-text',
'crops' => [],
'form' => [
[
'type' => 'Wysiwyg',
'name' => 'text',
'label' => 'Text Content',
'required' => true,
'translatable' => true,
'maxlength' => 65535,
'note' => 'Main text content',
'default' => 'Sample text content'
]
],
'view' => [
'path' => 'vendor/area17/twill/views/blocks/text',
'content' => null
],
'source' => 'core'
],
'image' => [
'class' => 'A17\Twill\View\Components\Blocks\TwillBlockComponent',
'title' => 'Image Block',
'component' => 'a17-block-image',
'crops' => [
'image' => [
'default' => [
[
'name' => 'default',
'ratio' => null
]
]
]
],
'form' => [
[
'type' => 'Medias',
'name' => 'image',
'label' => 'Image',
'required' => true,
'max' => 1,
'note' => 'Main image'
],
[
'type' => 'Input',
'name' => 'caption',
'label' => 'Caption',
'translatable' => true,
'maxlength' => 255,
'default' => 'Image caption'
]
],
'view' => [
'path' => 'vendor/area17/twill/views/blocks/image',
'content' => null
],
'source' => 'core'
]
];
foreach ($coreBlocks as $blockName => $block) {
$defaultBlocks[$blockName] = $block;
}
// Add any custom-configured default blocks from config
$configPaths = [
'twill.block_editor.blocks',
'twill.blocks',
'twill.block_editor.repeaters'
];
foreach ($configPaths as $path) {
$blocks = config($path, []);
if (!empty($blocks) && is_array($blocks)) {
foreach ($blocks as $blockName => $block) {
if (is_array($block)) {
$defaultBlocks["custom_{$blockName}"] = [
'class' => $block['class'] ?? 'A17\Twill\View\Components\Blocks\TwillBlockComponent',
'title' => $block['title'] ?? Str::title($blockName),
'component' => $block['component'] ?? null,
'crops' => $block['crops'] ?? [],
'form' => $this->processDefaultBlockFields($block['fields'] ?? []),
'view' => [
'path' => $block['view'] ?? null,
'content' => null
],
'source' => $path
];
}
}
}
}
return $defaultBlocks;
}
protected function processDefaultBlockFields(array $fields): array
{
$processed = [];
foreach ($fields as $fieldName => $field) {
if (is_array($field)) {
$processed[] = [
'type' => ucfirst($field['type'] ?? 'Unknown'),
'name' => $fieldName,
'label' => $field['label'] ?? Str::title($fieldName),
'note' => $field['note'] ?? null,
'required' => $field['required'] ?? false,
'translatable' => $field['translatable'] ?? false,
'maxlength' => $field['maxlength'] ?? null,
'max' => $field['max'] ?? null,
'options' => $field['options'] ?? null,
'default' => $this->generateDefaultValue($field['type'], $fieldName)
];
}
}
return $processed;
}
protected function generateDefaultValue(string $type, string $fieldName): mixed
{
switch (strtolower($type)) {
case 'text':
case 'textarea':
case 'input':
return "Sample {$fieldName}";
case 'wysiwyg':
return "<p>Sample content for {$fieldName}</p>";
case 'select':
case 'radio':
return null; // Will be populated based on options
case 'checkbox':
return false;
case 'number':
return 0;
case 'date':
return now()->format('Y-m-d');
default:
return null;
}
}
protected function getCustomBlocks(): array
{
$blocks = [];
$finder = new Finder();
// Get all PHP files in the blocks directory
$blockFiles = $finder
->files()
->name('*.php')
->in(app_path('View/Components/Twill/Blocks'));
foreach ($blockFiles as $file) {
$relativePath = $file->getRelativePath();
$className = $file->getBasename('.php');
// Build full class name
$fullClassName = $this->baseBlockPath . '\\';
if ($relativePath) {
$fullClassName .= str_replace('/', '\\', $relativePath) . '\\';
}
$fullClassName .= $className;
// Skip if class doesn't exist
if (!class_exists($fullClassName)) {
$this->warn("Class {$fullClassName} not found, skipping...");
continue;
}
// If page filter is set, only process matching blocks
if ($this->option('page') && !Str::contains($relativePath, $this->option('page'))) {
continue;
}
try {
$blockInfo = $this->extractBlockInfo($fullClassName, $relativePath, $className);
// Organize blocks by page/category if they're in subfolders
if ($relativePath) {
$category = str_replace('/', '.', $relativePath);
if (!isset($blocks[$category])) {
$blocks[$category] = [];
}
$blocks[$category][$className] = $blockInfo;
} else {
$blocks['global'][$className] = $blockInfo;
}
} catch (\Exception $e) {
$this->error("Error processing {$className}: " . $e->getMessage());
continue;
}
}
return $blocks;
}
protected function extractBlockInfo(string $className, string $relativePath, string $baseClassName): array
{
$reflection = new ReflectionClass($className);
$instance = $reflection->newInstanceWithoutConstructor();
// Get form fields by analyzing the getForm method
$form = $this->extractFormFieldsFromMethod($reflection);
// Get view path
$viewPath = $this->baseViewPath;
if ($relativePath) {
$viewPath .= "/{$relativePath}";
}
$viewPath .= "/{$baseClassName}";
// Get view content if it exists
$viewContent = null;
$viewFile = resource_path($viewPath . '.blade.php');
if (File::exists($viewFile)) {
$viewContent = File::get($viewFile);
}
// Get crops configuration
$crops = [];
if (method_exists($instance, 'getCrops')) {
$crops = $instance->getCrops();
}
return [
'class' => $className,
'title' => $this->getBlockTitle($instance),
'crops' => $crops,
'form' => $form,
'view' => [
'path' => $viewPath,
'content' => $viewContent,
],
];
}
protected function extractFormFieldsFromMethod(ReflectionClass $reflection): array
{
if (!$reflection->hasMethod('getForm')) {
return [];
}
try {
$method = $reflection->getMethod('getForm');
$code = $this->getMethodBody($method);
// Parse the form fields from the method body
$fields = [];
// Match form field definitions using regex
// This pattern captures the entire chain of method calls
// First pass: get all form fields with their full method chains
preg_match_all('/(?:Input|Medias|Wysiwyg|Radios|Select|BlockEditor|Files|MultiSelect)::make\(\)((?:->(?:[a-zA-Z]+)\([^\)]+\))*)[,;\n]/x', $code, $matches, PREG_SET_ORDER);
// If no matches found, try alternate pattern that might catch variable assignments
if (empty($matches)) {
preg_match_all('/(?:Input|Medias|Wysiwyg|Radios|Select|BlockEditor|Files|MultiSelect)::make\(\).*?(?:;|\])/s', $code, $matches, PREG_SET_ORDER);
}
foreach ($matches as $match) {
$methodChain = $match[1] ?? '';
$field = [
'type' => $this->extractFieldType($match[0])
];
// Try to infer name from context if not explicitly set
if (preg_match('/\$([a-zA-Z0-9_]+)\s*=/', $match[0], $varMatch)) {
$field['name'] = $varMatch[1];
}
// Extract name
if (preg_match('/->name\([\'"]([^\'"]+)[\'"]\)/', $methodChain, $nameMatch)) {
$field['name'] = $nameMatch[1];
}
// Extract label
if (preg_match('/->label\([\'"]([^\'"]+)[\'"]\)/', $methodChain, $labelMatch)) {
$field['label'] = $labelMatch[1];
}
// Extract notes
if (preg_match('/->note\([\'"]([^\'"]+)[\'"]\)/', $methodChain, $noteMatch)) {
$field['note'] = $noteMatch[1];
}
if (preg_match('/->fieldNote\([\'"]([^\'"]+)[\'"]\)/', $methodChain, $fieldNoteMatch)) {
$field['fieldNote'] = $fieldNoteMatch[1];
}
// Extract max value
if (preg_match('/->max\((\d+)\)/', $methodChain, $maxMatch)) {
$field['max'] = (int)$maxMatch[1];
}
// Extract options
if (preg_match('/->options\((.*?)\)/', $methodChain, $optionsMatch)) {
$optionsStr = $optionsMatch[1];
if (preg_match('/\[(.*?)\]/', $optionsStr, $arrayMatch)) {
$field['options'] = array_map(
function($item) { return trim($item, ' \'"'); },
explode(',', $arrayMatch[1])
);
} elseif (preg_match('/\{(.*?)\}/', $optionsStr, $objMatch)) {
$pairs = explode(',', $objMatch[1]);
$options = [];
foreach ($pairs as $pair) {
if (strpos($pair, '=>') !== false) {
list($key, $value) = array_map(
function($item) { return trim($item, ' \'"'); },
explode('=>', $pair)
);
$options[$key] = $value;
}
}
$field['options'] = $options;
}
}
$fields[] = $field;
}
return $fields;
} catch (\Exception $e) {
$this->warn("Error extracting form fields: " . $e->getMessage());
return [];
}
}
protected function extractFieldType(string $definition): string
{
if (preg_match('/^(\w+)::make/', $definition, $matches)) {
return $matches[1];
}
return 'Unknown';
}
protected function getMethodBody(ReflectionMethod $method): string
{
$filename = $method->getFileName();
$start_line = $method->getStartLine() - 1;
$end_line = $method->getEndLine();
$length = $end_line - $start_line;
$source = file($filename);
return implode('', array_slice($source, $start_line, $length));
}
protected function getBlockTitle($instance): string
{
return method_exists($instance, 'getBlockTitle')
? $instance->getBlockTitle(null) // Pass null as Block instance
: class_basename($instance);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment