1252 lines
47 KiB
PHP
1252 lines
47 KiB
PHP
#!/usr/bin/env php
|
||
<?php
|
||
/**
|
||
* Raiju HTML-to-Editable Converter
|
||
*
|
||
* Converts a static HTML page into a Raiju-editable PHP template + JSON content file.
|
||
*
|
||
* Usage:
|
||
* php cli/convert.php input.html [--slug=NAME] [--lang=CODE] [--title=TEXT]
|
||
* [--description=TEXT] [--dry-run] [--json-only] [--force]
|
||
*/
|
||
|
||
// Command-line tools must never run as a web request, even where a server ignores .htaccess.
|
||
if (PHP_SAPI !== 'cli' && isset($_SERVER['REQUEST_METHOD'])) {
|
||
http_response_code(404);
|
||
exit;
|
||
}
|
||
|
||
// Must run from project root
|
||
$projectRoot = dirname(__DIR__);
|
||
require_once $projectRoot . '/includes/page-path.php';
|
||
if (!file_exists($projectRoot . '/route.php')) {
|
||
fwrite(STDERR, "Error: Run this script from the Raiju project root.\n");
|
||
fwrite(STDERR, " cd /path/to/raiju && php cli/convert.php input.html\n");
|
||
exit(1);
|
||
}
|
||
|
||
// ── CLI argument parsing ──────────────────────────────────────────────
|
||
|
||
// Manual parsing because PHP getopt() stops at first non-option argument
|
||
$opts = [];
|
||
$args = [];
|
||
for ($i = 1; $i < count($argv); $i++) {
|
||
$a = $argv[$i];
|
||
if (preg_match('/^--([a-z-]+)=(.+)$/', $a, $m)) {
|
||
$opts[$m[1]] = $m[2];
|
||
} elseif (preg_match('/^--([a-z-]+)$/', $a, $m)) {
|
||
$opts[$m[1]] = true;
|
||
} elseif ($a[0] !== '-') {
|
||
$args[] = $a;
|
||
}
|
||
}
|
||
|
||
if (isset($opts['help']) || empty($args)) {
|
||
echo <<<USAGE
|
||
Raiju HTML-to-Editable Converter
|
||
|
||
Usage:
|
||
php cli/convert.php <input.html> [options]
|
||
|
||
Options:
|
||
--slug=NAME Page slug (default: derived from filename)
|
||
--lang=CODE Language code (default: en)
|
||
--title=TEXT Page title (default: from <title> or first <h1>)
|
||
--description=TEXT SEO description (default: from <meta description>)
|
||
--dry-run Show what would be generated without writing files
|
||
--json-only Only generate JSON content file, no PHP template
|
||
--no-css Skip CSS extraction
|
||
--force Overwrite existing files
|
||
--help Show this help
|
||
|
||
Examples:
|
||
php cli/convert.php landing-page.html --slug=landing --lang=en
|
||
php cli/convert.php about.html --dry-run
|
||
php cli/convert.php index.html --slug=home --title="Welcome"
|
||
|
||
USAGE;
|
||
exit(0);
|
||
}
|
||
|
||
$inputFile = $args[0];
|
||
if (!file_exists($inputFile)) {
|
||
fwrite(STDERR, "Error: File not found: $inputFile\n");
|
||
exit(1);
|
||
}
|
||
|
||
$slug = $opts['slug'] ?? pathinfo($inputFile, PATHINFO_FILENAME);
|
||
$slugParts = array_map(static function (string $part): string {
|
||
$part = preg_replace('/[^a-z0-9-]/', '-', strtolower(trim($part)));
|
||
return trim((string)preg_replace('/-+/', '-', (string)$part), '-');
|
||
}, explode('/', trim((string)$slug, '/')));
|
||
$slug = implode('/', array_filter($slugParts, static fn(string $part): bool => $part !== ''));
|
||
if (!raijuPageIsValidPath($slug)) {
|
||
fwrite(STDERR, "Error: --slug must be a valid lowercase URL path.\n");
|
||
exit(1);
|
||
}
|
||
$lang = $opts['lang'] ?? 'en';
|
||
$dryRun = isset($opts['dry-run']);
|
||
$jsonOnly = isset($opts['json-only']);
|
||
$noCss = isset($opts['no-css']);
|
||
$force = isset($opts['force']);
|
||
|
||
$html = file_get_contents($inputFile);
|
||
|
||
// ── Converter class ───────────────────────────────────────────────────
|
||
|
||
class HtmlToRaiju
|
||
{
|
||
private string $html;
|
||
private string $slug;
|
||
private string $lang;
|
||
private ?string $optTitle;
|
||
private ?string $optDescription;
|
||
private DOMDocument $dom;
|
||
private DOMXPath $xpath;
|
||
|
||
private string $pageTitle = '';
|
||
private string $pageDescription = '';
|
||
|
||
/** @var array{key: string, elements: array}[] */
|
||
private array $sections = [];
|
||
|
||
/** @var array Collected JSON content */
|
||
private array $jsonData = [];
|
||
|
||
/** @var string[] PHP template lines for the <main> body */
|
||
private array $templateLines = [];
|
||
|
||
/** @var array Track used field keys to avoid duplicates */
|
||
private array $usedKeys = [];
|
||
|
||
/** @var string[] Collected CSS blocks */
|
||
private array $cssBlocks = [];
|
||
|
||
/** @var string[] External stylesheet paths found in the HTML */
|
||
private array $linkedStylesheets = [];
|
||
|
||
/** @var int Counter for generated inline-style classes */
|
||
private int $inlineStyleCounter = 0;
|
||
|
||
/** @var string Path to the input HTML file (for resolving relative stylesheet paths) */
|
||
private string $inputDir = '';
|
||
|
||
/** @var bool Whether to extract CSS */
|
||
private bool $extractCssEnabled = true;
|
||
|
||
public function __construct(string $html, string $slug, string $lang, ?string $title = null, ?string $description = null, string $inputDir = '')
|
||
{
|
||
$this->html = $html;
|
||
$this->slug = $slug;
|
||
$this->lang = $lang;
|
||
$this->optTitle = $title;
|
||
$this->optDescription = $description;
|
||
$this->inputDir = $inputDir;
|
||
}
|
||
|
||
public function setExtractCss(bool $enabled): void
|
||
{
|
||
$this->extractCssEnabled = $enabled;
|
||
}
|
||
|
||
public function convert(): void
|
||
{
|
||
if ($this->extractCssEnabled) {
|
||
$this->extractCss();
|
||
}
|
||
$this->parseHtml();
|
||
$this->extractMeta();
|
||
$this->extractSections();
|
||
$this->processSections();
|
||
}
|
||
|
||
public function getPageTitle(): string { return $this->pageTitle; }
|
||
public function getPageDescription(): string { return $this->pageDescription; }
|
||
public function getSections(): array { return $this->sections; }
|
||
public function getJsonData(): array { return $this->jsonData; }
|
||
public function getLinkedStylesheets(): array { return $this->linkedStylesheets; }
|
||
public function hasCss(): bool { return !empty($this->cssBlocks); }
|
||
|
||
public function generateJson(): string
|
||
{
|
||
$data = [
|
||
'page' => raijuPageContentKey($this->lang, $this->slug),
|
||
'path' => $this->slug,
|
||
'lang' => $this->lang,
|
||
'title' => $this->pageTitle,
|
||
];
|
||
if ($this->pageDescription) {
|
||
$data['description'] = $this->pageDescription;
|
||
}
|
||
$data['lastModified'] = null;
|
||
$data = array_merge($data, $this->jsonData);
|
||
|
||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||
}
|
||
|
||
public function generateTemplate(): string
|
||
{
|
||
$contentPage = raijuPageContentKey($this->lang, $this->slug);
|
||
$title = addslashes($this->pageTitle);
|
||
$desc = addslashes($this->pageDescription);
|
||
$cssFile = 'css/page-' . str_replace('/', '--', $this->slug) . '.css';
|
||
$includePrefix = str_repeat('../', substr_count($this->slug, '/') + 1);
|
||
|
||
$php = "<?php\n";
|
||
$php .= "\$pageTitle = '$title';\n";
|
||
$php .= "\$pageDescription = '$desc';\n";
|
||
$php .= "\$currentLang = '{$this->lang}';\n";
|
||
$php .= "\$currentPage = '{$this->slug}';\n";
|
||
$php .= "\$contentPage = '$contentPage';\n";
|
||
$php .= "\$basePath = \$basePath ?? '$includePrefix';\n";
|
||
|
||
if ($this->hasCss()) {
|
||
$php .= "\$pageStylesheet = '$cssFile';\n";
|
||
}
|
||
|
||
// External CDN stylesheets (Google Fonts etc.)
|
||
$externalUrls = array_filter($this->linkedStylesheets, fn($u) => preg_match('#^https?://#', $u));
|
||
if (!empty($externalUrls)) {
|
||
$php .= "\$pageExternalStyles = [\n";
|
||
foreach ($externalUrls as $url) {
|
||
$php .= " '" . addslashes($url) . "',\n";
|
||
}
|
||
$php .= "];\n";
|
||
}
|
||
|
||
$php .= "\n";
|
||
$php .= "include '{$includePrefix}includes/header.php';\n";
|
||
$php .= "include '{$includePrefix}includes/content-loader.php';\n";
|
||
$php .= "\$_p = \$contentPage;\n";
|
||
$php .= "?>\n";
|
||
$php .= " <main class=\"main-content\">\n";
|
||
|
||
foreach ($this->templateLines as $line) {
|
||
$php .= $line . "\n";
|
||
}
|
||
|
||
$php .= " </main>\n";
|
||
$php .= "<?php include '{$includePrefix}includes/footer.php'; ?>\n";
|
||
|
||
return $php;
|
||
}
|
||
|
||
public function generateCss(): string
|
||
{
|
||
if (empty($this->cssBlocks)) {
|
||
return '';
|
||
}
|
||
|
||
$css = "/*\n";
|
||
$css .= " * Extracted styles for page: {$this->slug}\n";
|
||
$css .= " * Generated by Raiju HTML-to-Editable Converter\n";
|
||
$css .= " *\n";
|
||
$css .= " * Review and adjust these styles:\n";
|
||
$css .= " * - Replace hardcoded colors with CSS custom properties from css/style.css\n";
|
||
$css .= " * - Replace hardcoded spacing with --spacing-* tokens\n";
|
||
$css .= " * - Check that font families match your project's typography\n";
|
||
$css .= " */\n\n";
|
||
|
||
$css .= implode("\n\n", $this->cssBlocks) . "\n";
|
||
|
||
return $css;
|
||
}
|
||
|
||
// ── CSS Extraction ─────────────────────────────────────────────────
|
||
|
||
private function extractCss(): void
|
||
{
|
||
// 1. Extract <style> blocks
|
||
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/si', $this->html, $matches)) {
|
||
foreach ($matches[1] as $styleContent) {
|
||
$css = trim($styleContent);
|
||
if ($css !== '') {
|
||
$this->cssBlocks[] = "/* ── Embedded <style> block ── */\n" . $css;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Find <link rel="stylesheet"> references
|
||
if (preg_match_all('/<link[^>]+rel=["\']stylesheet["\'][^>]*>/i', $this->html, $linkMatches)) {
|
||
foreach ($linkMatches[0] as $linkTag) {
|
||
if (preg_match('/href=["\']([^"\']+)["\']/i', $linkTag, $hrefMatch)) {
|
||
$href = $hrefMatch[1];
|
||
// Skip external CDN stylesheets (Google Fonts, etc.) — keep as-is
|
||
if (preg_match('#^https?://#', $href)) {
|
||
$this->linkedStylesheets[] = $href;
|
||
continue;
|
||
}
|
||
// Try to read local stylesheet
|
||
$resolvedPath = $this->resolveFilePath($href);
|
||
if ($resolvedPath && file_exists($resolvedPath)) {
|
||
$content = file_get_contents($resolvedPath);
|
||
if (trim($content) !== '') {
|
||
$this->cssBlocks[] = "/* ── From linked file: $href ── */\n" . trim($content);
|
||
}
|
||
} else {
|
||
$this->cssBlocks[] = "/* ── Linked stylesheet not found: $href ── */\n/* Copy this file manually into css/ */";
|
||
$this->linkedStylesheets[] = $href;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Convert inline styles to classes
|
||
$this->html = preg_replace_callback(
|
||
'/(<\w[^>]*)\sstyle="([^"]+)"([^>]*>)/i',
|
||
function ($match) {
|
||
$style = $match[2];
|
||
// Skip trivial inline styles (display:none etc. that are likely JS-controlled)
|
||
if (preg_match('/^\s*display\s*:\s*none\s*;?\s*$/i', $style)) {
|
||
return $match[0];
|
||
}
|
||
$this->inlineStyleCounter++;
|
||
$className = 'converted-style-' . $this->inlineStyleCounter;
|
||
$this->cssBlocks[] = "/* ── Converted inline style ── */\n.$className {\n " .
|
||
implode(";\n ", array_filter(array_map('trim', explode(';', $style)))) .
|
||
";\n}";
|
||
|
||
// Add class to the element (merge with existing class if present)
|
||
$before = $match[1];
|
||
$after = $match[3];
|
||
if (preg_match('/class="([^"]*)"/i', $before, $classMatch)) {
|
||
$before = str_replace(
|
||
'class="' . $classMatch[1] . '"',
|
||
'class="' . $classMatch[1] . ' ' . $className . '"',
|
||
$before
|
||
);
|
||
} else {
|
||
$before .= ' class="' . $className . '"';
|
||
}
|
||
return $before . $after;
|
||
},
|
||
$this->html
|
||
);
|
||
}
|
||
|
||
private function resolveFilePath(string $href): ?string
|
||
{
|
||
// Remove query strings and fragments
|
||
$href = preg_replace('/[?#].*$/', '', $href);
|
||
|
||
if ($this->inputDir && !preg_match('#^/#', $href)) {
|
||
// Relative to the input HTML file
|
||
$path = $this->inputDir . '/' . $href;
|
||
if (file_exists($path)) return realpath($path);
|
||
}
|
||
|
||
// Try as-is (absolute or relative to cwd)
|
||
if (file_exists($href)) return realpath($href);
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── HTML Parsing ──────────────────────────────────────────────────
|
||
|
||
private function parseHtml(): void
|
||
{
|
||
$this->dom = new DOMDocument();
|
||
libxml_use_internal_errors(true);
|
||
// Ensure UTF-8 handling
|
||
$html = $this->html;
|
||
if (stripos($html, '<meta charset') === false && stripos($html, 'encoding') === false) {
|
||
$html = '<meta charset="UTF-8">' . $html;
|
||
}
|
||
$this->dom->loadHTML($html, LIBXML_HTML_NOIMPLIED);
|
||
libxml_clear_errors();
|
||
$this->xpath = new DOMXPath($this->dom);
|
||
}
|
||
|
||
private function extractMeta(): void
|
||
{
|
||
// Title
|
||
if ($this->optTitle) {
|
||
$this->pageTitle = $this->optTitle;
|
||
} else {
|
||
$titleNodes = $this->xpath->query('//title');
|
||
if ($titleNodes->length > 0) {
|
||
$this->pageTitle = trim($titleNodes->item(0)->textContent);
|
||
}
|
||
if (!$this->pageTitle) {
|
||
$h1 = $this->xpath->query('//h1');
|
||
if ($h1->length > 0) {
|
||
$this->pageTitle = trim($h1->item(0)->textContent);
|
||
}
|
||
}
|
||
if (!$this->pageTitle) {
|
||
$this->pageTitle = ucfirst(str_replace('-', ' ', $this->slug));
|
||
}
|
||
}
|
||
|
||
// Description
|
||
if ($this->optDescription) {
|
||
$this->pageDescription = $this->optDescription;
|
||
} else {
|
||
$meta = $this->xpath->query('//meta[@name="description"]/@content');
|
||
if ($meta->length > 0) {
|
||
$this->pageDescription = trim($meta->item(0)->textContent);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Section Extraction ────────────────────────────────────────────
|
||
|
||
private function extractSections(): void
|
||
{
|
||
// Find <body> or use document root
|
||
$body = $this->xpath->query('//body')->item(0);
|
||
if (!$body) {
|
||
$body = $this->dom->documentElement;
|
||
}
|
||
if (!$body) {
|
||
return;
|
||
}
|
||
|
||
$pendingElements = [];
|
||
$currentKey = null;
|
||
|
||
foreach ($this->childElements($body) as $node) {
|
||
$tag = strtolower($node->nodeName);
|
||
|
||
// Skip non-content elements
|
||
if (in_array($tag, ['script', 'style', 'link', 'meta', 'head', 'nav', 'noscript'])) {
|
||
continue;
|
||
}
|
||
|
||
// Check if this is a section boundary
|
||
if ($this->isSectionBoundary($node)) {
|
||
// Flush pending
|
||
if ($currentKey && $pendingElements) {
|
||
$this->sections[] = ['key' => $currentKey, 'nodes' => $pendingElements];
|
||
}
|
||
$currentKey = $this->inferSectionKey($node);
|
||
$pendingElements = [$node];
|
||
} else {
|
||
// If no section started yet, start one
|
||
if ($currentKey === null) {
|
||
$currentKey = $this->inferSectionKey($node);
|
||
}
|
||
$pendingElements[] = $node;
|
||
}
|
||
}
|
||
|
||
// Flush remaining
|
||
if ($currentKey && $pendingElements) {
|
||
$this->sections[] = ['key' => $currentKey, 'nodes' => $pendingElements];
|
||
}
|
||
|
||
// Deduplicate section keys
|
||
$seen = [];
|
||
foreach ($this->sections as &$section) {
|
||
$base = $section['key'];
|
||
if (isset($seen[$base])) {
|
||
$seen[$base]++;
|
||
$section['key'] = $base . '_' . $seen[$base];
|
||
} else {
|
||
$seen[$base] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
private function isSectionBoundary(DOMElement $node): bool
|
||
{
|
||
$tag = strtolower($node->nodeName);
|
||
|
||
// Semantic section elements
|
||
if (in_array($tag, ['section', 'article', 'header', 'footer', 'aside'])) {
|
||
return true;
|
||
}
|
||
|
||
// <main> is a section boundary
|
||
if ($tag === 'main') {
|
||
return true;
|
||
}
|
||
|
||
// Div with semantic class names
|
||
if ($tag === 'div') {
|
||
$class = $node->getAttribute('class');
|
||
$id = $node->getAttribute('id');
|
||
$combined = strtolower($class . ' ' . $id);
|
||
$semanticPatterns = [
|
||
'hero', 'banner', 'feature', 'about', 'service', 'pricing',
|
||
'testimonial', 'team', 'contact', 'cta', 'faq', 'gallery',
|
||
'stats', 'timeline', 'comparison', 'section', 'block',
|
||
'wrapper', 'container',
|
||
];
|
||
foreach ($semanticPatterns as $pattern) {
|
||
if (strpos($combined, $pattern) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function inferSectionKey(DOMElement $node): string
|
||
{
|
||
$tag = strtolower($node->nodeName);
|
||
|
||
// Try class name first
|
||
$class = strtolower($node->getAttribute('class'));
|
||
$id = strtolower($node->getAttribute('id'));
|
||
|
||
// Extract semantic name from class/id
|
||
$candidates = array_merge(
|
||
preg_split('/[\s\-_]+/', $class) ?: [],
|
||
preg_split('/[\s\-_]+/', $id) ?: []
|
||
);
|
||
$candidates = array_filter($candidates);
|
||
|
||
$semanticNames = [
|
||
'hero', 'banner', 'header', 'intro',
|
||
'features', 'feature', 'services', 'service',
|
||
'about', 'story', 'mission',
|
||
'pricing', 'plans', 'packages',
|
||
'testimonials', 'testimonial', 'reviews',
|
||
'team', 'members', 'people',
|
||
'contact', 'cta', 'action',
|
||
'faq', 'questions',
|
||
'gallery', 'portfolio', 'work',
|
||
'stats', 'numbers', 'figures',
|
||
'timeline', 'history', 'roadmap',
|
||
'comparison', 'compare',
|
||
'footer', 'bottom',
|
||
'content', 'main', 'body',
|
||
];
|
||
|
||
foreach ($candidates as $c) {
|
||
if (in_array($c, $semanticNames)) {
|
||
return $c;
|
||
}
|
||
}
|
||
|
||
// Try tag name for semantic elements
|
||
if (in_array($tag, ['header', 'footer', 'article', 'aside'])) {
|
||
return $tag;
|
||
}
|
||
|
||
// Fallback: use a generic section key
|
||
return 'section';
|
||
}
|
||
|
||
// ── Section Processing ────────────────────────────────────────────
|
||
|
||
private function processSections(): void
|
||
{
|
||
foreach ($this->sections as $section) {
|
||
$key = $section['key'];
|
||
$nodes = $section['nodes'];
|
||
|
||
// If the section has exactly one container node, process its children
|
||
if (count($nodes) === 1 && $this->hasContentChildren($nodes[0])) {
|
||
$this->processContainer($key, $nodes[0]);
|
||
} else {
|
||
// Process nodes directly
|
||
$this->processNodeList($key, $nodes, 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function processContainer(string $sectionKey, DOMElement $container): void
|
||
{
|
||
$tag = strtolower($container->nodeName);
|
||
$attrs = $this->buildAttrString($container, ['class', 'id']);
|
||
|
||
// Check if children form a repeating pattern
|
||
$pattern = $this->detectRepeatingPattern($container);
|
||
if ($pattern) {
|
||
$this->processRepeatingPattern($sectionKey, $container, $pattern);
|
||
return;
|
||
}
|
||
|
||
// Open the container tag
|
||
$indent = str_repeat(' ', 8);
|
||
$this->templateLines[] = "$indent<$tag$attrs>";
|
||
|
||
// Check for inner wrapper (main > div.container > actual content)
|
||
$children = $this->childElements($container);
|
||
if (count($children) === 1 && $this->isWrapper($children[0])) {
|
||
$inner = $children[0];
|
||
$innerTag = strtolower($inner->nodeName);
|
||
$innerAttrs = $this->buildAttrString($inner, ['class', 'id']);
|
||
$this->templateLines[] = "$indent <$innerTag$innerAttrs>";
|
||
$this->processNodeList($sectionKey, $this->childElements($inner), 4);
|
||
$this->templateLines[] = "$indent </$innerTag>";
|
||
} else {
|
||
$this->processNodeList($sectionKey, $children, 3);
|
||
}
|
||
|
||
$this->templateLines[] = "$indent</$tag>";
|
||
}
|
||
|
||
private function processNodeList(string $sectionKey, array $nodes, int $indentLevel): void
|
||
{
|
||
$indent = str_repeat(' ', $indentLevel * 4);
|
||
$fieldCounter = [];
|
||
|
||
// Pre-scan: count consecutive <p> tags to decide editableHtml vs editableText
|
||
$consecutiveP = [];
|
||
$pRun = 0;
|
||
$pRunStart = -1;
|
||
foreach ($nodes as $i => $node) {
|
||
if ($node instanceof DOMElement && strtolower($node->nodeName) === 'p') {
|
||
if ($pRun === 0) $pRunStart = $i;
|
||
$pRun++;
|
||
} else {
|
||
if ($pRun >= 2) {
|
||
for ($j = $pRunStart; $j < $pRunStart + $pRun; $j++) {
|
||
$consecutiveP[$j] = true;
|
||
}
|
||
}
|
||
$pRun = 0;
|
||
}
|
||
}
|
||
if ($pRun >= 2) {
|
||
for ($j = $pRunStart; $j < $pRunStart + $pRun; $j++) {
|
||
$consecutiveP[$j] = true;
|
||
}
|
||
}
|
||
|
||
// Collect consecutive <p> for editableHtml
|
||
$htmlBuffer = [];
|
||
$htmlBufferStart = -1;
|
||
|
||
$flushHtmlBuffer = function () use (&$htmlBuffer, &$htmlBufferStart, $sectionKey, $indent, &$fieldCounter) {
|
||
if (empty($htmlBuffer)) return;
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'content', $fieldCounter);
|
||
$htmlContent = implode("\n", $htmlBuffer);
|
||
$escaped = addslashes($htmlContent);
|
||
$this->templateLines[] = "$indent<div><?php echo editableHtml(\$_p, '$fieldName', '$escaped'); ?></div>";
|
||
$this->setJsonValue($fieldName, $htmlContent);
|
||
$htmlBuffer = [];
|
||
$htmlBufferStart = -1;
|
||
};
|
||
|
||
foreach ($nodes as $i => $node) {
|
||
if (!($node instanceof DOMElement)) {
|
||
// Text nodes with meaningful content
|
||
$text = trim($node->textContent);
|
||
if ($text !== '') {
|
||
$flushHtmlBuffer();
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'text', $fieldCounter);
|
||
$escaped = addslashes($text);
|
||
$this->templateLines[] = "$indent<?php echo editableText(\$_p, '$fieldName', '$escaped'); ?>";
|
||
$this->setJsonValue($fieldName, $text);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
$tag = strtolower($node->nodeName);
|
||
|
||
// Consecutive <p> → editableHtml
|
||
if (isset($consecutiveP[$i])) {
|
||
$htmlBuffer[] = $this->getInnerHtml($node, true);
|
||
if ($htmlBufferStart === -1) $htmlBufferStart = $i;
|
||
continue;
|
||
}
|
||
|
||
$flushHtmlBuffer();
|
||
|
||
// Headings
|
||
if (preg_match('/^h([1-6])$/', $tag, $m)) {
|
||
$text = trim($node->textContent);
|
||
$role = !isset($fieldCounter['title']) ? 'title' : 'heading';
|
||
$fieldName = $this->uniqueFieldName($sectionKey, $role, $fieldCounter);
|
||
$escaped = addslashes($text);
|
||
$this->templateLines[] = "$indent<$tag><?php echo editableText(\$_p, '$fieldName', '$escaped'); ?></$tag>";
|
||
$this->setJsonValue($fieldName, $text);
|
||
continue;
|
||
}
|
||
|
||
// Single paragraph
|
||
if ($tag === 'p') {
|
||
$innerHtml = $this->getInnerHtml($node);
|
||
$text = trim($node->textContent);
|
||
$hasFormatting = ($innerHtml !== htmlspecialchars($text, ENT_QUOTES, 'UTF-8') && $innerHtml !== $text);
|
||
$pAttrs = $this->buildAttrString($node, ['class']);
|
||
|
||
if ($hasFormatting || mb_strlen($text) > 200) {
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'content', $fieldCounter);
|
||
$wrapped = '<p>' . $innerHtml . '</p>';
|
||
$escaped = addslashes($wrapped);
|
||
$this->templateLines[] = "$indent<div><?php echo editableHtml(\$_p, '$fieldName', '$escaped'); ?></div>";
|
||
$this->setJsonValue($fieldName, $wrapped);
|
||
} else {
|
||
$role = (!isset($fieldCounter['title']) && !isset($fieldCounter['subtitle']) && isset($fieldCounter['title_done']))
|
||
? 'subtitle'
|
||
: 'text';
|
||
$fieldName = $this->uniqueFieldName($sectionKey, $role, $fieldCounter);
|
||
$escaped = addslashes($text);
|
||
$this->templateLines[] = "$indent<p$pAttrs><?php echo editableText(\$_p, '$fieldName', '$escaped'); ?></p>";
|
||
$this->setJsonValue($fieldName, $text);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Images
|
||
if ($tag === 'img') {
|
||
$src = $node->getAttribute('src') ?: '';
|
||
$alt = $node->getAttribute('alt') ?: '';
|
||
$class = $node->getAttribute('class') ?: '';
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'image', $fieldCounter);
|
||
$srcE = addslashes($src);
|
||
$altE = addslashes($alt);
|
||
$classArg = $class ? ", '$class'" : '';
|
||
$this->templateLines[] = "$indent<?php echo editableImage(\$_p, '$fieldName', '$srcE', '$altE'$classArg); ?>";
|
||
$this->setJsonValue($fieldName, ['src' => $src, 'alt' => $alt]);
|
||
continue;
|
||
}
|
||
|
||
// Links (standalone, button-like)
|
||
if ($tag === 'a' && $this->isStandaloneLink($node)) {
|
||
$text = trim($node->textContent);
|
||
$href = $node->getAttribute('href') ?: '#';
|
||
$class = $node->getAttribute('class') ?: '';
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'cta', $fieldCounter);
|
||
$textE = addslashes($text);
|
||
$hrefE = addslashes($href);
|
||
$classArg = $class ? ", '$class'" : '';
|
||
$this->templateLines[] = "$indent<?php echo editableLink(\$_p, '$fieldName', '$textE', '$hrefE'$classArg); ?>";
|
||
$this->setJsonValue($fieldName, ['text' => $text, 'href' => $href]);
|
||
continue;
|
||
}
|
||
|
||
// Blockquote
|
||
if ($tag === 'blockquote') {
|
||
$this->processBlockquote($sectionKey, $node, $indent, $fieldCounter);
|
||
continue;
|
||
}
|
||
|
||
// Lists (ul/ol)
|
||
if (in_array($tag, ['ul', 'ol'])) {
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'list', $fieldCounter);
|
||
$listHtml = '<' . $tag . '>' . $this->getInnerHtml($node) . '</' . $tag . '>';
|
||
$escaped = addslashes($listHtml);
|
||
$this->templateLines[] = "$indent<div><?php echo editableHtml(\$_p, '$fieldName', '$escaped'); ?></div>";
|
||
$this->setJsonValue($fieldName, $listHtml);
|
||
continue;
|
||
}
|
||
|
||
// Nested container — check for repeating pattern
|
||
if (in_array($tag, ['div', 'section', 'article', 'ul', 'ol']) && $this->hasContentChildren($node)) {
|
||
$pattern = $this->detectRepeatingPattern($node);
|
||
if ($pattern) {
|
||
$this->processRepeatingPattern($sectionKey, $node, $pattern);
|
||
} else {
|
||
// Recurse into the container
|
||
$nodeAttrs = $this->buildAttrString($node, ['class', 'id']);
|
||
$this->templateLines[] = "$indent<$tag$nodeAttrs>";
|
||
$this->processNodeList($sectionKey, $this->childElements($node), $indentLevel + 1);
|
||
$this->templateLines[] = "$indent</$tag>";
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Picture element — find inner img
|
||
if ($tag === 'picture') {
|
||
$img = $this->xpath->query('.//img', $node)->item(0);
|
||
if ($img) {
|
||
$src = $img->getAttribute('src') ?: '';
|
||
$alt = $img->getAttribute('alt') ?: '';
|
||
$class = $img->getAttribute('class') ?: '';
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'image', $fieldCounter);
|
||
$srcE = addslashes($src);
|
||
$altE = addslashes($alt);
|
||
$classArg = $class ? ", '$class'" : '';
|
||
$this->templateLines[] = "$indent<?php echo editableImage(\$_p, '$fieldName', '$srcE', '$altE'$classArg); ?>";
|
||
$this->setJsonValue($fieldName, ['src' => $src, 'alt' => $alt]);
|
||
} else {
|
||
$this->templateLines[] = "$indent" . $this->dom->saveHTML($node);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Fallback: keep as-is but try to process inline content
|
||
$this->templateLines[] = "$indent" . trim($this->dom->saveHTML($node));
|
||
}
|
||
|
||
$flushHtmlBuffer();
|
||
}
|
||
|
||
// ── Repeating Pattern Detection ───────────────────────────────────
|
||
|
||
private function detectRepeatingPattern(DOMElement $container): ?array
|
||
{
|
||
$children = $this->childElements($container);
|
||
if (count($children) < 2) {
|
||
return null;
|
||
}
|
||
|
||
// Check if all children share the same tag + class pattern
|
||
$firstTag = strtolower($children[0]->nodeName);
|
||
$firstClass = $children[0]->getAttribute('class');
|
||
$matching = 0;
|
||
|
||
foreach ($children as $child) {
|
||
if (strtolower($child->nodeName) === $firstTag && $child->getAttribute('class') === $firstClass) {
|
||
$matching++;
|
||
}
|
||
}
|
||
|
||
// At least 2 matching children, and majority of children match
|
||
if ($matching < 2 || $matching < count($children) * 0.6) {
|
||
return null;
|
||
}
|
||
|
||
// Analyze the structure of the first item to build a field map
|
||
$fields = $this->analyzeItemStructure($children[0]);
|
||
if (empty($fields)) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'tag' => $firstTag,
|
||
'class' => $firstClass,
|
||
'fields' => $fields,
|
||
'items' => $children,
|
||
];
|
||
}
|
||
|
||
private function analyzeItemStructure(DOMElement $item): array
|
||
{
|
||
$fields = [];
|
||
foreach ($this->childElements($item) as $child) {
|
||
$tag = strtolower($child->nodeName);
|
||
$text = trim($child->textContent);
|
||
if (!$text && $tag !== 'img') continue;
|
||
|
||
if (preg_match('/^h[1-6]$/', $tag)) {
|
||
$fields[] = ['role' => 'title', 'tag' => $tag];
|
||
} elseif ($tag === 'p') {
|
||
$fields[] = ['role' => 'text', 'tag' => $tag];
|
||
} elseif (in_array($tag, ['cite', 'figcaption'])) {
|
||
$fields[] = ['role' => 'attribution', 'tag' => $tag];
|
||
} elseif ($tag === 'footer') {
|
||
// <footer> inside blockquote typically contains attribution
|
||
$fields[] = ['role' => 'attribution', 'tag' => $tag];
|
||
} elseif ($tag === 'img') {
|
||
$fields[] = ['role' => 'image', 'tag' => $tag];
|
||
} elseif ($tag === 'a' && $this->isStandaloneLink($child)) {
|
||
$fields[] = ['role' => 'link', 'tag' => $tag];
|
||
} elseif ($tag === 'span' || $tag === 'div') {
|
||
// Try to infer from class
|
||
$class = strtolower($child->getAttribute('class'));
|
||
if (strpos($class, 'title') !== false || strpos($class, 'name') !== false) {
|
||
$fields[] = ['role' => 'title', 'tag' => $tag];
|
||
} elseif (strpos($class, 'desc') !== false || strpos($class, 'text') !== false || strpos($class, 'content') !== false) {
|
||
$fields[] = ['role' => 'desc', 'tag' => $tag];
|
||
} elseif (strpos($class, 'icon') !== false) {
|
||
$fields[] = ['role' => 'icon', 'tag' => $tag];
|
||
} else {
|
||
$fields[] = ['role' => 'text', 'tag' => $tag];
|
||
}
|
||
}
|
||
}
|
||
return $fields;
|
||
}
|
||
|
||
private function processRepeatingPattern(string $sectionKey, DOMElement $container, array $pattern): void
|
||
{
|
||
$containerTag = strtolower($container->nodeName);
|
||
$containerAttrs = $this->buildAttrString($container, ['class', 'id']);
|
||
$itemTag = $pattern['tag'];
|
||
$itemClass = $pattern['class'];
|
||
$fields = $pattern['fields'];
|
||
$items = $pattern['items'];
|
||
$listKey = $sectionKey . '.items';
|
||
|
||
// Build defaults array
|
||
$defaults = [];
|
||
foreach ($fields as $f) {
|
||
if ($f['role'] === 'image') {
|
||
$defaults[$f['role']] = ['src' => '', 'alt' => ''];
|
||
} elseif ($f['role'] === 'link') {
|
||
$defaults[$f['role']] = ['text' => '', 'href' => '#'];
|
||
} elseif ($f['role'] !== 'icon') {
|
||
$defaults[$f['role']] = '';
|
||
}
|
||
}
|
||
$defaultsJson = var_export($defaults, true);
|
||
// Compact the var_export output
|
||
$defaultsJson = preg_replace('/\s+/', ' ', $defaultsJson);
|
||
$defaultsJson = str_replace(['array (', ')'], ['[', ']'], $defaultsJson);
|
||
$defaultsJson = str_replace(' => ', ' => ', $defaultsJson);
|
||
|
||
$indent = str_repeat(' ', 8);
|
||
$indent2 = $indent . ' ';
|
||
$indent3 = $indent2 . ' ';
|
||
|
||
$itemClassAttr = $itemClass ? " class=\"$itemClass\"" : '';
|
||
|
||
$this->templateLines[] = "$indent<$containerTag$containerAttrs <?php echo editableListAttrs(\$_p, '$listKey', $defaultsJson); ?>>";
|
||
$this->templateLines[] = "$indent2<?php foreach (editableListItems(\$_p, '$listKey') as \$i => \$item): ?>";
|
||
$hasStructuredFields = count($fields) > 1;
|
||
foreach ($fields as $f) {
|
||
if (in_array($f['role'], ['image', 'link', 'icon'], true)) {
|
||
$hasStructuredFields = true;
|
||
break;
|
||
}
|
||
}
|
||
$itemAttrsHelper = $hasStructuredFields
|
||
? "editableListGroupItemAttrs(\$_p, '$listKey', \$i, [], $defaultsJson, 'Item')"
|
||
: "editableListItemAttrs(\$_p, '$listKey', \$i)";
|
||
$this->templateLines[] = "$indent2<$itemTag$itemClassAttr <?php echo $itemAttrsHelper; ?>>";
|
||
|
||
// Generate field templates
|
||
foreach ($fields as $f) {
|
||
$role = $f['role'];
|
||
$tag = $f['tag'];
|
||
if ($role === 'icon') {
|
||
$this->templateLines[] = "$indent3<!-- icon: consider keeping as-is or using editableText -->";
|
||
continue;
|
||
}
|
||
$fieldPath = "$listKey.\$i.$role";
|
||
if ($role === 'image') {
|
||
$this->templateLines[] = "$indent3<?php echo editableImage(\$_p, \"$fieldPath\", '', '', ''); ?>";
|
||
} elseif ($role === 'link') {
|
||
$this->templateLines[] = "$indent3<?php echo editableLink(\$_p, \"$fieldPath\", '', '#', ''); ?>";
|
||
} else {
|
||
$this->templateLines[] = "$indent3<$tag><?php echo editableText(\$_p, \"$fieldPath\", ''); ?></$tag>";
|
||
}
|
||
}
|
||
|
||
$this->templateLines[] = "$indent2</$itemTag>";
|
||
$this->templateLines[] = "$indent2<?php endforeach; ?>";
|
||
$this->templateLines[] = "$indent</$containerTag>";
|
||
|
||
// Generate JSON data for items
|
||
$jsonItems = [];
|
||
foreach ($items as $idx => $itemNode) {
|
||
$itemData = [];
|
||
$childElements = $this->childElements($itemNode);
|
||
$fieldIdx = 0;
|
||
foreach ($childElements as $child) {
|
||
if ($fieldIdx >= count($fields)) break;
|
||
$f = $fields[$fieldIdx];
|
||
$childTag = strtolower($child->nodeName);
|
||
|
||
// Try to match by expected tag
|
||
if ($childTag === $f['tag'] || ($f['role'] === 'title' && preg_match('/^h[1-6]$/', $childTag))) {
|
||
if ($f['role'] === 'image') {
|
||
$itemData[$f['role']] = [
|
||
'src' => $child->getAttribute('src') ?: '',
|
||
'alt' => $child->getAttribute('alt') ?: '',
|
||
];
|
||
} elseif ($f['role'] === 'link') {
|
||
$itemData[$f['role']] = [
|
||
'text' => trim($child->textContent),
|
||
'href' => $child->getAttribute('href') ?: '#',
|
||
];
|
||
} elseif ($f['role'] === 'icon') {
|
||
// skip icons in JSON
|
||
} else {
|
||
$itemData[$f['role']] = trim($child->textContent);
|
||
}
|
||
$fieldIdx++;
|
||
}
|
||
}
|
||
$jsonItems[(string)$idx] = $itemData;
|
||
}
|
||
|
||
// Cast to object so JSON encodes as {"0": {...}, "1": {...}} not [...]
|
||
$this->setJsonValue($sectionKey . '.items', (object)$jsonItems);
|
||
}
|
||
|
||
// ── Blockquote ────────────────────────────────────────────────────
|
||
|
||
private function processBlockquote(string $sectionKey, DOMElement $node, string $indent, array &$fieldCounter): void
|
||
{
|
||
$quoteText = '';
|
||
$attribution = '';
|
||
|
||
foreach ($this->childElements($node) as $child) {
|
||
$tag = strtolower($child->nodeName);
|
||
if (in_array($tag, ['cite', 'footer', 'figcaption'])) {
|
||
$attribution = trim($child->textContent);
|
||
} else {
|
||
$quoteText .= trim($child->textContent) . ' ';
|
||
}
|
||
}
|
||
if (!$quoteText) {
|
||
$quoteText = trim($node->textContent);
|
||
if ($attribution && strpos($quoteText, $attribution) !== false) {
|
||
$quoteText = trim(str_replace($attribution, '', $quoteText));
|
||
}
|
||
}
|
||
$quoteText = trim($quoteText);
|
||
|
||
$fieldName = $this->uniqueFieldName($sectionKey, 'quote', $fieldCounter);
|
||
$quotE = addslashes($quoteText);
|
||
$this->templateLines[] = "$indent<blockquote>";
|
||
$this->templateLines[] = "$indent <p><?php echo editableText(\$_p, '$fieldName', '$quotE'); ?></p>";
|
||
|
||
if ($attribution) {
|
||
$attrField = $this->uniqueFieldName($sectionKey, 'attribution', $fieldCounter);
|
||
$attrE = addslashes($attribution);
|
||
$this->templateLines[] = "$indent <cite><?php echo editableText(\$_p, '$attrField', '$attrE'); ?></cite>";
|
||
$this->setJsonValue($attrField, $attribution);
|
||
}
|
||
|
||
$this->templateLines[] = "$indent</blockquote>";
|
||
$this->setJsonValue($fieldName, $quoteText);
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────
|
||
|
||
/** @return DOMElement[] */
|
||
private function childElements(DOMNode $parent): array
|
||
{
|
||
$elements = [];
|
||
foreach ($parent->childNodes as $child) {
|
||
if ($child instanceof DOMElement) {
|
||
$elements[] = $child;
|
||
}
|
||
}
|
||
return $elements;
|
||
}
|
||
|
||
private function hasContentChildren(DOMElement $node): bool
|
||
{
|
||
foreach ($node->childNodes as $child) {
|
||
if ($child instanceof DOMElement) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private function isWrapper(DOMElement $node): bool
|
||
{
|
||
$tag = strtolower($node->nodeName);
|
||
if ($tag !== 'div') return false;
|
||
$class = strtolower($node->getAttribute('class'));
|
||
return (bool)preg_match('/\b(container|wrapper|inner|content)\b/', $class);
|
||
}
|
||
|
||
private function isStandaloneLink(DOMElement $node): bool
|
||
{
|
||
// A link is "standalone" if it's not wrapping complex content (like images or blocks)
|
||
$children = $this->childElements($node);
|
||
foreach ($children as $child) {
|
||
$tag = strtolower($child->nodeName);
|
||
if (in_array($tag, ['img', 'div', 'section', 'article', 'ul', 'ol', 'table'])) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check for button-like classes
|
||
$class = strtolower($node->getAttribute('class'));
|
||
if (preg_match('/\b(btn|button|cta|action|link)\b/', $class)) {
|
||
return true;
|
||
}
|
||
|
||
// If it's a direct child of a section (not nested in a paragraph)
|
||
$parent = $node->parentNode;
|
||
if ($parent instanceof DOMElement) {
|
||
$parentTag = strtolower($parent->nodeName);
|
||
if (!in_array($parentTag, ['p', 'span', 'li', 'td', 'th'])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function getInnerHtml(DOMElement $node, bool $wrapInP = false): string
|
||
{
|
||
$html = '';
|
||
foreach ($node->childNodes as $child) {
|
||
$html .= $this->dom->saveHTML($child);
|
||
}
|
||
$html = trim($html);
|
||
if ($wrapInP) {
|
||
$html = '<p>' . $html . '</p>';
|
||
}
|
||
return $html;
|
||
}
|
||
|
||
private function buildAttrString(DOMElement $node, array $preserve): string
|
||
{
|
||
$attrs = '';
|
||
foreach ($preserve as $name) {
|
||
$val = $node->getAttribute($name);
|
||
if ($val) {
|
||
$attrs .= " $name=\"" . htmlspecialchars($val, ENT_QUOTES, 'UTF-8') . "\"";
|
||
}
|
||
}
|
||
return $attrs;
|
||
}
|
||
|
||
private function uniqueFieldName(string $section, string $role, array &$counter): string
|
||
{
|
||
$key = $section . '.' . $role;
|
||
if (!isset($counter[$role])) {
|
||
$counter[$role] = 1;
|
||
if ($role === 'title') $counter['title_done'] = true;
|
||
if (!isset($this->usedKeys[$key])) {
|
||
$this->usedKeys[$key] = true;
|
||
return $key;
|
||
}
|
||
}
|
||
|
||
// Find next available
|
||
$n = $counter[$role] + 1;
|
||
while (isset($this->usedKeys[$section . '.' . $role . '_' . $n])) {
|
||
$n++;
|
||
}
|
||
$counter[$role] = $n;
|
||
$finalKey = $section . '.' . $role . '_' . $n;
|
||
$this->usedKeys[$finalKey] = true;
|
||
return $finalKey;
|
||
}
|
||
|
||
private function setJsonValue(string $dotKey, $value): void
|
||
{
|
||
$parts = explode('.', $dotKey);
|
||
$ref = &$this->jsonData;
|
||
foreach ($parts as $part) {
|
||
if (!isset($ref[$part])) {
|
||
$ref[$part] = [];
|
||
}
|
||
$ref = &$ref[$part];
|
||
}
|
||
$ref = $value;
|
||
}
|
||
}
|
||
|
||
// ── Main ──────────────────────────────────────────────────────────────
|
||
|
||
$inputDir = realpath(dirname($inputFile)) ?: dirname($inputFile);
|
||
$converter = new HtmlToRaiju(
|
||
$html,
|
||
$slug,
|
||
$lang,
|
||
$opts['title'] ?? null,
|
||
$opts['description'] ?? null,
|
||
$inputDir
|
||
);
|
||
|
||
if ($noCss) {
|
||
$converter->setExtractCss(false);
|
||
}
|
||
$converter->convert();
|
||
|
||
$sections = $converter->getSections();
|
||
|
||
// ── Summary output ────────────────────────────────────────────────────
|
||
|
||
echo "Parsing $inputFile...\n\n";
|
||
|
||
if (empty($sections)) {
|
||
echo "No content sections found.\n";
|
||
exit(1);
|
||
}
|
||
|
||
echo "Found " . count($sections) . " section(s):\n";
|
||
foreach ($sections as $s) {
|
||
$nodeCount = count($s['nodes']);
|
||
$types = [];
|
||
foreach ($s['nodes'] as $node) {
|
||
if ($node instanceof DOMElement) {
|
||
$types[] = strtolower($node->nodeName);
|
||
}
|
||
}
|
||
$typeStr = implode(', ', array_count_values($types));
|
||
$typeStr = preg_replace_callback('/(\w+),\s*(\d+)/', fn($m) => $m[2] . '× ' . $m[1], $typeStr);
|
||
// Simpler: just show tag names
|
||
$tagList = implode(', ', array_unique($types));
|
||
echo " " . str_pad($s['key'], 20) . " → $tagList\n";
|
||
}
|
||
echo "\n";
|
||
|
||
// ── Generate files ────────────────────────────────────────────────────
|
||
|
||
$contentPage = raijuPageContentKey($lang, $slug);
|
||
$jsonPath = $projectRoot . '/content/pages/' . $contentPage . '.json';
|
||
$templateDir = $projectRoot . '/' . $lang;
|
||
$templatePath = $templateDir . '/' . $slug . '.php';
|
||
$cssPath = $projectRoot . '/css/page-' . str_replace('/', '--', $slug) . '.css';
|
||
|
||
$jsonContent = $converter->generateJson();
|
||
$templateContent = $converter->generateTemplate();
|
||
$cssContent = $noCss ? '' : $converter->generateCss();
|
||
|
||
if ($dryRun) {
|
||
echo "── DRY RUN ──\n\n";
|
||
echo "Would create: $jsonPath\n";
|
||
echo str_repeat('─', 60) . "\n";
|
||
echo $jsonContent;
|
||
echo "\n";
|
||
|
||
if (!$jsonOnly) {
|
||
echo "Would create: $templatePath\n";
|
||
echo str_repeat('─', 60) . "\n";
|
||
echo $templateContent;
|
||
echo "\n";
|
||
}
|
||
|
||
if ($cssContent) {
|
||
echo "Would create: $cssPath\n";
|
||
echo str_repeat('─', 60) . "\n";
|
||
echo $cssContent;
|
||
echo "\n";
|
||
}
|
||
|
||
$linkedStyles = $converter->getLinkedStylesheets();
|
||
$externalStyles = array_filter($linkedStyles, fn($u) => preg_match('#^https?://#', $u));
|
||
if (!empty($externalStyles)) {
|
||
echo "External stylesheets (loaded via \$pageExternalStyles):\n";
|
||
foreach ($externalStyles as $url) {
|
||
echo " → $url\n";
|
||
}
|
||
echo "\n";
|
||
}
|
||
|
||
echo "── No files written ──\n";
|
||
exit(0);
|
||
}
|
||
|
||
// Write JSON
|
||
if (file_exists($jsonPath) && !$force) {
|
||
fwrite(STDERR, "Error: $jsonPath already exists. Use --force to overwrite.\n");
|
||
exit(1);
|
||
}
|
||
file_put_contents($jsonPath, $jsonContent);
|
||
echo " \033[32m✓\033[0m $jsonPath\n";
|
||
|
||
// Write template
|
||
if (!$jsonOnly) {
|
||
$templateWriteDir = dirname($templatePath);
|
||
if (!is_dir($templateWriteDir)) {
|
||
mkdir($templateWriteDir, 0755, true);
|
||
}
|
||
if (file_exists($templatePath) && !$force) {
|
||
fwrite(STDERR, "Error: $templatePath already exists. Use --force to overwrite.\n");
|
||
exit(1);
|
||
}
|
||
file_put_contents($templatePath, $templateContent);
|
||
echo " \033[32m✓\033[0m $templatePath\n";
|
||
}
|
||
|
||
// Write CSS
|
||
if ($cssContent && !$jsonOnly) {
|
||
if (file_exists($cssPath) && !$force) {
|
||
fwrite(STDERR, "Error: $cssPath already exists. Use --force to overwrite.\n");
|
||
exit(1);
|
||
}
|
||
file_put_contents($cssPath, $cssContent);
|
||
echo " \033[32m✓\033[0m $cssPath\n";
|
||
}
|
||
|
||
// Show external stylesheets info
|
||
$linkedStyles = $converter->getLinkedStylesheets();
|
||
$externalStyles = array_filter($linkedStyles, fn($u) => preg_match('#^https?://#', $u));
|
||
if (!empty($externalStyles)) {
|
||
echo "\nExternal stylesheets (auto-loaded via \$pageExternalStyles):\n";
|
||
foreach ($externalStyles as $url) {
|
||
echo " → $url\n";
|
||
}
|
||
}
|
||
|
||
echo "\nAdd to navigation:\n";
|
||
echo " Edit includes/nav-config.php and add '$slug' to \$PAGE_MAPPING and \$NAV_ITEMS\n";
|