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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"require": {
"php": "^8.4",
"justbetter/statamic-glide-directive": "dev-feature/statamic-6",
"league/html-to-markdown": "^5.1",
"rapidez/blade-directives": "^1.0",
"rapidez/core": "^5.0",
"rapidez/sitemap": "^5.0",
Expand Down
32 changes: 32 additions & 0 deletions config/rapidez/statamic/migration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Rapidez\Statamic\Pipelines\BardData\FixListItems;
use Rapidez\Statamic\Pipelines\Html\HtmlThroughMarkdown;
use Rapidez\Statamic\Pipelines\Html\TransformIframes;
use Rapidez\Statamic\Pipelines\Html\TransformRapidezContent;
use Rapidez\Statamic\Pipelines\Markdown\TransformImages;

return [
/**
* These are the pipelines your migrations will run through in order to format Magento data
* into data Statamic can hold.
*/
'pipelines' => [
'clean_html' => [
/**
* These pipelines are executed by the CleanHtml action **in this order**
*/
'html' => [
TransformRapidezContent::class,
TransformIframes::class,
HtmlThroughMarkdown::class,
],
'markdown' => [
TransformImages::class,
]
],
'convert_field' => [
FixListItems::class
]
]
];
23 changes: 23 additions & 0 deletions src/Actions/CleanHtml.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Rapidez\Statamic\Actions;

use Illuminate\Pipeline\Pipeline;

class CleanHtml
{
public function __construct(private Pipeline $pipeline)
{
}
/**
* Transform HTML to statamic compatible content
*/
public function execute(string $html): string
{
return $this->pipeline
->send($html)
->via('handle')
->through(config('rapidez.statamic.migration.pipelines.clean_html.html', []))
->thenReturn();
}
}
73 changes: 73 additions & 0 deletions src/Actions/ConvertField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Rapidez\Statamic\Actions;

use Illuminate\Pipeline\Pipeline;
use InvalidArgumentException;
use Statamic\Entries\Entry;
use Statamic\Fieldtypes\Bard;
use Statamic\Fieldtypes\Bard\Augmentor;
use Statamic\Fieldtypes\Replicator;

class ConvertField
{
public function __construct(
private readonly CleanHtml $cleanHtml,
private readonly Pipeline $pipeline
) {}

/**
* Transform HTML to statamic compatible content
*/
public function execute(Entry $entry, string $html, ?string $fieldName = 'page_builder'): Entry
{
$fieldType = $entry->blueprint()->field($fieldName)?->fieldType();
if ($fieldType === null) {
throw new InvalidArgumentException(__('Entry with blueprint ":blueprint" does not contain field ":fieldname"', ['blueprint' => $entry->blueprint()->handle, 'fieldname' => $fieldName]));
}

if (!$fieldType instanceof Replicator || (!$fieldType instanceof Bard && !$fieldType->flattenedSetsConfig()->hasAny('content'))) {
throw new InvalidArgumentException(__('":fieldname" on Blueprint ":blueprint" is not a Bard field or is not a replicator containing a Bard field', ['blueprint' => $entry->blueprint()->handle, 'fieldname' => $fieldName]));
}

$isReplicator = false;
if (!($fieldType instanceof Bard) && $fieldType instanceof Replicator) {
$isReplicator = true;
}

$html = $this->cleanHtml->execute($html);
if (trim($html) === '') {
return $entry;
}

$augmentor = new Augmentor($fieldType instanceof Bard ? $fieldType : resolve(Bard::class));
$data = $augmentor->renderHtmlToProsemirror($html);
$content = $this->pipeline
->send($data['content'])
->via('handle')
->through(config('rapidez.statamic.migration.pipelines.convert_field', []))
->thenReturn();

$currentContent = $isReplicator ? data_get($entry->get($fieldName), '0.content.content') : $entry->get($fieldName);
if (
$currentContent
// We must check by rendering to html since the array structure changes, but the html output does not.
&& $augmentor->renderProsemirrorToHtml(['content' => $currentContent]) == $augmentor->renderProsemirrorToHtml(['content' => $content])
) {
return $entry;
}

if ($isReplicator) {
// TODO: Currently we only support a fieldset named "content", containing a field called "content" which is a Bard field.
// Ideally we should determine the needed path by nesting fields in the Replicator until we find a Bard field.
$content = [[
'type' => 'content',
'content' => ['content' => $content]
]];
}

$entry->set($fieldName, $content);

return $entry;
}
}
134 changes: 134 additions & 0 deletions src/Commands/MigrateCmsPages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace Rapidez\Statamic\Commands;

use Statamic\Facades\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Rapidez\Core\Facades\Rapidez;
use Rapidez\Core\Models\Page;
use Rapidez\Statamic\Actions\ConvertField;
use Statamic\Eloquent\Entries\EntryQueryBuilder;
use Statamic\Facades\Entry;

class MigrateCmsPages extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'statamic-content-migration:migrate-cms-pages {--identifiers=} {--identifier-type=whitelist : Type of identifier, whitelist/blacklist}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate magento CMS pages into the "pages" collection';

private ConvertField $converter;

/** @var Collection<int,\Statamic\Sites\Site> $sitePerStoreId */
private Collection $sitePerStoreId;

/**
* Execute the console command.
*/
public function handle(ConvertField $converter): void
{
$this->converter = $converter;

$this->sitePerStoreId = Site::all()
->filter(fn(\Statamic\Sites\Site $site) => $site->attribute('magento_store_id'))
->mapWithKeys(fn(\Statamic\Sites\Site $site, $key): array => [$site->attribute('magento_store_id') => $site]);

foreach ($this->sitePerStoreId as $storeId => $site) {
Rapidez::setStore($storeId);

$this->createCmsPages($site);
}

$this->output->writeln(__('Don\'t forget to disable the pages in Magento or remove the cms page fallback route! ":line"', ['line' => 'Rapidez::removeFallbackRoute(CmsPageController::class);']));
}

public function createCmsPages(\Statamic\Sites\Site $site): void
{
/** @var Page $cmsPageModel */
$cmsPageModel = config('rapidez.models.page');
$pagesQuery = $cmsPageModel::query();

if ($this->option('identifiers')) {
$identifiers = is_array($this->option('identifiers')) ? $this->option('identifiers') : explode(',', $this->option('identifiers'));
$pagesQuery->when($this->option('identifier-type') === 'blacklist', fn($q) => $q->whereNotIn('identifier', $identifiers));
$pagesQuery->when($this->option('identifier-type') !== 'blacklist', fn($q) => $q->whereIn('identifier', $identifiers));
}

$this->output->progressStart($pagesQuery->count());

foreach ($pagesQuery->lazy() as $page) {
$this->output->progressAdvance();

/** @var EntryQueryBuilder $query */
$query = Entry::query();
if ($query
->where('collection', 'pages')
->where('slug', $page->identifier)
->where('site', $site->handle)
->exists()
) {
continue;
}

$this->createCmsPage($page, $site);
}

$this->output->progressFinish();
}

private function createCmsPage($page, \Statamic\Sites\Site $site): void
{
if (trim((string) $page->content) === '') {
return;
}

$pageStores = DB::table('cms_page_store')->where('page_id', $page->page_id)->pluck('store_id');

/** @var EntryQueryBuilder $query */
$query = Entry::query();
$query
->where('collection', 'pages')
->where('slug', $page->identifier);
if ($page->store_id !== 0) {
$query->whereIn('site', $this->sitePerStoreId->filter(fn(\Statamic\Sites\Site $aSite): bool => in_array($aSite->attribute('magento_store_id'), $pageStores->toArray()))->map(fn(\Statamic\Sites\Site $aSite) => $aSite->handle()));
}

/** @var ?\Statamic\Entries\Entry $originEntry */
$originEntry = $query->first();

if ($originEntry !== null) {
/** @var \Statamic\Entries\Entry $entry */
$entry = $originEntry->makeLocalization($site->handle);
} else {
/** @var \Statamic\Entries\Entry $entry */
$entry = Entry::make();
$entry
->collection('pages')
->locale($site)
->published(1)
->slug($page->identifier);
}

$entry->title = Rapidez::content($page->title); // @phpstan-ignore property.notFound

$this->converter->execute($entry, $page->content, 'page_builder');

$entry->seo = [ // @phpstan-ignore property.notFound
'title' => Rapidez::content($page->meta_title),
'description' => Rapidez::content($page->meta_description),
];

$entry->save();
}
}
57 changes: 57 additions & 0 deletions src/Pipelines/BardData/FixListItems.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Rapidez\Statamic\Pipelines\BardData;

