├── src ├── Resources │ ├── docs │ │ └── permalinks.png │ ├── config │ │ ├── controller.yaml │ │ ├── routes.yaml │ │ └── services.yaml │ └── views │ │ ├── page.html.twig │ │ ├── base.html.twig │ │ └── index.html.twig ├── WordpressBundle.php ├── Parser │ ├── MenuParserInterface.php │ ├── PageParserInterface.php │ ├── MediaParserInterface.php │ ├── CategoryParserInterface.php │ ├── RewriteMediaUrl.php │ ├── RewriteUrls.php │ ├── RewriteImageReferences.php │ ├── RewriteLinks.php │ └── MessageParser.php ├── Model │ ├── Menu.php │ ├── Media.php │ ├── MenuItem.php │ ├── Category.php │ └── Page.php ├── Service │ ├── ImageUploaderInterface.php │ ├── LocalImageUploader.php │ └── Wordpress.php ├── Twig │ └── WordpressExtension.php ├── Controller │ └── WordpressController.php ├── DependencyInjection │ ├── Configuration.php │ └── WordpressExtension.php └── Api │ └── WpClient.php ├── phpstan.neon.dist ├── psalm.xml ├── phpstan-baseline.neon ├── composer.json ├── psalm.baseline.xml ├── tests └── Parser │ ├── RewriteMediaUrlTest.php │ ├── RewriteUrlsTest.php │ ├── RewriteImageReferencesTest.php │ └── RewriteLinksTest.php └── README.md /src/Resources/docs/permalinks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Happyr/symfony-wpblog/master/src/Resources/docs/permalinks.png -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 5 6 | reportUnmatchedIgnoredErrors: false 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /src/Resources/config/controller.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Happyr\WordpressBundle\Controller\WordpressController: 3 | autowire: true 4 | autoconfigure: true 5 | arguments: ['@Happyr\WordpressBundle\Service\Wordpress', ~, ~, ~] 6 | -------------------------------------------------------------------------------- /src/WordpressBundle.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class WordpressBundle extends Bundle 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/Parser/MenuParserInterface.php: -------------------------------------------------------------------------------- 1 | 5 |
children
6 | $configuration
11 | $remoteUrl['host']
16 | $remoteUrl['host']
21 | $testUrl['path']
22 | $remoteUrl['host']
27 | [\'"])(.+?)(?P=quote)|sim', $content, $matches)) { 34 | return $content; 35 | } 36 | 37 | $remoteUrl = parse_url($this->remoteUrl); 38 | 39 | for ($i = 0; $i < count($matches[0]); ++$i) { 40 | $url = $matches[2][$i]; 41 | $testUrl = parse_url($url); 42 | if (!empty($testUrl['host']) && $testUrl['host'] !== $remoteUrl['host']) { 43 | continue; 44 | } 45 | 46 | // download the media and store it somewhere good. 47 | $replacement = $this->imageUploader->uploadImage($url); 48 | 49 | // rewrite the URL. 50 | $content = str_replace($url, $replacement, $content); 51 | } 52 | 53 | return $content; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Model/MenuItem.php: -------------------------------------------------------------------------------- 1 | id = (int) $data['id']; 31 | $this->parentId = (int) $data['parent_id']; 32 | $this->title = $data['title']; 33 | $this->type = $data['type']; 34 | $this->icon = $data['icon']; 35 | } 36 | 37 | public function getId(): int 38 | { 39 | return $this->id; 40 | } 41 | 42 | public function setId(int $id): void 43 | { 44 | $this->id = $id; 45 | } 46 | 47 | public function getParentId(): int 48 | { 49 | return $this->parentId; 50 | } 51 | 52 | public function setParentId(int $parentId): void 53 | { 54 | $this->parentId = $parentId; 55 | } 56 | 57 | public function getTitle(): string 58 | { 59 | return $this->title; 60 | } 61 | 62 | public function setTitle(string $title): void 63 | { 64 | $this->title = $title; 65 | } 66 | 67 | public function getType(): string 68 | { 69 | return $this->type; 70 | } 71 | 72 | public function setType(string $type): void 73 | { 74 | $this->type = $type; 75 | } 76 | 77 | public function getIcon(): string 78 | { 79 | return $this->icon; 80 | } 81 | 82 | public function setIcon(string $icon): void 83 | { 84 | $this->icon = $icon; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Parser/RewriteLinks.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class RewriteLinks implements PageParserInterface, MenuParserInterface 15 | { 16 | private $remoteUrl; 17 | private $urlGenerator; 18 | 19 | public function __construct(string $remoteUrl, UrlGeneratorInterface $urlGenerator) 20 | { 21 | $this->remoteUrl = $remoteUrl; 22 | $this->urlGenerator = $urlGenerator; 23 | } 24 | 25 | public function parsePage(Page $page): void 26 | { 27 | $page->setContent($this->rewrite($page->getContent())); 28 | $page->setExcerpt($this->rewrite($page->getExcerpt())); 29 | } 30 | 31 | public function parseMenu(Menu $menu): void 32 | { 33 | // TODO: Implement parseMenu() method. 34 | } 35 | 36 | private function rewrite(string $content): string 37 | { 38 | if (!preg_match_all('|href=(?P[\'"])(.+?)(?P=quote)|si', $content, $matches)) { 39 | return $content; 40 | } 41 | 42 | $remoteUrl = parse_url($this->remoteUrl); 43 | for ($i = 0; $i < count($matches[0]); ++$i) { 44 | $url = $matches[2][$i]; 45 | $testUrl = parse_url($url); 46 | if (empty($testUrl['host']) || $testUrl['host'] !== $remoteUrl['host']) { 47 | continue; 48 | } 49 | if (preg_match('@/(?:page|post)/(.*)@si', $testUrl['path'], $urlMatch)) { 50 | $replacement = $this->urlGenerator->generate('happyr_wordpress_page', ['slug' => $urlMatch[1]]); 51 | $content = str_replace($url, $replacement, $content); 52 | } 53 | } 54 | 55 | return $content; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Model/Category.php: -------------------------------------------------------------------------------- 1 | id = $data['id']; 56 | $this->count = $data['count']; 57 | $this->description = $data['description']; 58 | $this->link = $data['link']; 59 | $this->name = $data['name']; 60 | $this->slug = $data['slug']; 61 | $this->taxonomy = $data['taxonomy']; 62 | $this->parentId = $data['parent']; 63 | } 64 | 65 | /** 66 | * @return int 67 | */ 68 | public function getId() 69 | { 70 | return $this->id; 71 | } 72 | 73 | public function getCount(): int 74 | { 75 | return $this->count; 76 | } 77 | 78 | public function getDescription(): string 79 | { 80 | return $this->description; 81 | } 82 | 83 | public function getLink(): string 84 | { 85 | return $this->link; 86 | } 87 | 88 | public function getName(): string 89 | { 90 | return $this->name; 91 | } 92 | 93 | public function getSlug(): string 94 | { 95 | return $this->slug; 96 | } 97 | 98 | public function getTaxonomy(): string 99 | { 100 | return $this->taxonomy; 101 | } 102 | 103 | public function getParentId(): int 104 | { 105 | return $this->parentId; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Parser/RewriteImageReferencesTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(ImageUploaderInterface::class) 20 | ->onlyMethods(['uploadImage']) 21 | ->getMock(); 22 | $router->expects($this->once()) 23 | ->method('uploadImage') 24 | ->with($inputUrl) 25 | ->willReturn($outputUrl); 26 | 27 | $page = $this->getMockBuilder(Page::class) 28 | ->disableOriginalConstructor() 29 | ->onlyMethods(['getContent', 'setContent', 'getExcerpt', 'setExcerpt']) 30 | ->getMock(); 31 | $page->expects($this->once()) 32 | ->method('getContent') 33 | ->willReturn($input); 34 | 35 | $page->expects($this->once()) 36 | ->method('setContent') 37 | ->with($output); 38 | 39 | $page->expects($this->once()) 40 | ->method('getExcerpt') 41 | ->willReturn('text'); 42 | $page->expects($this->once()) 43 | ->method('setExcerpt') 44 | ->with('text'); 45 | 46 | $parser = new RewriteImageReferences($apiUrl, $router); 47 | $parser->parsePage($page); 48 | } 49 | 50 | public function pageProvider() 51 | { 52 | $apiUrl = 'http://wordpress.com/wp-json/wp/v2/'; 53 | $input = 'FooBar'; 54 | $inputUrl = 'http://wordpress.com/wp-conent/uploads/2018/foobar.jpg'; 55 | $outputUrl = '/uploads/foobar.jpg'; 56 | $output = 'Foo
Bar'; 57 | 58 | yield [$apiUrl, $input, $inputUrl, $output, $outputUrl]; 59 | yield [$apiUrl, 'Foo
Bar', '/wp-conent/uploads/2018/foobar.jpg', $output, $outputUrl]; 60 | yield [$apiUrl, 'Foo
Bar', $inputUrl, 'Foo
Bar', $outputUrl]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Parser/RewriteLinksTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(UrlGenerator::class) 20 | ->disableOriginalConstructor() 21 | ->onlyMethods(['generate']) 22 | ->getMock(); 23 | $router->expects($this->once()) 24 | ->method('generate') 25 | ->with('happyr_wordpress_page', $routeParams) 26 | ->willReturn($newUrl); 27 | 28 | $page = $this->getMockBuilder(Page::class) 29 | ->disableOriginalConstructor() 30 | ->onlyMethods(['getContent', 'setContent', 'getExcerpt', 'setExcerpt']) 31 | ->getMock(); 32 | $page->expects($this->once()) 33 | ->method('getContent') 34 | ->willReturn($input); 35 | 36 | $page->expects($this->once()) 37 | ->method('setContent') 38 | ->with($output); 39 | 40 | $page->expects($this->once()) 41 | ->method('getExcerpt') 42 | ->willReturn('text'); 43 | $page->expects($this->once()) 44 | ->method('setExcerpt') 45 | ->with('text'); 46 | 47 | $parser = new RewriteLinks($apiUrl, $router); 48 | $parser->parsePage($page); 49 | } 50 | 51 | public function pageProvider() 52 | { 53 | $apiUrl = 'http://wordpress.com/wp-json/wp/v2/'; 54 | $routeParams = ['slug' => 'foobar']; 55 | $newUrl = 'https://new-url.com/foobar'; 56 | $input = 'Click me'; 57 | $output = 'Click me'; 58 | 59 | yield [$input, $output, $apiUrl, $newUrl, $routeParams]; 60 | yield ['Click me', $output, $apiUrl, $newUrl, $routeParams]; 61 | yield ['Click me', 'Click me', $apiUrl, 'https://new-url.com/foo/bar', ['slug' => 'foo/bar']]; 62 | yield ['Click me', 'Click me', $apiUrl, $newUrl, $routeParams]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Configuration implements ConfigurationInterface 18 | { 19 | public function getConfigTreeBuilder() 20 | { 21 | $treeBuilder = new TreeBuilder('wordpress'); 22 | $root = $treeBuilder->getRootNode(); 23 | 24 | $root->children() 25 | ->scalarNode('url')->cannotBeEmpty()->isRequired()->end() 26 | ->arrayNode('cache') 27 | ->children() 28 | ->scalarNode('service')->cannotBeEmpty()->isRequired()->end() 29 | ->integerNode('ttl')->defaultValue(3600)->end() 30 | ->end() 31 | ->end() 32 | ->arrayNode('controller') 33 | ->canBeDisabled() 34 | ->children() 35 | ->scalarNode('index_template')->defaultValue('@Wordpress/index.html.twig')->end() 36 | ->scalarNode('page_template')->defaultValue('@Wordpress/page.html.twig')->end() 37 | ->booleanNode('allow_invalidate')->defaultTrue()->info('Add an endpoint for invalidating pages')->end() 38 | ->end() 39 | ->end() 40 | ->arrayNode('local_image_uploader') 41 | ->canBeDisabled() 42 | ->children() 43 | ->scalarNode('local_path')->defaultValue('%kernel.project_dir%/public/uploads')->end() 44 | ->scalarNode('public_prefix')->defaultValue('/uploads')->end() 45 | ->end() 46 | ->end() 47 | ->arrayNode('parser') 48 | ->addDefaultsIfNotSet() 49 | ->children() 50 | ->arrayNode('image') 51 | ->canBeDisabled() 52 | ->children() 53 | ->scalarNode('uploader')->defaultValue(LocalImageUploader::class)->end() 54 | ->end() 55 | ->end() 56 | ->arrayNode('link') 57 | ->canBeDisabled() 58 | ->children()->end() 59 | ->end() 60 | ->arrayNode('url') 61 | ->canBeDisabled() 62 | ->children()->end() 63 | ->end() 64 | ->end() 65 | ->end() 66 | ->end(); 67 | 68 | return $treeBuilder; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/DependencyInjection/WordpressExtension.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class WordpressExtension extends Extension 22 | { 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function load(array $configs, ContainerBuilder $container) 27 | { 28 | $configuration = $this->getConfiguration($configs, $container); 29 | $config = $this->processConfiguration($configuration, $configs); 30 | 31 | $remoteUrl = rtrim($config['url'], '/'); 32 | $container->setParameter('happyr_wordpress.remote_url', $remoteUrl); 33 | 34 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 35 | $loader->load('services.yaml'); 36 | 37 | if ($config['controller']['enabled']) { 38 | $loader->load('controller.yaml'); 39 | $container->getDefinition(WordpressController::class) 40 | ->replaceArgument(1, $config['controller']['index_template']) 41 | ->replaceArgument(2, $config['controller']['page_template']) 42 | ->replaceArgument(3, $config['controller']['allow_invalidate']); 43 | } 44 | 45 | $container->getDefinition(Wordpress::class) 46 | ->replaceArgument(2, new Reference($config['cache']['service'])) 47 | ->replaceArgument(3, $config['cache']['ttl']); 48 | 49 | $container->getDefinition(LocalImageUploader::class) 50 | ->replaceArgument(0, $config['local_image_uploader']['local_path']) 51 | ->replaceArgument(1, $config['local_image_uploader']['public_prefix']); 52 | 53 | $this->configureParsers($container, $config['parser']); 54 | } 55 | 56 | private function configureParsers(ContainerBuilder $container, array $config) 57 | { 58 | $parsers = ['image' => RewriteImageReferences::class, 'link' => RewriteLinks::class, 'url' => RewriteUrls::class]; 59 | foreach ($parsers as $key => $serviceId) { 60 | if (!$config[$key]['enabled']) { 61 | $container->removeDefinition($serviceId); 62 | } 63 | } 64 | 65 | if ($config['image']['enabled']) { 66 | $container->getDefinition(RewriteImageReferences::class) 67 | ->replaceArgument(1, new Reference($config['image']['uploader'])); 68 | $container->getDefinition(RewriteMediaUrl::class) 69 | ->replaceArgument(1, new Reference($config['image']['uploader'])); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Parser/MessageParser.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class MessageParser 18 | { 19 | private $pageParsers; 20 | private $menuParsers; 21 | private $mediaParsers; 22 | private $categoryParsers; 23 | 24 | /** 25 | * @param PageParserInterface[] $pageParsers 26 | * @param MenuParserInterface[] $menuParsers 27 | * @param MediaParserInterface[] $mediaParsers 28 | * @param CategoryParserInterface[] $categoryParsers 29 | */ 30 | public function __construct(iterable $pageParsers, iterable $menuParsers, iterable $mediaParsers, iterable $categoryParsers) 31 | { 32 | $this->pageParsers = $pageParsers; 33 | $this->menuParsers = $menuParsers; 34 | $this->mediaParsers = $mediaParsers; 35 | $this->categoryParsers = $categoryParsers; 36 | } 37 | 38 | public function parsePage(array $data): ?Page 39 | { 40 | try { 41 | $page = new Page($data); 42 | } catch (\Throwable $t) { 43 | return null; 44 | } 45 | 46 | foreach ($this->pageParsers as $parser) { 47 | $parser->parsePage($page); 48 | } 49 | 50 | return $page; 51 | } 52 | 53 | public function parseMenu(array $data): ?Menu 54 | { 55 | try { 56 | $menu = new Menu($data); 57 | } catch (\Throwable $t) { 58 | return null; 59 | } 60 | 61 | foreach ($this->menuParsers as $parser) { 62 | $parser->parseMenu($menu); 63 | } 64 | 65 | return $menu; 66 | } 67 | 68 | /** 69 | * @return Category[] 70 | */ 71 | public function parseCategories(array $data): array 72 | { 73 | $collection = []; 74 | if (isset($data['id'])) { 75 | $data = [$data]; 76 | } 77 | foreach ($data as $d) { 78 | try { 79 | $category = new Category($d); 80 | foreach ($this->categoryParsers as $parser) { 81 | $parser->parseCategory($category); 82 | } 83 | $collection[] = $category; 84 | } catch (\Throwable $t) { 85 | continue; 86 | } 87 | } 88 | 89 | return $collection; 90 | } 91 | 92 | /** 93 | * @return Media[] 94 | */ 95 | public function parseMedia(array $data): array 96 | { 97 | $collection = []; 98 | if (isset($data['id'])) { 99 | $data = [$data]; 100 | } 101 | foreach ($data as $d) { 102 | try { 103 | $media = new Media($d); 104 | foreach ($this->mediaParsers as $parser) { 105 | $parser->parseMedia($media); 106 | } 107 | $collection[] = $media; 108 | } catch (\Throwable $t) { 109 | continue; 110 | } 111 | } 112 | 113 | return $collection; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Api/WpClient.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class WpClient 18 | { 19 | private $baseUrl; 20 | private $httpClient; 21 | private $requestFactory; 22 | 23 | public function __construct(ClientInterface $httpClient, RequestFactoryInterface $requestFactory, string $baseUrl) 24 | { 25 | $this->baseUrl = $baseUrl; 26 | $this->requestFactory = $requestFactory; 27 | $this->httpClient = $httpClient; 28 | } 29 | 30 | public function getPostList(string $query): array 31 | { 32 | $request = $this->requestFactory->createRequest('GET', $this->baseUrl.'/wp/v2/posts'.$query); 33 | $response = $this->httpClient->sendRequest($request); 34 | 35 | return $this->jsonDecode($response); 36 | } 37 | 38 | public function getPage(string $slug): array 39 | { 40 | // Check pages 41 | $request = $this->requestFactory->createRequest('GET', $this->baseUrl.'/wp/v2/pages?slug='.$slug); 42 | $response = $this->httpClient->sendRequest($request); 43 | 44 | $data = $this->jsonDecode($response); 45 | if (count($data) >= 1) { 46 | return $data[0]; 47 | } 48 | 49 | // Check posts 50 | $request = $this->requestFactory->createRequest('GET', $this->baseUrl.'/wp/v2/posts?slug='.$slug); 51 | $response = $this->httpClient->sendRequest($request); 52 | 53 | $data = $this->jsonDecode($response); 54 | if (count($data) >= 1) { 55 | return $data[0]; 56 | } 57 | 58 | return []; 59 | } 60 | 61 | /** 62 | * This requires the https://wordpress.org/plugins/tutexp-rest-api-menu/ to be installed. 63 | */ 64 | public function getMenu(string $slug): array 65 | { 66 | $request = $this->requestFactory->createRequest('GET', $this->baseUrl.'/tutexpmenu/v2/menus/'.$slug); 67 | $response = $this->httpClient->sendRequest($request); 68 | 69 | $data = $this->jsonDecode($response); 70 | if (count($data) >= 1) { 71 | return $data; 72 | } 73 | 74 | return []; 75 | } 76 | 77 | /** 78 | * Generic GET. 79 | * 80 | * @param string $uri example "/wp/v2/categories" 81 | * 82 | * @throws \Psr\Http\Client\ClientExceptionInterface 83 | */ 84 | public function getUri(string $uri): array 85 | { 86 | $request = $this->requestFactory->createRequest('GET', $this->baseUrl.$uri); 87 | $response = $this->httpClient->sendRequest($request); 88 | 89 | $data = $this->jsonDecode($response); 90 | if (empty($data)) { 91 | return []; 92 | } 93 | 94 | return $data; 95 | } 96 | 97 | private function jsonDecode(ResponseInterface $response): array 98 | { 99 | $body = $response->getBody()->__toString(); 100 | $contentType = $response->getHeaderLine('Content-Type'); 101 | if (0 !== strpos($contentType, 'application/json') && 0 !== strpos($contentType, 'application/octet-stream')) { 102 | throw new \RuntimeException('The ModelHydrator cannot hydrate response with Content-Type: '.$contentType); 103 | } 104 | $data = json_decode($body, true); 105 | if (JSON_ERROR_NONE !== json_last_error()) { 106 | throw new \RuntimeException(sprintf('Error (%d) when trying to json_decode response', json_last_error())); 107 | } 108 | 109 | if (!is_array($data)) { 110 | return []; 111 | } 112 | 113 | return $data; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Happyr WordPress Bundle 2 | 3 | Let your editors write blog posts with Wordpress excellent backend but still deliver 4 | super quick responses, leverage Twig and integrate with your blog content with 5 | your Symfony application. As an extra bonus, this means that your WordPress application 6 | does not need to be exposed to the internet. 7 | 8 | This is a small bundle that talks to the WordPress REST API. We make sure to cache 9 | each request so your blog do not get overwhelmed with requests. 10 | 11 | ## WordPress configuration 12 | 13 | ### Rewriting links 14 | 15 | We need to rewrite absolute URLs from WordPress. To make things easier for us, please 16 | set your page and post url prefix to "page". 17 | 18 |  19 | 20 | ### Invalidate cache 21 | 22 | You should configure Symfony to be very aggressive when caching resources from 23 | WordPress. But when an editor makes an update you need to invalidate the cache 24 | and redownload the updated resource. 25 | 26 | The Symfony bundle provides an endpoint to invalidate cache. You should use this 27 | endpoint when a post in updated and deleted. 28 | 29 | (TODO add a small wordpress plugin for this in `Resources/Wordpress`) 30 | 31 | ## Symfony installation 32 | 33 | There are quite a few moving parts to set up this bundle. But they all make perfect 34 | sense. Lets take them one by one: 35 | 36 | ### API endpoint 37 | 38 | Where is your WordPress blog? You should define the endpoint to the build-in REST 39 | API. In the example below we assume you access your WordPress app with the following 40 | URL: `http://demo.wp-api.org`. 41 | 42 | ```yaml 43 | # /config/packages/happyr_wordpress.yaml 44 | wordpress: 45 | url: 'http://demo.wp-api.org/wp-json' 46 | ``` 47 | 48 | ### Templates 49 | 50 | The bundle comes with 2 default templates. One for an index page that list your 51 | latest posts and one template for a single post/page. You should of course replace 52 | these with something you like better. This could easily be done with some configuration: 53 | 54 | ```yaml 55 | # /config/packages/happyr_wordpress.yaml 56 | wordpress: 57 | # ... 58 | controller: 59 | index_template: index.html.twig 60 | page_template: page.html.twig 61 | ``` 62 | 63 | ### Routes 64 | 65 | To enable the default controllers you need to include the provided routes.yaml. 66 | ```yaml 67 | # /config/routes.yaml 68 | wordpress: 69 | resource: '@WordpressBundle/Resources/config/routes.yaml' 70 | prefix: '/p' # optional 71 | ``` 72 | 73 | You may of course use your own controllers. Just make sure that you define a route 74 | named `happyr_wordpress_page`. 75 | 76 | ```yaml 77 | # /config/packages/happyr_wordpress.yaml 78 | wordpress: 79 | # ... 80 | controller: false 81 | 82 | ``` 83 | ```yaml 84 | # /config/routes.yaml 85 | # ... 86 | 87 | happyr_wordpress_page: 88 | path: /wp/{slug} 89 | methods: 'GET' 90 | controller: App\Controller\MyWordpressController::show 91 | requirements: 92 | slug: '.+' 93 | ``` 94 | 95 | ### Caching 96 | 97 | WordPress is a great tool but it is slower than your Symfony application. Make 98 | sure we cache all responses from Wordpress. We use `Symfony\Contracts\Cache\CacheInterface` 99 | for caching because it got stampede protection built-in. 100 | 101 | ```yaml 102 | # /config/packages/happyr_wordpress.yaml 103 | wordpress: 104 | # ... 105 | cache: 106 | service: 'App\Cache\SymfonyCache' 107 | ttl: 604800 # One week 108 | ``` 109 | 110 | ### Parsers 111 | 112 | When we fetch data from WordPress we need to parse it somehow. We need to make sure 113 | all links refer to the symfony application and not the WordPress application. We 114 | also need to handle the image references. 115 | 116 | You may disable parses you do not want with configuration: 117 | 118 | ```yaml 119 | # /config/packages/happyr_wordpress.yaml 120 | wordpress: 121 | # ... 122 | parser: 123 | image: false 124 | link: false 125 | url: false 126 | ``` 127 | 128 | You may also add your own parsers by register a new service and tag it with 129 | `happyr_wordpress.parser.page` or `happyr_wordpress.parser.menu`. 130 | 131 | ### Images 132 | 133 | We do not want any references to images to go to the WordPress application. We 134 | need to download the image and upload it somewhere good. Like AWS S3. You can 135 | configure the `RewriteImageReferences` parser with a custom uploader to achieve 136 | this. Make sure your uploader implements `ImageUploaderInterface`. 137 | 138 | ```yaml 139 | # /config/packages/happyr_wordpress.yaml 140 | wordpress: 141 | # ... 142 | parser: 143 | image: 144 | uploader: 'App\MyUploaderService' 145 | ``` 146 | 147 | The default uploader uploads images to a local folder. This is alright if there 148 | only is a few images and you have CloudFront or any other reverse proxy caches 149 | in front of your Symfony application. 150 | 151 | ```yaml 152 | # /config/packages/happyr_wordpress.yaml 153 | wordpress: 154 | # ... 155 | local_image_uploader: 156 | local_path: '%kernel.project_dir%/public/uploads' 157 | public_prefix: '/uploads' 158 | ``` 159 | -------------------------------------------------------------------------------- /src/Service/Wordpress.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class Wordpress 21 | { 22 | private $cache; 23 | private $client; 24 | private $messageParser; 25 | private $ttl; 26 | 27 | public function __construct(WpClient $client, MessageParser $parser, CacheInterface $cache, int $ttl) 28 | { 29 | $this->client = $client; 30 | $this->messageParser = $parser; 31 | $this->cache = $cache; 32 | $this->ttl = $ttl; 33 | } 34 | 35 | /** 36 | * Get a list of posts. Ie for the start page. Feel free to pass any query to 37 | * Wordpress API. 38 | * 39 | * {@link https://developer.wordpress.org/rest-api/reference/posts/#arguments} 40 | */ 41 | public function listPosts(string $query = ''): array 42 | { 43 | return $this->cache->get($this->getCacheKey('query', $query), function (ItemInterface $item) use ($query) { 44 | $data = $this->client->getPostList($query); 45 | if (!$this->isValidResponse($data)) { 46 | $item->expiresAfter(30); 47 | 48 | return []; 49 | } 50 | 51 | $item->expiresAfter($this->ttl); 52 | $pages = []; 53 | foreach ($data as $d) { 54 | $pages[] = $this->messageParser->parsePage($d); 55 | } 56 | 57 | return $pages; 58 | }); 59 | } 60 | 61 | public function getPage(string $slug): ?Page 62 | { 63 | return $this->cache->get($this->getCacheKey('page', $slug), function (ItemInterface $item) use ($slug) { 64 | $data = $this->client->getPage($slug); 65 | if (!$this->isValidResponse($data)) { 66 | $item->expiresAfter(300); 67 | 68 | return null; 69 | } 70 | 71 | $item->expiresAfter($this->ttl); 72 | 73 | return $this->messageParser->parsePage($data); 74 | }); 75 | } 76 | 77 | public function getMenu(string $slug): ?Menu 78 | { 79 | return $this->cache->get($this->getCacheKey('menu', $slug), function (ItemInterface $item) use ($slug) { 80 | $data = $this->client->getMenu($slug); 81 | if (!$this->isValidResponse($data)) { 82 | $item->expiresAfter(300); 83 | 84 | return null; 85 | } 86 | 87 | $item->expiresAfter($this->ttl); 88 | 89 | return $this->messageParser->parseMenu($data); 90 | }); 91 | } 92 | 93 | public function getCategories(string $slug = ''): array 94 | { 95 | return $this->cache->get($this->getCacheKey('categories', $slug), function (ItemInterface $item) use ($slug) { 96 | $data = $this->client->getUri('/wp/v2/categories'.$slug); 97 | if (!$this->isValidResponse($data)) { 98 | $item->expiresAfter(300); 99 | 100 | return null; 101 | } 102 | 103 | $item->expiresAfter($this->ttl); 104 | 105 | return $this->messageParser->parseCategories($data); 106 | }); 107 | } 108 | 109 | public function getMedia(string $slug = ''): array 110 | { 111 | return $this->cache->get($this->getCacheKey('media', $slug), function (ItemInterface $item) use ($slug) { 112 | $data = $this->client->getUri('/wp/v2/media'.$slug); 113 | if (!$this->isValidResponse($data)) { 114 | $item->expiresAfter(300); 115 | 116 | return null; 117 | } 118 | 119 | $item->expiresAfter($this->ttl); 120 | 121 | return $this->messageParser->parseMedia($data); 122 | }); 123 | } 124 | 125 | /** 126 | * Purge cache for pages and menus. 127 | */ 128 | public function purgeCache(string $identifier): void 129 | { 130 | // Make sure to expire item 131 | $callback = function (ItemInterface $item) { 132 | $item->expiresAfter(0); 133 | }; 134 | 135 | // Get item and force recompute. 136 | $this->cache->get($this->getCacheKey('page', $identifier), $callback, INF); 137 | $this->cache->get($this->getCacheKey('menu', $identifier), $callback, INF); 138 | $this->cache->get($this->getCacheKey('categories', $identifier), $callback, INF); 139 | $this->cache->get($this->getCacheKey('media', $identifier), $callback, INF); 140 | $this->cache->get($this->getCacheKey('query', $identifier), $callback, INF); 141 | } 142 | 143 | private function getCacheKey(string $prefix, string $identifier): string 144 | { 145 | return sha1($prefix.'_'.$identifier); 146 | } 147 | 148 | private function isValidResponse($data): bool 149 | { 150 | if (isset($data['code']) && isset($data['data']['status']) && 400 === $data['data']['status']) { 151 | return false; 152 | } 153 | 154 | return !empty($data); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Model/Page.php: -------------------------------------------------------------------------------- 1 | createdAt = new \DateTimeImmutable($data['date_gmt'], $utc); 61 | $this->updatedAt = new \DateTimeImmutable($data['modified_gmt'], $utc); 62 | $diff = $this->createdAt->diff(new \DateTimeImmutable($data['date'])); 63 | $seconds = $diff->h * 60 * 60 + $diff->m * 60; 64 | $tz = timezone_name_from_abbr('', $seconds, 1); 65 | // Workaround for bug #44780 66 | if (false === $tz) { 67 | $tz = timezone_name_from_abbr('', $seconds, 0); 68 | } 69 | $localTimeZone = new \DateTimeZone($tz); 70 | $this->createdAt = $this->createdAt->setTimezone($localTimeZone); 71 | $this->updatedAt = $this->updatedAt->setTimezone($localTimeZone); 72 | 73 | $this->id = $data['id']; 74 | $this->guid = $data['guid']['rendered']; 75 | $this->slug = $data['slug']; 76 | $this->status = $data['status']; 77 | $this->type = $data['type']; 78 | $this->link = $data['link']; 79 | $this->title = $data['title']['rendered']; 80 | $this->content = $data['content']['rendered']; 81 | $this->excerpt = $data['excerpt']['rendered']; 82 | $this->author = $data['author']; 83 | $this->featuredMedia = $data['featured_media']; 84 | $this->commentStatus = $data['comment_status']; 85 | $this->pingStatus = $data['ping_status']; 86 | $this->sticky = $data['sticky']; 87 | $this->template = $data['template']; 88 | $this->format = $data['format']; 89 | $this->meta = $data['meta']; 90 | $this->categories = $data['categories']; 91 | $this->tags = $data['tags']; 92 | } 93 | 94 | public function getId(): int 95 | { 96 | return $this->id; 97 | } 98 | 99 | public function setId(int $id): void 100 | { 101 | $this->id = $id; 102 | } 103 | 104 | public function getGuid(): string 105 | { 106 | return $this->guid; 107 | } 108 | 109 | public function setGuid(string $guid): void 110 | { 111 | $this->guid = $guid; 112 | } 113 | 114 | public function getCreatedAt(): \DateTimeImmutable 115 | { 116 | return $this->createdAt; 117 | } 118 | 119 | public function setCreatedAt(\DateTimeImmutable $createdAt): void 120 | { 121 | $this->createdAt = $createdAt; 122 | } 123 | 124 | public function getUpdatedAt(): \DateTimeImmutable 125 | { 126 | return $this->updatedAt; 127 | } 128 | 129 | public function setUpdatedAt(\DateTimeImmutable $updatedAt): void 130 | { 131 | $this->updatedAt = $updatedAt; 132 | } 133 | 134 | public function getSlug(): string 135 | { 136 | return $this->slug; 137 | } 138 | 139 | public function setSlug(string $slug): void 140 | { 141 | $this->slug = $slug; 142 | } 143 | 144 | public function getStatus(): string 145 | { 146 | return $this->status; 147 | } 148 | 149 | public function setStatus(string $status): void 150 | { 151 | $this->status = $status; 152 | } 153 | 154 | public function getType(): string 155 | { 156 | return $this->type; 157 | } 158 | 159 | public function setType(string $type): void 160 | { 161 | $this->type = $type; 162 | } 163 | 164 | public function getLink(): string 165 | { 166 | return $this->link; 167 | } 168 | 169 | public function setLink(string $link): void 170 | { 171 | $this->link = $link; 172 | } 173 | 174 | public function getTitle(): string 175 | { 176 | return $this->title; 177 | } 178 | 179 | public function setTitle(string $title): void 180 | { 181 | $this->title = $title; 182 | } 183 | 184 | public function getContent(): string 185 | { 186 | return $this->content; 187 | } 188 | 189 | public function setContent(string $content): void 190 | { 191 | $this->content = $content; 192 | } 193 | 194 | public function getExcerpt(): string 195 | { 196 | return $this->excerpt; 197 | } 198 | 199 | public function setExcerpt(string $excerpt): void 200 | { 201 | $this->excerpt = $excerpt; 202 | } 203 | 204 | public function getAuthor(): int 205 | { 206 | return $this->author; 207 | } 208 | 209 | public function setAuthor(int $author): void 210 | { 211 | $this->author = $author; 212 | } 213 | 214 | public function getFeaturedMedia(): int 215 | { 216 | return $this->featuredMedia; 217 | } 218 | 219 | public function setFeaturedMedia(int $featuredMedia): void 220 | { 221 | $this->featuredMedia = $featuredMedia; 222 | } 223 | 224 | public function getCommentStatus(): string 225 | { 226 | return $this->commentStatus; 227 | } 228 | 229 | public function setCommentStatus(string $commentStatus): void 230 | { 231 | $this->commentStatus = $commentStatus; 232 | } 233 | 234 | public function getPingStatus(): string 235 | { 236 | return $this->pingStatus; 237 | } 238 | 239 | public function setPingStatus(string $pingStatus): void 240 | { 241 | $this->pingStatus = $pingStatus; 242 | } 243 | 244 | public function isSticky(): bool 245 | { 246 | return $this->sticky; 247 | } 248 | 249 | public function setSticky(bool $sticky): void 250 | { 251 | $this->sticky = $sticky; 252 | } 253 | 254 | public function getTemplate(): string 255 | { 256 | return $this->template; 257 | } 258 | 259 | public function setTemplate(string $template): void 260 | { 261 | $this->template = $template; 262 | } 263 | 264 | public function getFormat(): string 265 | { 266 | return $this->format; 267 | } 268 | 269 | public function setFormat(string $format): void 270 | { 271 | $this->format = $format; 272 | } 273 | 274 | public function getMeta(): array 275 | { 276 | return $this->meta; 277 | } 278 | 279 | public function setMeta(array $meta): void 280 | { 281 | $this->meta = $meta; 282 | } 283 | 284 | public function getCategories(): array 285 | { 286 | return $this->categories; 287 | } 288 | 289 | public function setCategories(array $categories): void 290 | { 291 | $this->categories = $categories; 292 | } 293 | 294 | public function getTags(): array 295 | { 296 | return $this->tags; 297 | } 298 | 299 | public function setTags(array $tags): void 300 | { 301 | $this->tags = $tags; 302 | } 303 | } 304 | --------------------------------------------------------------------------------