├── .gitignore ├── slides.pdf ├── Tests ├── Functional │ ├── DemoApp │ │ ├── DemoBundle │ │ │ ├── Resources │ │ │ │ └── views │ │ │ │ │ └── Test │ │ │ │ │ ├── _foo_ │ │ │ │ │ └── header.html.twig │ │ │ │ │ ├── header.html.twig │ │ │ │ │ ├── __en_GB │ │ │ │ │ └── header.html.twig │ │ │ │ │ ├── _bar_de_DE │ │ │ │ │ └── header.html.twig │ │ │ │ │ ├── locale.html.twig │ │ │ │ │ └── index.html.twig │ │ │ ├── AlexMultisiteDemoBundle.php │ │ │ └── Controller │ │ │ │ └── TestController.php │ │ ├── routing.yml │ │ ├── AppKernel.php │ │ └── config.yml │ ├── DemoApp_NoSort │ │ ├── routing.yml │ │ ├── DemoBundle │ │ │ ├── AlexMultisiteDemoBundle.php │ │ │ └── Controller │ │ │ │ └── TestController.php │ │ ├── config.yml │ │ └── AppKernel.php │ ├── DemoApp_Security │ │ ├── routing.yml │ │ ├── DemoBundle │ │ │ ├── Resources │ │ │ │ └── views │ │ │ │ │ └── Test │ │ │ │ │ ├── homepage.html.twig │ │ │ │ │ └── login.html.twig │ │ │ ├── AlexMultisiteDemoBundle.php │ │ │ └── Controller │ │ │ │ └── TestController.php │ │ ├── AppKernel.php │ │ └── config.yml │ ├── WebTestCase.php │ ├── TemplatingTest.php │ ├── SecurityTest.php │ ├── ConfigTest.php │ ├── RoutingTest.php │ └── AbstractAppKernel.php └── Branding │ ├── BrandingTest.php │ └── SiteContextTest.php ├── autoload.php ├── CONTRIBUTORS.md ├── composer.json ├── .travis.yml ├── Resources └── config │ ├── framework_extra.xml │ ├── site_context.xml │ └── twig.xml ├── CHANGELOG.md ├── AlexMultisiteBundle.php ├── DependencyInjection ├── Compiler │ ├── TwigLoaderPass.php │ └── InjectSiteContextPass.php ├── Configuration.php └── AlexMultisiteExtension.php ├── Twig ├── MultisiteExtension.php └── MultisiteLoader.php ├── phpunit.xml.dist ├── LICENSE ├── Annotation └── Route.php ├── Branding ├── Branding.php └── SiteContext.php ├── Router ├── Loader │ └── AnnotatedRouteControllerLoader.php └── MultisiteRouter.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | -------------------------------------------------------------------------------- /slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandresalome/multisite-bundle/HEAD/slides.pdf -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Resources/views/Test/_foo_/header.html.twig: -------------------------------------------------------------------------------- 1 | Foo header 2 | ========== 3 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Resources/views/Test/header.html.twig: -------------------------------------------------------------------------------- 1 | Default header 2 | ============== 3 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Resources/views/Test/__en_GB/header.html.twig: -------------------------------------------------------------------------------- 1 | English header 2 | ============== 3 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/routing.yml: -------------------------------------------------------------------------------- 1 | _demo: 2 | resource: "@AlexMultisiteDemoBundle/Controller" 3 | type: annotation 4 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Resources/views/Test/_bar_de_DE/header.html.twig: -------------------------------------------------------------------------------- 1 | German header on bar 2 | ==================== 3 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_NoSort/routing.yml: -------------------------------------------------------------------------------- 1 | _demo: 2 | resource: "@AlexMultisiteDemoBundle/Controller" 3 | type: annotation 4 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_Security/routing.yml: -------------------------------------------------------------------------------- 1 | _demo: 2 | resource: "@AlexMultisiteDemoBundle/Controller" 3 | type: annotation 4 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | Homepage {{ site_context.currentBranding.name }}/{{ site_context.currentLocale }} 2 | 3 |

Login

4 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/AlexMultisiteDemoBundle.php: -------------------------------------------------------------------------------- 1 | Login form 2 | 3 |
4 |

Username:

5 |

Password:

6 |

7 |
8 | 9 |

Go back to homepage