use Closure;

class FixListItems
{
private const array BLOCK_NODE_TYPES = [
'paragraph', 'heading', 'bulletList', 'orderedList',
'codeBlock', 'blockquote', 'horizontalRule',
];

public function handle(array $data, Closure $next): array
{
return $next($this->fixListItems($data));
}

/**
* Recursively walk any value (array, nested arrays) and fix listItem nodes
* whose content consists entirely of inline nodes (e.g. bare "text" nodes)
* by wrapping them in a paragraph node, as Statamic's Bard editor requires.
*/
public function fixListItems(mixed $data): mixed
{
if (!is_array($data)) {
return $data;
}

if (($data['type'] ?? null) === 'listItem' && isset($data['content'])) {
$data['content'] = $this->wrapInlineContentInParagraph($data['content']);
}

foreach ($data as $key => $value) {
$data[$key] = $this->fixListItems($value);
}

return $data;
}

/**
* @param array<int, mixed> $content
* @return array<int, mixed>
*/
private function wrapInlineContentInParagraph(array $content): array
{
$allInline = collect($content)->every(
fn (mixed $node): bool => !in_array($node['type'] ?? null, self::BLOCK_NODE_TYPES, true)
);

if (!$allInline) {
return $content;
}

return [['type' => 'paragraph', 'content' => $content]];
}
}
32 changes: 32 additions & 0 deletions src/Pipelines/Html/HtmlThroughMarkdown.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Rapidez\Statamic\Pipelines\Html;

use Closure;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\Converter\TableConverter;
use League\HTMLToMarkdown\HtmlConverter;

class HtmlThroughMarkdown
{
private readonly ?HtmlConverter $converter;

public function __construct(private Pipeline $pipeline)
{
$this->converter = new HtmlConverter(['strip_tags' => true]);
$this->converter->getEnvironment()->addConverter(new TableConverter());
}

public function handle(string $html, Closure $next): string
{
$markdown = $this->converter->convert($html);
$markdown = $this->pipeline
->send($markdown)
->via('handle')
->through(config('rapidez.statamic.migration.pipelines.clean_html.markdown', []))
->thenReturn();

return $next(Str::markdown($markdown));
}
}
27 changes: 27 additions & 0 deletions src/Pipelines/Html/TransformIframes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Rapidez\Statamic\Pipelines\Html;

use Closure;

class TransformIframes
{
public function handle(string $html, Closure $next): string
{
return $next(preg_replace_callback('#<iframe[^>]*src="(https?://[^"]+)"[^>]*>(?:</iframe>)?#', $this->transformIframe(...), $html));
}

/**
* @param array<int, string> $matches
*/
private function transformIframe(array $matches): string
{
$value = (string) $matches[1];

if (str_contains($value, 'youtube.com/embed/')) {
$value = str_replace('youtube.com/embed/', 'youtube.com/watch?v=', $value);
}

return '<a href="'.$value.'">'.$value.'</a>';
}
}
Loading