10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.3 5 | - 5.4 6 | - 5.5 7 | - 5.6 8 | - 7.0 9 | - hhvm 10 | 11 | env: 12 | - SYMFONY_VERSION="2.4.*" 13 | - SYMFONY_VERSION="2.8.*" 14 | - SYMFONY_VERSION="3.0.*" 15 | 16 | matrix: 17 | exclude: 18 | - php: 5.3 19 | env: SYMFONY_VERSION="3.0.*" 20 | - php: 5.4 21 | env: SYMFONY_VERSION="3.0.*" 22 | 23 | before_script: 24 | - composer require symfony/symfony:${SYMFONY_VERSION} 25 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Resources/views/Test/index.html.twig: -------------------------------------------------------------------------------- 1 | {{ include('AlexMultisiteDemoBundle:Test:header.html.twig') }} 2 | Test homepage 3 | 4 | The index page 5 | Default link to locale page 6 | Link with locale 7 | Link with branding 8 | Link with branding 9 | -------------------------------------------------------------------------------- /Resources/config/framework_extra.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Alex\MultisiteBundle\Router\Loader\AnnotatedRouteControllerLoader 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Tests/Functional/WebTestCase.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new InjectSiteContextPass()); 15 | $container->addCompilerPass(new TwigLoaderPass()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_NoSort/AppKernel.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | %alex_multisite.default_branding% 12 | %alex_multisite.default_locale% 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /DependencyInjection/Compiler/TwigLoaderPass.php: -------------------------------------------------------------------------------- 1 | getAlias('twig.loader'); 19 | $container->setAlias('twig.loader', 'twig.loader.multisite'); 20 | $container->setAlias('twig.loader.previous', $alias); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Twig/MultisiteExtension.php: -------------------------------------------------------------------------------- 1 | siteContext = $siteContext; 14 | } 15 | 16 | /** 17 | * {@inheritdoc} 18 | */ 19 | public function getGlobals() 20 | { 21 | return array( 22 | 'site_context' => $this->siteContext 23 | ); 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function getName() 30 | { 31 | return 'multisite'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Resources/config/twig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Tests/Functional/TemplatingTest.php: -------------------------------------------------------------------------------- 1 | request('GET', 'http://foo.example.org/page-en'); 15 | $this->assertContains('Foo header', $client->getResponse()->getContent()); 16 | 17 | // bar - fr_FR 18 | $client->request('GET', 'http://bar.example.org/page-fr'); 19 | $this->assertContains('Default header', $client->getResponse()->getContent()); 20 | 21 | // bar - de_DE 22 | $client->request('GET', 'http://de.bar.example.org/page-de'); 23 | $this->assertContains('German header', $client->getResponse()->getContent()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Functional/SecurityTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/bar-en/'); 14 | $this->assertContains('Homepage bar/en_GB', $client->getResponse()->getContent()); 15 | 16 | $crawler = $client->click($crawler->filter('a:contains("Login")')->eq(0)->link()); 17 | $this->assertContains('Login form', $client->getResponse()->getContent()); 18 | 19 | $form = $crawler->selectButton('Submit')->form(array( 20 | '_username' => 'user', 21 | '_password' => 'user' 22 | )); 23 | 24 | $client->submit($form); 25 | 26 | $crawler = $client->followRedirect(); 27 | // make sure we stayed on same site 28 | $this->assertContains('Homepage bar/en_GB', $client->getResponse()->getContent()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_NoSort/DemoBundle/Controller/TestController.php: -------------------------------------------------------------------------------- 1 | request('GET', 'http://foo.example.org/page-en'); 15 | $this->assertContains('flag A', $client->getResponse()->getContent()); 16 | 17 | // foo - fr_FR 18 | $client->request('GET', 'http://foo.example.org/fr/page-fr'); 19 | $this->assertNotContains('flag A', $client->getResponse()->getContent()); 20 | 21 | // bar - fr_FR 22 | $client->request('GET', 'http://bar.example.org/page-fr'); 23 | $this->assertNotContains('flag B', $client->getResponse()->getContent()); 24 | } 25 | 26 | public function testNoSort() 27 | { 28 | $client = self::createClient('DemoApp_NoSort'); 29 | 30 | $client->request('GET', '/bar'); 31 | $this->assertContains('Route A', $client->getResponse()->getContent()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DependencyInjection/Compiler/InjectSiteContextPass.php: -------------------------------------------------------------------------------- 1 | getDefinition('sensio_framework_extra.routing.loader.annot_class')->addMethodCall('setSiteContext', array(new Reference('site_context'))); 20 | $container->getDefinition('router.default')->setClass('Alex\MultisiteBundle\Router\MultisiteRouter'); 21 | $container->getDefinition('router.default')->addMethodCall('setSiteContext', array(new Reference('site_context'))); 22 | $container->getDefinition('router.default')->addMethodCall('setSortRoutes', array("%alex_multisite.sort_routes%")); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | Tests 19 | 20 | 21 | 22 | 23 | 24 | . 25 | 26 | Resources 27 | Tests 28 | vendor 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Alexandre Salomé 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /Annotation/Route.php: -------------------------------------------------------------------------------- 1 | paths = $paths; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @return array|null 50 | */ 51 | public function getPaths() 52 | { 53 | return $this->paths; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_Security/config.yml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: foo 3 | router: 4 | resource: "%kernel.root_dir%/routing.yml" 5 | templating: { engine: twig } 6 | test: ~ 7 | session: 8 | storage_id: session.storage.mock_file 9 | 10 | security: 11 | providers: 12 | in_memory: 13 | memory: 14 | users: 15 | user: 16 | password: user 17 | roles: 'ROLE_USER' 18 | encoders: 19 | Symfony\Component\Security\Core\User\User: plaintext 20 | firewalls: 21 | default: 22 | anonymous: ~ 23 | form_login: 24 | login_path: login 25 | check_path: login-check 26 | default_target_path: homepage 27 | logout: 28 | path: logout 29 | 30 | alex_multisite: 31 | default_branding: foo 32 | default_locale: fr_FR 33 | brandings: 34 | foo: 35 | en_GB: { prefix: /foo-en } 36 | fr_FR: { prefix: /foo-fr } 37 | bar: 38 | fr_FR: { prefix: /bar-fr } 39 | en_GB: { prefix: /bar-en } 40 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | root('alex_multisite'); 20 | 21 | $rootNode 22 | ->children() 23 | ->booleanNode('sort_routes')->defaultTrue()->end() 24 | ->scalarNode('default_branding')->isRequired()->end() 25 | ->scalarNode('default_locale')->isRequired()->end() 26 | ->arrayNode('default_config') 27 | ->prototype('scalar')->end() 28 | ->end() 29 | ->arrayNode('brandings') 30 | ->prototype('variable') 31 | ->end() 32 | ->end() 33 | ->end() 34 | ; 35 | 36 | return $treeBuilder; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp/DemoBundle/Controller/TestController.php: -------------------------------------------------------------------------------- 1 | request('GET', $url); 23 | $this->assertContains('Test homepage', $client->getResponse()->getContent(), "Test ".$url); 24 | } 25 | } 26 | 27 | public function testLocaleOnly() 28 | { 29 | $client = self::createClient(); 30 | 31 | // foo - en_GB 32 | $client->request('GET', 'http://foo.example.org/page-en'); 33 | $this->assertContains('branding: foo, locale: en_GB', $client->getResponse()->getContent()); 34 | 35 | // foo - fr_FR 36 | $client->request('GET', 'http://foo.example.org/fr/page-fr'); 37 | $this->assertContains('branding: foo, locale: fr_FR', $client->getResponse()->getContent()); 38 | 39 | // bar - fr_FR 40 | $client->request('GET', 'http://bar.example.org/page-fr'); 41 | $this->assertContains('branding: bar, locale: fr_FR', $client->getResponse()->getContent()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/Branding/BrandingTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('foo', $branding->getName()); 13 | } 14 | 15 | public function testHasLocale() 16 | { 17 | $branding = new Branding('foo', array( 18 | 'fr_FR' => array('host' => 'foo') 19 | )); 20 | 21 | $this->assertFalse($branding->hasLocale('en_GB')); 22 | $this->assertTrue($branding->hasLocale('fr_FR')); 23 | } 24 | 25 | public function testGetHost() 26 | { 27 | $branding = new Branding('foo', array( 28 | 'fr_FR' => array('host' => 'foo') 29 | )); 30 | 31 | $this->assertEquals('foo', $branding->getHost('fr_FR')); 32 | $this->assertNull($branding->getHost('en_GB')); 33 | } 34 | 35 | public function testGetOption() 36 | { 37 | $branding = new Branding('foo', array('fr_FR' => array('bar' => 'bar'))); 38 | $this->assertEquals('bar', $branding->getOption('fr_FR', 'bar')); 39 | } 40 | 41 | public function testPrefixPath() 42 | { 43 | $branding = new Branding('foo', array( 44 | 'fr_FR' => array('prefix' => '/test') 45 | )); 46 | 47 | $this->assertEquals('/test/fr', $branding->prefixPath('fr_FR', '/fr')); 48 | $this->assertEquals('/fr', $branding->prefixPath('en_GB', '/fr')); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/Functional/DemoApp_Security/DemoBundle/Controller/TestController.php: -------------------------------------------------------------------------------- 1 | getSession(); 34 | $error = null; 35 | 36 | if ($request->attributes->has('_security.last_error')) { 37 | $error = $request->attributes->get('_security.last_error'); 38 | } elseif ($session !== null && $session->has('_security.last_error')) { 39 | $error = $session->get('_security.last_error'); 40 | $session->remove('_security.last_error'); 41 | } 42 | 43 | return array('error' => $error); 44 | } 45 | 46 | 47 | /** 48 | * @Route(name="login-check", path="/login-check") 49 | */ 50 | public function loginCheckAction() 51 | { 52 | throw new \LogicException('Should not be executed.'); 53 | } 54 | 55 | /** 56 | * @Route(name="logout", paths={ 57 | * "fr_FR"="/deconnexion", 58 | * "en_GB"="/logout", 59 | * }) 60 | */ 61 | public function logoutAction() 62 | { 63 | throw new \LogicException('Should not be executed.'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/Functional/AbstractAppKernel.php: -------------------------------------------------------------------------------- 1 | name) { 16 | return $this->name; 17 | } 18 | 19 | $class = get_class($this); 20 | $posEnd = strrpos($class, '\\'); 21 | $posStart = strrpos($class, '\\', $posEnd - strlen($class) - 1) + 1; 22 | 23 | $this->name = strtolower(substr($class, $posStart, $posEnd - $posStart)); 24 | 25 | return $this->name; 26 | } 27 | 28 | public function getDirectory() 29 | { 30 | if ($this->directory) { 31 | return $this->directory; 32 | } 33 | 34 | $this->directory = sys_get_temp_dir().'/test_ms_'.$this->getName(); 35 | 36 | if (!is_dir($this->directory)) { 37 | mkdir($this->directory, 0777, true); 38 | } 39 | 40 | return $this->directory; 41 | } 42 | 43 | public function getAdditionalBundles() 44 | { 45 | return array(); 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function registerBundles() 52 | { 53 | $bundles = array( 54 | new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), 55 | new \Symfony\Bundle\TwigBundle\TwigBundle(), 56 | new \Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(), 57 | new \Alex\MultisiteBundle\AlexMultisiteBundle(), 58 | ); 59 | 60 | return array_merge($bundles, $this->getAdditionalBundles()); 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function registerContainerConfiguration(LoaderInterface $loader) 67 | { 68 | $refl = new \ReflectionClass($this); 69 | $dir = dirname($refl->getFilename()); 70 | 71 | $loader->load($dir.'/config.yml'); 72 | } 73 | 74 | public function getCacheDir() 75 | { 76 | return $this->getDirectory(); 77 | } 78 | 79 | public function getLogDir() 80 | { 81 | return $this->getDirectory(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Tests/Branding/SiteContextTest.php: -------------------------------------------------------------------------------- 1 | getSiteContext(); 13 | $this->assertNull($context->getOption('foo')); 14 | $this->assertEquals('default', $context->getOption('inexisting', 'default')); 15 | $context->setCurrentLocale('en_GB'); 16 | $this->assertTrue($context->getOption('foo')); 17 | } 18 | 19 | public function testNormalizePaths_WithDetails() 20 | { 21 | $context = $this->getSiteContext(); 22 | 23 | $actual = array( 24 | 'foo' => array('fr_FR' => '/french'), 25 | 'bar' => array('de_DE' => '/german') 26 | ); 27 | 28 | $expected = array( 29 | 'foo' => array( 30 | 'fr_FR' => '/french' 31 | ), 32 | 'bar' => array( 33 | 'de_DE' => '/de/german' 34 | ), 35 | ); 36 | 37 | $this->assertEquals($expected, $context->normalizePaths($actual)); 38 | } 39 | 40 | public function testNormalizePaths_WithLocale() 41 | { 42 | $context = $this->getSiteContext(); 43 | 44 | $actual = array( 45 | 'fr_FR' => '/french', 46 | 'en_GB' => '/english', 47 | 'de_DE' => '/german', 48 | ); 49 | 50 | $expected = array( 51 | 'foo' => array( 52 | 'fr_FR' => '/french', 53 | 'en_GB' => '/english', 54 | ), 55 | 'bar' => array( 56 | 'fr_FR' => '/french', 57 | 'en_GB' => '/en/english', 58 | 'de_DE' => '/de/german', 59 | ), 60 | ); 61 | 62 | $this->assertEquals($expected, $context->normalizePaths($actual)); 63 | } 64 | 65 | private function getSiteContext() 66 | { 67 | $foo = new Branding('foo', array( 68 | 'fr_FR' => array('host' => 'foo.fr'), 69 | 'en_GB' => array('foo' => true, 'host' => 'foo.co.uk'), 70 | )); 71 | 72 | $bar = new Branding('bar', array( 73 | 'fr_FR' => array('host' => 'bar.com'), 74 | 'en_GB' => array('host' => 'bar.com', 'prefix' => '/en'), 75 | 'de_DE' => array('host' => 'bar.com', 'prefix' => '/de') 76 | )); 77 | 78 | return new SiteContext(array($foo, $bar), 'foo', 'fr_FR'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /DependencyInjection/AlexMultisiteExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration(new Configuration(), $configs); 23 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 24 | 25 | $container->setParameter('alex_multisite.default_branding', $config['default_branding']); 26 | $container->setParameter('alex_multisite.default_locale', $config['default_locale']); 27 | $container->setParameter('alex_multisite.sort_routes', $config['sort_routes']); 28 | 29 | $loader->load('site_context.xml'); 30 | $loader->load('framework_extra.xml'); 31 | $loader->load('twig.xml'); 32 | 33 | $this->addBrandingDefinition($container, $config['brandings']); 34 | } 35 | 36 | /** 37 | * Adds configured brandings to the site context service. 38 | * 39 | * @param ContainerBuilder $container 40 | * @param array $options 41 | */ 42 | private function addBrandingDefinition(ContainerBuilder $container, array $options) 43 | { 44 | $brandings = array(); 45 | 46 | if (isset($options['_defaults'])) { 47 | $globalOptions = $options['_defaults']; 48 | unset($options['_defaults']); 49 | } else { 50 | $globalOptions = array(); 51 | } 52 | 53 | foreach ($options as $name => $localeOptions) { 54 | if (isset($localeOptions['_defaults'])) { 55 | $brandingOptions = $localeOptions['_defaults']; 56 | unset($localeOptions['_defaults']); 57 | } else { 58 | $brandingOptions = array(); 59 | } 60 | 61 | $arg = array(); 62 | foreach ($localeOptions as $locale => $options) { 63 | $arg[$locale] = array_merge($globalOptions, $brandingOptions, $options); 64 | } 65 | 66 | $brandings[] = new Definition( 67 | 'Alex\MultisiteBundle\Branding\Branding', 68 | array($name, $arg) 69 | ); 70 | } 71 | 72 | $container->getDefinition('site_context')->replaceArgument(0, $brandings); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Branding/Branding.php: -------------------------------------------------------------------------------- 1 | array('host' => 'example.org', 'prefix' => '/fr'), 12 | * 'en_GB' => array('host' => 'example.org'), 13 | * ); 14 | */ 15 | class Branding 16 | { 17 | /** 18 | * @var string 19 | */ 20 | private $name; 21 | 22 | /** 23 | * @var array 24 | */ 25 | private $localesConfig; 26 | 27 | /** 28 | * Constructor of a branding. 29 | * 30 | * @param string $name 31 | * @param array $localesConfig 32 | */ 33 | public function __construct($name, array $localesConfig) 34 | { 35 | $this->name = $name; 36 | $this->localesConfig = $localesConfig; 37 | } 38 | 39 | /** 40 | * Returns the name of the branding. 41 | * 42 | * @return string 43 | */ 44 | public function getName() 45 | { 46 | return $this->name; 47 | } 48 | 49 | /** 50 | * Tests if branding has a given locale. 51 | * 52 | * @return boolean 53 | */ 54 | public function hasLocale($locale) 55 | { 56 | return isset($this->localesConfig[$locale]); 57 | } 58 | 59 | /** 60 | * Returns host configured for a locale, or null if not found. 61 | * 62 | * @param string $locale 63 | * 64 | * @return string|null returns a hostname or null if not found. 65 | */ 66 | public function getHost($locale) 67 | { 68 | if (!isset($this->localesConfig[$locale]['host'])) { 69 | return null; 70 | } 71 | 72 | return $this->localesConfig[$locale]['host']; 73 | } 74 | 75 | /** 76 | * Prefixes the path for a given locale, if a prefix is configured. 77 | * 78 | * ``prefixPath`` will return the path if no prefix is configured. 79 | * 80 | * @param string $locale 81 | * @param string $path 82 | * 83 | * @return string 84 | */ 85 | public function prefixPath($locale, $path) 86 | { 87 | if (isset($this->localesConfig[$locale]['prefix'])) { 88 | return $this->localesConfig[$locale]['prefix'].$path; 89 | } 90 | 91 | return $path; 92 | } 93 | 94 | /** 95 | * Returns value of an option. 96 | * 97 | * @param string $locale 98 | * @param string $name 99 | * @param mixed $default 100 | * 101 | * @return mixed 102 | */ 103 | public function getOption($locale, $name, $default = null) 104 | { 105 | return isset($this->localesConfig[$locale][$name]) ? $this->localesConfig[$locale][$name] : $default; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Twig/MultisiteLoader.php: -------------------------------------------------------------------------------- 1 | loader = $loader; 32 | $this->siteContext = $siteContext; 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function getSource($name) 39 | { 40 | $templates = $this->getTemplates($name); 41 | 42 | foreach ($templates as $template) { 43 | try { 44 | return $this->loader->getSource($template); 45 | } catch (\Twig_Error $e) { 46 | } 47 | } 48 | 49 | throw new \Twig_Error_Loader(sprintf("Template \"%s\" not found. Tried the following:\n%s", $name, implode("\n", $templates))); 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | */ 55 | protected function findTemplate($name) 56 | { 57 | return $this->loader->findTemplate($name); 58 | } 59 | 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function getCacheKey($name) 65 | { 66 | $templates = $this->getTemplates($name); 67 | 68 | foreach ($templates as $template) { 69 | try { 70 | return $this->loader->getCacheKey($template); 71 | } catch (\Twig_Error $e) { 72 | } 73 | } 74 | 75 | throw new \Twig_Error_Loader(sprintf("Template \"%s\" not found. Tried the following:\n%s", $name, implode("\n", $templates))); 76 | } 77 | 78 | /** 79 | * {@inheritdoc} 80 | */ 81 | public function isFresh($name, $time) 82 | { 83 | $templates = $this->getTemplates($name); 84 | 85 | foreach ($templates as $template) { 86 | try { 87 | return $this->loader->isFresh($template, $time); 88 | } catch (\Twig_Error $e) { 89 | } 90 | } 91 | 92 | throw new \Twig_Error_Loader(sprintf("Template \"%s\" not found. Tried the following:\n%s", $name, implode("\n", $templates))); 93 | } 94 | 95 | /** 96 | * {@inheritdoc} 97 | */ 98 | private function getTemplates($name) 99 | { 100 | $posA = strrpos($name, ':'); 101 | $posB = strrpos($name, '/'); 102 | $posC = strrpos($name, '/'); 103 | 104 | $b = $this->siteContext->getCurrentBrandingName(); 105 | $l = $this->siteContext->getCurrentLocale(); 106 | 107 | if ($posA === false && $posB === false && $posC === false) { 108 | $prefix = ''; 109 | $suffix = '/'.$name; 110 | } else { 111 | $pos = max($posA, $posB, $posC); 112 | $prefix = substr($name, 0, $pos + 1); 113 | $suffix = '/'.substr($name, $pos + 1); 114 | } 115 | 116 | return array( 117 | $prefix.'_'.$b.'_'.$l.''.$suffix, 118 | $prefix.'_'.$b.'_'.$suffix, 119 | $prefix.'__'.$l.''.$suffix, 120 | $name 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Router/Loader/AnnotatedRouteControllerLoader.php: -------------------------------------------------------------------------------- 1 | siteContext = $siteContext; 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | protected function addRoute(RouteCollection $collection, $annot, $globals, \ReflectionClass $class, \ReflectionMethod $method) 39 | { 40 | // If the annotation is not the Multisite's one, call parent method 41 | if (!$annot instanceof RouteAnnotation) { 42 | return parent::addRoute($collection, $annot, $globals, $class, $method); 43 | } 44 | 45 | // mono-route 46 | if (null === $annot->getPaths()) { 47 | return parent::addRoute($collection, $annot, $globals, $class, $method); 48 | } 49 | 50 | 51 | return $this->addMultisiteRoute($collection, $annot, $globals, $class, $method); 52 | } 53 | 54 | /** 55 | * Specific method to add a multisite route to the collection. 56 | * 57 | * Paths option should not be null. 58 | */ 59 | protected function addMultisiteRoute(RouteCollection $collection, $annot, $globals, \ReflectionClass $class, \ReflectionMethod $method) 60 | { 61 | $paths = $this->siteContext->normalizePaths($annot->getPaths()); 62 | 63 | foreach ($paths as $branding => $locales) { 64 | foreach ($locales as $locale => $path) { 65 | 66 | // this block of code is copied from Symfony\Component\Routing\Loader\AnnotationFileLoader 67 | $name = $annot->getName(); 68 | if (null === $name) { 69 | $name = $this->getDefaultRouteName($class, $method); 70 | } 71 | $name = MultisiteRouter::ROUTE_PREFIX.'_'.$branding.'_'.$locale.'__'.$name; 72 | 73 | $defaults = array_replace($globals['defaults'], $annot->getDefaults()); 74 | foreach ($method->getParameters() as $param) { 75 | if (!isset($defaults[$param->getName()]) && $param->isOptional()) { 76 | $defaults[$param->getName()] = $param->getDefaultValue(); 77 | } 78 | } 79 | 80 | // +2 lines 81 | $defaults['_branding'] = $branding; 82 | $defaults['_locale'] = $locale; 83 | 84 | $requirements = array_replace($globals['requirements'], $annot->getRequirements()); 85 | $options = array_replace($globals['options'], $annot->getOptions()); 86 | $schemes = array_replace($globals['schemes'], $annot->getSchemes()); 87 | $methods = array_replace($globals['methods'], $annot->getMethods()); 88 | 89 | $host = $annot->getHost(); 90 | if (null === $host) { 91 | $host = $globals['host']; 92 | } 93 | 94 | // +3 lines 95 | if (!$host) { 96 | $host = $this->siteContext->getBranding($branding)->getHost($locale); 97 | } 98 | 99 | $condition = $annot->getCondition(); 100 | if (null === $condition) { 101 | $condition = $globals['condition']; 102 | } 103 | 104 | $route = new Route($globals['path'].$path, $defaults, $requirements, $options, $host, $schemes, $methods, $condition); 105 | $this->configureRoute($route, $class, $method, $annot); 106 | 107 | $collection->add($name, $route); 108 | } 109 | } 110 | 111 | // cache will refresh when file is modified 112 | $collection->addResource(new FileResource($class->getFileName())); 113 | 114 | return $collection; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Router/MultisiteRouter.php: -------------------------------------------------------------------------------- 1 | sortRoutes = $value; 35 | } 36 | 37 | /** 38 | * Changes the site context. 39 | * 40 | * @param SiteContext $siteContext 41 | * 42 | * @return MultisiteRouter 43 | */ 44 | public function setSiteContext(SiteContext $siteContext) 45 | { 46 | $this->siteContext = $siteContext; 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function matchRequest(Request $request) 55 | { 56 | $match = parent::matchRequest($request); 57 | $this->setMatchContext($match); 58 | 59 | return $match; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function match($pathinfo) 66 | { 67 | $match = parent::match($pathinfo); 68 | $this->setMatchContext($match); 69 | 70 | return $match; 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | */ 76 | public function generate($name, $parameters = array(), $referenceType = self::ABSOLUTE_PATH) 77 | { 78 | if (isset($parameters['_branding'])) { 79 | $branding = $parameters['_branding']; 80 | unset($parameters['_branding']); 81 | } else { 82 | $branding = $this->getSiteContext()->getCurrentBrandingName(); 83 | } 84 | 85 | if (isset($parameters['_locale'])) { 86 | $locale = $parameters['_locale']; 87 | unset($parameters['_locale']); 88 | } else { 89 | $locale = $this->siteContext->getCurrentLocale(); 90 | } 91 | 92 | if (null !== $branding && null !== $locale) { 93 | $multisiteName = self::ROUTE_PREFIX.'_'.$branding.'_'.$locale.'__'.$name; 94 | try { 95 | return parent::generate($multisiteName, $parameters, $referenceType); 96 | } catch (RouteNotFoundException $e) { 97 | // fallback to default behavior 98 | } 99 | } 100 | 101 | return parent::generate($name, $parameters, $referenceType); 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | */ 107 | public function getRouteCollection() 108 | { 109 | $collection = parent::getRouteCollection(); 110 | $routes = $collection->all(); 111 | $newCollection = new RouteCollection(); 112 | 113 | $routes = $this->sortRoutes($routes); 114 | foreach ($routes as $name => $route) { 115 | $newCollection->add($name, $route); 116 | } 117 | 118 | return $newCollection; 119 | } 120 | 121 | /** 122 | * Sort routes by domain. 123 | * 124 | * @param Route[] $routes 125 | * 126 | * @return Route[] 127 | */ 128 | protected function sortRoutes(array $routes) 129 | { 130 | if (!$this->sortRoutes) { 131 | return $routes; 132 | } 133 | 134 | // group by host is a good-enough strategy for most of the cases 135 | $hosts = array(); 136 | foreach ($routes as $name => $route) { 137 | $branding = $route->getDefault('_branding'); 138 | $locale = $route->getDefault('_locale'); 139 | $hosts[$branding.'__'.$locale][$name] = $route; 140 | } 141 | 142 | return call_user_func_array('array_merge', $hosts); 143 | } 144 | 145 | /** 146 | * @return SiteContext 147 | * 148 | * @throws RuntimeException If no site context is available 149 | */ 150 | private function getSiteContext() 151 | { 152 | if (null === $this->siteContext) { 153 | throw new \RuntimeException('No site context injected in router.'); 154 | } 155 | 156 | return $this->siteContext; 157 | } 158 | 159 | /** 160 | * Changes router context and site context according to matched route. 161 | * 162 | * @param array $match 163 | */ 164 | private function setMatchContext(array $match) 165 | { 166 | if (isset($match['_branding'])) { 167 | $this->context->setParameter('_branding', $match['_branding']); 168 | 169 | $this->siteContext->setCurrentBranding($this->siteContext->getBranding($match['_branding'])); 170 | } 171 | 172 | if (isset($match['_locale'])) { 173 | $this->context->setParameter('_locale', $match['_locale']); 174 | 175 | $this->siteContext->setCurrentLocale($match['_locale']); 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AlexMultisiteBundle - Branding and Internationalization 2 | 3 | ![Build status](https://travis-ci.org/alexandresalome/multisite-bundle.png?branch=master) [![Latest Stable Version](https://poser.pugx.org/alexandresalome/multisite-bundle/v/stable)](https://packagist.org/packages/alexandresalome/multisite-bundle) [![Total Downloads](https://poser.pugx.org/alexandresalome/multisite-bundle/downloads)](https://packagist.org/packages/alexandresalome/multisite-bundle) [![License](https://poser.pugx.org/alexandresalome/multisite-bundle/license)](https://packagist.org/packages/alexandresalome/multisite-bundle) [![Monthly Downloads](https://poser.pugx.org/alexandresalome/multisite-bundle/d/monthly)](https://packagist.org/packages/alexandresalome/multisite-bundle) [![Daily Downloads](https://poser.pugx.org/alexandresalome/multisite-bundle/d/daily)](https://packagist.org/packages/alexandresalome/multisite-bundle) 4 | 5 | This bundle allows you to manage multiple brandings and multiple locales in a Symfony2 application. 6 | 7 | * [View slides](slides.pdf) 8 | * [CHANGELOG](CHANGELOG.md) 9 | * [CONTRIBUTORS](CONTRIBUTORS.md) 10 | 11 | **Requirements**: 12 | 13 | * FrameworkExtraBundle 14 | * TwigBundle 15 | 16 | ## Features 17 | 18 | * Multiple routes for each site 19 | * Configuration per site 20 | * Templates per site 21 | 22 | ## Installation 23 | 24 | Add to your **composer.json**: 25 | 26 | ```json 27 | { 28 | "require": { 29 | "alexandresalome/multisite-bundle": "~0.1" 30 | } 31 | } 32 | ``` 33 | 34 | Add the bundle to your kernel: 35 | 36 | ```php 37 | # app/AppKernel.php 38 | 39 | class AppKernel extends Kernel 40 | { 41 | public function registerBundles() 42 | { 43 | $bundles = array( 44 | # ... 45 | new Alex\MultisiteBundle\AlexMultisiteBundle(), 46 | ); 47 | } 48 | } 49 | ``` 50 | 51 | ## Configuration 52 | 53 | Add this section to your **config.yml** file: 54 | 55 | ```yaml 56 | alex_multisite: 57 | default_branding: branding_A 58 | default_locale: fr_FR 59 | brandings: 60 | _defaults: 61 | register: true 62 | branding_A: 63 | en_GB: { host: branding-a.com } 64 | fr_FR: { host: branding-a.com, prefix: /fr } 65 | branding_B: 66 | _defaults: 67 | register: false 68 | en_GB: { host: branding-b.com } 69 | de_DE: { host: branding-b.de, register: false } 70 | ``` 71 | 72 | In this section, you must configure your brandings and locales. 73 | 74 | You can also add extra options, like the **register** option here. 75 | 76 | ## Declare your routes 77 | 78 | In your controllers, substitute 79 | 80 | ```php 81 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 82 | ``` 83 | 84 | with 85 | 86 | ```php 87 | use Alex\MultisiteBundle\Annotation\Route; 88 | ``` 89 | 90 | You can then configure a multisite route in two ways: 91 | 92 | ```php 93 | /** 94 | * @Route(name="login", paths={ 95 | * "fr_FR"="/connexion", 96 | * "en_GB"="/login" 97 | * }) 98 | */ 99 | public function loginAction() 100 | # ... 101 | ``` 102 | 103 | The path will be the same for all brandings, but will be localized. If you 104 | want a different path for same locale in different sites: 105 | 106 | ```php 107 | /** 108 | * @Route(name="login", paths={ 109 | * "branding_A"={ 110 | * "fr_FR"="/connexion-on-A", 111 | * "en_GB"="/login-on-A", 112 | * }, 113 | * "branding_B"={ 114 | * "en_GB"="/login-on-B", 115 | * }, 116 | * }) 117 | */ 118 | public function loginAction() 119 | # ... 120 | ``` 121 | 122 | ## Override templates 123 | 124 | If you want to change a template for a specific site, create a similarly named file with branding/locale option in it: 125 | 126 | Given your default template is ``AcmeDemoBundle::contact.html.twig``. 127 | 128 | You can override it with branding, locale, or both: 129 | 130 | * ``AcmeDemoBundle::_branding_locale/contact.html.twig`` 131 | * ``AcmeDemoBundle::_branding_/contact.html.twig`` 132 | * ``AcmeDemoBundle::__locale/contact.html.twig`` 133 | 134 | Just create the file and it will automatically be loaded in place of the previous one. 135 | 136 | ## Read the site context 137 | 138 | **From templates**, use the global variable **site_context**, which returns a ``Alex\MultisiteBundle\Branding\SiteContext`` instance: 139 | 140 | ``` 141 | You are currently on {{ site_context.currentBrandingName }} 142 | Your locale is {{ site_context.currentLocale }} 143 | ``` 144 | 145 | You can also read options from config with: 146 | 147 | ``` 148 | The option register is {{ site_context.option('register') ? 'enabled': 'not enabled' }} 149 | ``` 150 | 151 | **In your controllers**, use service **site_context**: 152 | 153 | ```php 154 | public function indexAction() 155 | { 156 | $this->get('site_context')->getCurrentLocale(); 157 | $this->get('site_context')->getOption('register'); 158 | } 159 | ``` 160 | 161 | ## Disable route sorting 162 | 163 | You might want to rely on natural order of routes. If you're in this case, you can disable the optimization by providing the **sort_route** option: 164 | 165 | ```yaml 166 | alex_multisite: 167 | sort_routes: false 168 | # ... 169 | ``` 170 | 171 | ## Security 172 | 173 | You can combine this bundle with Symfony's **SecurityBundle**, and use this bundle for your routes for login form page. 174 | 175 | But, you must **not** use the multisite feature for the following routes: 176 | 177 | * login-check 178 | * logout 179 | -------------------------------------------------------------------------------- /Branding/SiteContext.php: -------------------------------------------------------------------------------- 1 | addBranding($branding); 48 | } 49 | 50 | $this->defaultBrandingName = $defaultBrandingName; 51 | $this->defaultLocale = $defaultLocale; 52 | } 53 | 54 | /** 55 | * Changes the current branding of site context. 56 | * 57 | * @param Branding $branding 58 | * 59 | * @return SiteContext 60 | */ 61 | public function setCurrentBranding(Branding $branding) 62 | { 63 | $this->currentBranding = $branding; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * Changes the current locale. 70 | * 71 | * @param string $locale 72 | * 73 | * @return SiteContext 74 | */ 75 | public function setCurrentLocale($locale) 76 | { 77 | $this->currentLocale = $locale; 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Returns name of the current branding. 84 | * 85 | * @return string 86 | */ 87 | public function getCurrentBrandingName() 88 | { 89 | return $this->getCurrentBranding()->getName(); 90 | } 91 | 92 | /** 93 | * Returns current branding. 94 | * 95 | * @return Branding 96 | */ 97 | public function getCurrentBranding() 98 | { 99 | if (null === $this->currentBranding) { 100 | return $this->getBranding($this->defaultBrandingName); 101 | } 102 | 103 | return $this->currentBranding; 104 | } 105 | 106 | /** 107 | * Returns current locale. 108 | * 109 | * @return string 110 | */ 111 | public function getCurrentLocale() 112 | { 113 | if (null === $this->currentLocale) { 114 | return $this->defaultLocale; 115 | } 116 | 117 | return $this->currentLocale; 118 | } 119 | 120 | /** 121 | * Returns a given branding. 122 | * 123 | * @param string $name 124 | * 125 | * @return Branding 126 | */ 127 | public function getBranding($name) 128 | { 129 | foreach ($this->brandings as $branding) { 130 | if ($branding->getName() === $name) { 131 | return $branding; 132 | } 133 | } 134 | 135 | $names = array_map(function ($branding) { 136 | return $branding->getName(); 137 | }, $this->brandings); 138 | 139 | throw new \InvalidArgumentException(sprintf('No branding named "%s". Available are: %s.', $name, implode(', ', $names))); 140 | } 141 | 142 | /** 143 | * Returns brandings with a given locale. 144 | * 145 | * @param string $locale 146 | * 147 | * @return Branding[] 148 | */ 149 | public function getBrandingsWithLocale($locale) 150 | { 151 | $result = array(); 152 | foreach ($this->brandings as $branding) { 153 | if ($branding->hasLocale($locale)) { 154 | $result[] = $branding; 155 | } 156 | } 157 | 158 | return $result; 159 | } 160 | 161 | /** 162 | * Converts an array used in annotation to an associative array branding/locale. 163 | * 164 | * @return array 165 | */ 166 | public function normalizePaths(array $paths) 167 | { 168 | $result = array(); 169 | 170 | foreach ($paths as $key => $value) { 171 | // key is locale 172 | if (is_string($value)) { 173 | foreach ($this->getBrandingsWithLocale($key) as $branding) { 174 | $result[$branding->getName()][$key] = $branding->prefixPath($key, $value); 175 | } 176 | } 177 | 178 | // key is branding 179 | if (is_array($value)) { 180 | foreach ($value as $locale => $path) { 181 | $result[$key][$locale] = $this->getBranding($key)->prefixPath($locale, $path); 182 | } 183 | } 184 | } 185 | 186 | return $result; 187 | } 188 | 189 | /** 190 | * Returns value of an option. 191 | * 192 | * @param string $name 193 | * @param mixed $default 194 | * 195 | * @return mixed 196 | */ 197 | public function getOption($name, $default = null) 198 | { 199 | return $this->getCurrentBranding()->getOption($this->getCurrentLocale(), $name, $default); 200 | } 201 | 202 | /** 203 | * Adds a new branding to the context. 204 | * 205 | * @param Branding $branding 206 | * 207 | * @return SiteContext 208 | */ 209 | private function addBranding(Branding $branding) 210 | { 211 | $this->brandings[] = $branding; 212 | 213 | return $this; 214 | } 215 | } 216 | --------------------------------------------------------------------------------