├── .ddev └── config.yaml ├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .idea ├── .gitignore ├── composerJson.xml ├── laravel-storyblok.iml ├── misc.xml ├── modules.xml ├── php-test-framework.xml ├── php.xml └── vcs.xml ├── .phpunit.cache └── test-results ├── .styleci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── storyblok.php ├── renovate.json ├── src ├── Block.php ├── Console │ ├── BlockMakeCommand.php │ ├── BlockSyncCommand.php │ ├── FolderMakeCommand.php │ ├── PageMakeCommand.php │ ├── StubViewsCommand.php │ └── stubs │ │ ├── blade.stub │ │ ├── block.blade.stub │ │ ├── block.scss.stub │ │ ├── block.stub │ │ ├── folder.stub │ │ ├── page.blade.stub │ │ └── page.stub ├── Events │ ├── PublishingEvent.php │ ├── StoryblokPublished.php │ └── StoryblokUnpublished.php ├── Exceptions │ ├── DenylistedUrlException.php │ └── UnableToRenderException.php ├── Field.php ├── FieldFactory.php ├── Fields │ ├── Asset.php │ ├── AssetLink.php │ ├── DateTime.php │ ├── EmailLink.php │ ├── Image.php │ ├── Link.php │ ├── Markdown.php │ ├── MultiAsset.php │ ├── RichText.php │ ├── StoryLink.php │ ├── SvgImage.php │ ├── Table.php │ ├── Textarea.php │ └── UrlLink.php ├── Folder.php ├── Http │ ├── Controllers │ │ ├── LiveContentController.php │ │ ├── StoryblokController.php │ │ └── WebhookController.php │ └── Middleware │ │ └── StoryblokEditor.php ├── Listeners │ └── ClearCache.php ├── Page.php ├── RequestStory.php ├── Solutions │ └── CreateMissingBlockSolution.php ├── Storyblok.php ├── StoryblokFacade.php ├── StoryblokServiceProvider.php ├── Support │ ├── ImageTransformation.php │ ├── ImageTransformers │ │ ├── BaseTransformer.php │ │ ├── Imgix.php │ │ ├── Storyblok.php │ │ ├── StoryblokLegacy.php │ │ └── StoryblokSvg.php │ └── NullPage.php ├── Traits │ ├── HasChildClasses.php │ ├── HasMeta.php │ ├── HasSettings.php │ └── SchemaOrg.php ├── resources │ └── views │ │ ├── editor-bridge.blade.php │ │ ├── embeds │ │ └── youtube.blade.php │ │ ├── picture-element.blade.php │ │ └── srcset.blade.php └── routes │ └── api.php └── stubs ├── Asset.stub ├── Block.stub ├── Folder.stub └── Page.stub /.ddev/config.yaml: -------------------------------------------------------------------------------- 1 | name: laravel-storyblok 2 | type: php 3 | docroot: "" 4 | php_version: "8.3" 5 | webserver_type: nginx-fpm 6 | xdebug_enabled: false 7 | additional_hostnames: [] 8 | additional_fqdns: [] 9 | database: 10 | type: mariadb 11 | version: "10.4" 12 | use_dns_when_possible: true 13 | composer_version: "2" 14 | web_environment: [] 15 | 16 | # Key features of DDEV's config.yaml: 17 | 18 | # name: # Name of the project, automatically provides 19 | # http://projectname.ddev.site and https://projectname.ddev.site 20 | 21 | # type: # drupal6/7/8, backdrop, typo3, wordpress, php 22 | 23 | # docroot: # Relative path to the directory containing index.php. 24 | 25 | # php_version: "8.1" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" 26 | 27 | # You can explicitly specify the webimage but this 28 | # is not recommended, as the images are often closely tied to DDEV's' behavior, 29 | # so this can break upgrades. 30 | 31 | # webimage: # nginx/php docker image. 32 | 33 | # database: 34 | # type: # mysql, mariadb, postgres 35 | # version: # database version, like "10.4" or "8.0" 36 | # MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0 37 | # PostgreSQL versions can be 9-16. 38 | 39 | # router_http_port: # Port to be used for http (defaults to global configuration, usually 80) 40 | # router_https_port: # Port for https (defaults to global configuration, usually 443) 41 | 42 | # xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart" 43 | # Note that for most people the commands 44 | # "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, 45 | # as leaving Xdebug enabled all the time is a big performance hit. 46 | 47 | # xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" 48 | # Note that for most people the commands 49 | # "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, 50 | # as leaving Xhprof enabled all the time is a big performance hit. 51 | 52 | # webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn 53 | 54 | # timezone: Europe/Berlin 55 | # This is the timezone used in the containers and by PHP; 56 | # it can be set to any valid timezone, 57 | # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 58 | # For example Europe/Dublin or MST7MDT 59 | 60 | # composer_root: 61 | # Relative path to the Composer root directory from the project root. This is 62 | # the directory which contains the composer.json and where all Composer related 63 | # commands are executed. 64 | 65 | # composer_version: "2" 66 | # You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 67 | # to use the latest major version available at the time your container is built. 68 | # It is also possible to use each other Composer version channel. This includes: 69 | # - 2.2 (latest Composer LTS version) 70 | # - stable 71 | # - preview 72 | # - snapshot 73 | # Alternatively, an explicit Composer version may be specified, for example "2.2.18". 74 | # To reinstall Composer after the image was built, run "ddev debug refresh". 75 | 76 | # nodejs_version: "18" 77 | # change from the default system Node.js version to another supported version, like 16, 18, 20. 78 | # Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any 79 | # Node.js version, including v6, etc. 80 | # You only need to configure this if you are not using nvm and you want to use a major 81 | # version that is not the default. 82 | 83 | # additional_hostnames: 84 | # - somename 85 | # - someothername 86 | # would provide http and https URLs for "somename.ddev.site" 87 | # and "someothername.ddev.site". 88 | 89 | # additional_fqdns: 90 | # - example.com 91 | # - sub1.example.com 92 | # would provide http and https URLs for "example.com" and "sub1.example.com" 93 | # Please take care with this because it can cause great confusion. 94 | 95 | # upload_dirs: "custom/upload/dir" 96 | # 97 | # upload_dirs: 98 | # - custom/upload/dir 99 | # - ../private 100 | # 101 | # would set the destination paths for ddev import-files to /custom/upload/dir 102 | # When Mutagen is enabled this path is bind-mounted so that all the files 103 | # in the upload_dirs don't have to be synced into Mutagen. 104 | 105 | # disable_upload_dirs_warning: false 106 | # If true, turns off the normal warning that says 107 | # "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set" 108 | 109 | # ddev_version_constraint: "" 110 | # Example: 111 | # ddev_version_constraint: ">= 1.22.4" 112 | # This will enforce that the running ddev version is within this constraint. 113 | # See https://github.com/Masterminds/semver#checking-version-constraints for 114 | # supported constraint formats 115 | 116 | # working_dir: 117 | # web: /var/www/html 118 | # db: /home 119 | # would set the default working directory for the web and db services. 120 | # These values specify the destination directory for ddev ssh and the 121 | # directory in which commands passed into ddev exec are run. 122 | 123 | # omit_containers: [db, ddev-ssh-agent] 124 | # Currently only these containers are supported. Some containers can also be 125 | # omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit 126 | # the "db" container, several standard features of DDEV that access the 127 | # database container will be unusable. In the global configuration it is also 128 | # possible to omit ddev-router, but not here. 129 | 130 | # performance_mode: "global" 131 | # DDEV offers performance optimization strategies to improve the filesystem 132 | # performance depending on your host system. Should be configured globally. 133 | # 134 | # If set, will override the global config. Possible values are: 135 | # - "global": uses the value from the global config. 136 | # - "none": disables performance optimization for this project. 137 | # - "mutagen": enables Mutagen for this project. 138 | # - "nfs": enables NFS for this project. 139 | # 140 | # See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs 141 | # See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen 142 | 143 | # fail_on_hook_fail: False 144 | # Decide whether 'ddev start' should be interrupted by a failing hook 145 | 146 | # host_https_port: "59002" 147 | # The host port binding for https can be explicitly specified. It is 148 | # dynamic unless otherwise specified. 149 | # This is not used by most people, most people use the *router* instead 150 | # of the localhost port. 151 | 152 | # host_webserver_port: "59001" 153 | # The host port binding for the ddev-webserver can be explicitly specified. It is 154 | # dynamic unless otherwise specified. 155 | # This is not used by most people, most people use the *router* instead 156 | # of the localhost port. 157 | 158 | # host_db_port: "59002" 159 | # The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic 160 | # unless explicitly specified. 161 | 162 | # mailpit_http_port: "8025" 163 | # mailpit_https_port: "8026" 164 | # The Mailpit ports can be changed from the default 8025 and 8026 165 | 166 | # host_mailpit_port: "8025" 167 | # The mailpit port is not normally bound on the host at all, instead being routed 168 | # through ddev-router, but it can be bound directly to localhost if specified here. 169 | 170 | # webimage_extra_packages: [php7.4-tidy, php-bcmath] 171 | # Extra Debian packages that are needed in the webimage can be added here 172 | 173 | # dbimage_extra_packages: [telnet,netcat] 174 | # Extra Debian packages that are needed in the dbimage can be added here 175 | 176 | # use_dns_when_possible: true 177 | # If the host has internet access and the domain configured can 178 | # successfully be looked up, DNS will be used for hostname resolution 179 | # instead of editing /etc/hosts 180 | # Defaults to true 181 | 182 | # project_tld: ddev.site 183 | # The top-level domain used for project URLs 184 | # The default "ddev.site" allows DNS lookup via a wildcard 185 | # If you prefer you can change this to "ddev.local" to preserve 186 | # pre-v1.9 behavior. 187 | 188 | # ngrok_args: --basic-auth username:pass1234 189 | # Provide extra flags to the "ngrok http" command, see 190 | # https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" 191 | 192 | # disable_settings_management: false 193 | # If true, DDEV will not create CMS-specific settings files like 194 | # Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php 195 | # In this case the user must provide all such settings. 196 | 197 | # You can inject environment variables into the web container with: 198 | # web_environment: 199 | # - SOMEENV=somevalue 200 | # - SOMEOTHERENV=someothervalue 201 | 202 | # no_project_mount: false 203 | # (Experimental) If true, DDEV will not mount the project into the web container; 204 | # the user is responsible for mounting it manually or via a script. 205 | # This is to enable experimentation with alternate file mounting strategies. 206 | # For advanced users only! 207 | 208 | # bind_all_interfaces: false 209 | # If true, host ports will be bound on all network interfaces, 210 | # not the localhost interface only. This means that ports 211 | # will be available on the local network if the host firewall 212 | # allows it. 213 | 214 | # default_container_timeout: 120 215 | # The default time that DDEV waits for all containers to become ready can be increased from 216 | # the default 120. This helps in importing huge databases, for example. 217 | 218 | #web_extra_exposed_ports: 219 | #- name: nodejs 220 | # container_port: 3000 221 | # http_port: 2999 222 | # https_port: 3000 223 | #- name: something 224 | # container_port: 4000 225 | # https_port: 4000 226 | # http_port: 3999 227 | # Allows a set of extra ports to be exposed via ddev-router 228 | # Fill in all three fields even if you don’t intend to use the https_port! 229 | # If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start. 230 | # 231 | # The port behavior on the ddev-webserver must be arranged separately, for example 232 | # using web_extra_daemons. 233 | # For example, with a web app on port 3000 inside the container, this config would 234 | # expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 235 | # web_extra_exposed_ports: 236 | # - name: myapp 237 | # container_port: 3000 238 | # http_port: 9998 239 | # https_port: 9999 240 | 241 | #web_extra_daemons: 242 | #- name: "http-1" 243 | # command: "/var/www/html/node_modules/.bin/http-server -p 3000" 244 | # directory: /var/www/html 245 | #- name: "http-2" 246 | # command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" 247 | # directory: /var/www/html 248 | 249 | # override_config: false 250 | # By default, config.*.yaml files are *merged* into the configuration 251 | # But this means that some things can't be overridden 252 | # For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge 253 | # and you can't erase existing hooks or all environment variables. 254 | # However, with "override_config: true" in a particular config.*.yaml file, 255 | # 'use_dns_when_possible: false' can override the existing values, and 256 | # hooks: 257 | # post-start: [] 258 | # or 259 | # web_environment: [] 260 | # or 261 | # additional_hostnames: [] 262 | # can have their intended affect. 'override_config' affects only behavior of the 263 | # config.*.yaml file it exists in. 264 | 265 | # Many DDEV commands can be extended to run tasks before or after the 266 | # DDEV command is executed, for example "post-start", "post-import-db", 267 | # "pre-composer", "post-composer" 268 | # See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more 269 | # information on the commands that can be extended and the tasks you can define 270 | # for them. Example: 271 | #hooks: 272 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi:riclep 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ develop, feature/** ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: php-actions/composer@v6 21 | - uses: php-actions/phpunit@v3 22 | with: 23 | configuration: phpunit.xml.dist 24 | args: --coverage-text 25 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml 3 | -------------------------------------------------------------------------------- /.idea/composerJson.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/laravel-storyblok.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | /etc/php/8.3/cli/conf.d/10-mysqlnd.ini, /etc/php/8.3/cli/conf.d/10-opcache.ini, /etc/php/8.3/cli/conf.d/10-pdo.ini, /etc/php/8.3/cli/conf.d/15-xml.ini, /etc/php/8.3/cli/conf.d/20-apcu.ini, /etc/php/8.3/cli/conf.d/20-assert.ini, /etc/php/8.3/cli/conf.d/20-bcmath.ini, /etc/php/8.3/cli/conf.d/20-bz2.ini, /etc/php/8.3/cli/conf.d/20-calendar.ini, /etc/php/8.3/cli/conf.d/20-ctype.ini, /etc/php/8.3/cli/conf.d/20-curl.ini, /etc/php/8.3/cli/conf.d/20-dom.ini, /etc/php/8.3/cli/conf.d/20-exif.ini, /etc/php/8.3/cli/conf.d/20-ffi.ini, /etc/php/8.3/cli/conf.d/20-fileinfo.ini, /etc/php/8.3/cli/conf.d/20-ftp.ini, /etc/php/8.3/cli/conf.d/20-gd.ini, /etc/php/8.3/cli/conf.d/20-gettext.ini, /etc/php/8.3/cli/conf.d/20-iconv.ini, /etc/php/8.3/cli/conf.d/20-igbinary.ini, /etc/php/8.3/cli/conf.d/20-imagick.ini, /etc/php/8.3/cli/conf.d/20-intl.ini, /etc/php/8.3/cli/conf.d/20-ldap.ini, /etc/php/8.3/cli/conf.d/20-mbstring.ini, /etc/php/8.3/cli/conf.d/20-msgpack.ini, /etc/php/8.3/cli/conf.d/20-mysqli.ini, /etc/php/8.3/cli/conf.d/20-pdo_mysql.ini, /etc/php/8.3/cli/conf.d/20-pdo_pgsql.ini, /etc/php/8.3/cli/conf.d/20-pdo_sqlite.ini, /etc/php/8.3/cli/conf.d/20-pgsql.ini, /etc/php/8.3/cli/conf.d/20-phar.ini, /etc/php/8.3/cli/conf.d/20-posix.ini, /etc/php/8.3/cli/conf.d/20-readline.ini, /etc/php/8.3/cli/conf.d/20-redis.ini, /etc/php/8.3/cli/conf.d/20-shmop.ini, /etc/php/8.3/cli/conf.d/20-simplexml.ini, /etc/php/8.3/cli/conf.d/20-soap.ini, /etc/php/8.3/cli/conf.d/20-sockets.ini, /etc/php/8.3/cli/conf.d/20-sqlite3.ini, /etc/php/8.3/cli/conf.d/20-sysvmsg.ini, /etc/php/8.3/cli/conf.d/20-sysvsem.ini, /etc/php/8.3/cli/conf.d/20-sysvshm.ini, /etc/php/8.3/cli/conf.d/20-tokenizer.ini, /etc/php/8.3/cli/conf.d/20-uploadprogress.ini, /etc/php/8.3/cli/conf.d/20-xmlreader.ini, /etc/php/8.3/cli/conf.d/20-xmlrpc.ini, /etc/php/8.3/cli/conf.d/20-xmlwriter.ini, /etc/php/8.3/cli/conf.d/20-xsl.ini, /etc/php/8.3/cli/conf.d/20-zip.ini, /etc/php/8.3/cli/conf.d/25-memcached.ini 200 | /etc/php/8.3/cli/php.ini 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 279 | 280 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 290 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.phpunit.cache/test-results: -------------------------------------------------------------------------------- 1 | {"version":1,"defects":{"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_grayscle_filter":7,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_rotate_filter":8},"times":{"Riclep\\Storyblok\\Tests\\BlockTest::can_extract_content":0.079,"Riclep\\Storyblok\\Tests\\BlockTest::can_extract_meta":0.004,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_uuid_from_meta":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_rich_text_field":0.006,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_asset_link_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_email_link_field":0.004,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_story_link_field":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_url_link_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_link_field":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_asset_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_image_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_table_field":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_read_default_value":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_custom_blocks_using_block_field_name":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_identify_custom_blocks_using_block_name":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_read_text_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_read_text_accessor_field":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_read_richtext_field":0.007,"Riclep\\Storyblok\\Tests\\BlockTest::can_render_block":0.016,"Riclep\\Storyblok\\Tests\\BlockTest::can_render_using":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_load_relations":0.025,"Riclep\\Storyblok\\Tests\\BlockTest::can_load_single_relation":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_load_single_relation_with_custom_class":0.004,"Riclep\\Storyblok\\Tests\\BlockTest::can_load_multiple_relations":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_load_multiple_relations_with_custom_class":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_cast_field_types":0.005,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_find_child_block":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_parent":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_page":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_component_path":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_ancestor_component":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_check_is_child_of_component":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_check_is_ancestor_of_component":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::nonexisting_field_returns_null_from_magic_getter":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::can_add_schema_org":0.001,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_views":0.006,"Riclep\\Storyblok\\Tests\\BlockTest::can_get_editorlink_in_edit_mode":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::editorlink_is_empty_when_not_in_edit_mode":0.004,"Riclep\\Storyblok\\Tests\\BlockTest::can_cast_block_to_string":0.001,"Riclep\\Storyblok\\Tests\\BlockTest::can_interate_over_fields":0.001,"Riclep\\Storyblok\\Tests\\BlockTest::can_call_fields_ready":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::will_throw_exception_with_view_not_found":0.003,"Riclep\\Storyblok\\Tests\\BlockTest::block_can_have_settings":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::block_can_have_setting":0.001,"Riclep\\Storyblok\\Tests\\BlockTest::block_can_get_setting":0.002,"Riclep\\Storyblok\\Tests\\BlockTest::block_settings_can_process_comma_separated_list":0.001,"Riclep\\Storyblok\\Tests\\BlockTest::will_return_mutli_self_fields":0.004,"Riclep\\Storyblok\\Tests\\FieldTest::can_read_text":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_text_area_to_html":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_markdown_to_html":0.032,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_rich_text_to_string":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_blocks_in_rich_text":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_blocks_in_rich_text_to_html":0.003,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_date_to_carbon":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_set_default_date_format":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_set_date_format_with_property":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::empty_dates_return_null":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_asset_as_url":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_image_asset_as_url":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_image_asset_as_url_with_custom_domain":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_image_asset_dimensions":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_original_image_url":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_resize_image":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_resize_image_with_legacy_driver":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_set_image_format":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_fit_image":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_custom_image_service_domain":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_custom_image_service_domain_with_legacy_driver":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_image_details":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::transparent_filled_images_have_correct_format":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_a_named_transformation":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_create_picture_elements":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_set_picture_element_transforms":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_create_transform_as_new_instance":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_create_picture_element_with_custom_domains":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_create_img_srcset":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_create_img_srcset_with_custom_domains":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_imgix_driver":0.006,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_asset_url_with_accessor":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_check_asset_has_file":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_check_multi_asset_has_files":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_array_access_on_multi_asset":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_array_access_on_multi_asset_with_custom_domain":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_make_assets_from_multi_asset":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_email_link_address":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_asset_link_url":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_url_link_url":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_story_link_url":0,"Riclep\\Storyblok\\Tests\\FieldTest::can_get_story_link_url_with_anchor":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_use_custom_field_class":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_transform_image_url_with_imgix":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_change_image_transformer_to_imgix":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_convert_table_fields_to_html":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_set_focal_point":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_add_with_content":0.001,"Riclep\\Storyblok\\Tests\\FolderTest::can_get_total_stories":0.002,"Riclep\\Storyblok\\Tests\\FolderTest::can_get_total_stories_for_this_page":0.002,"Riclep\\Storyblok\\Tests\\FolderTest::will_return_zero_stories_for_empty_folder":0,"Riclep\\Storyblok\\Tests\\FolderTest::can_get_folder_stories":0.001,"Riclep\\Storyblok\\Tests\\FolderTest::can_paginate_folder":0.003,"Riclep\\Storyblok\\Tests\\FolderTest::can_use_fluent_access":0.001,"Riclep\\Storyblok\\Tests\\FolderTest::can_set_settings":0,"Riclep\\Storyblok\\Tests\\FolderTest::can_add_settings_in_folder_constructor":0.001,"Riclep\\Storyblok\\Tests\\PageTest::can_get_default_page_class":0.004,"Riclep\\Storyblok\\Tests\\PageTest::can_get_publish_date":0.002,"Riclep\\Storyblok\\Tests\\PageTest::can_get_updated_date":0.004,"Riclep\\Storyblok\\Tests\\PageTest::can_get_slug":0.003,"Riclep\\Storyblok\\Tests\\PageTest::get_tags":0.002,"Riclep\\Storyblok\\Tests\\PageTest::get_tags_alphabetically":0.003,"Riclep\\Storyblok\\Tests\\PageTest::has_tag":0.002,"Riclep\\Storyblok\\Tests\\PageTest::get_content_block":0.002,"Riclep\\Storyblok\\Tests\\PageTest::can_get_bespoke_page_content_block_class":0.003,"Riclep\\Storyblok\\Tests\\PageTest::can_get_content_from_page_block":0.002,"Riclep\\Storyblok\\Tests\\PageTest::can_add_schema_org":0.002,"Riclep\\Storyblok\\Tests\\PageTest::can_read_schema_org":0.002,"Riclep\\Storyblok\\Tests\\PageTest::can_get_page_view":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_grayscle_filter":0.049,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_blur_filter":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_rotate_filter":0.002,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_many_filters_and_resize":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_grayscale_filter":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_brightness_filter":0.001,"Riclep\\Storyblok\\Tests\\FieldTest::can_apply_many_filters":0.001}} -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - single_class_element_per_statement -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | You can find the [changelog in the package docs](https://ls.sirric.co.uk) 4 | 5 | 6 | 7 | https://keepachangelog.com/en/1.0.0/ 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Richard Le Poidevin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Use Storyblok’s amazing headless CMS with Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/riclep/laravel-storyblok.svg?style=flat-square)](https://packagist.org/packages/riclep/laravel-storyblok) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/riclep/laravel-storyblok.svg?style=flat-square)](https://packagist.org/packages/riclep/laravel-storyblok) 5 | 6 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/riclep/laravel-storyblok/Tests) 7 | ![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/riclep/laravel-storyblok/php) 8 | 9 | [![Build](https://img.shields.io/scrutinizer/build/g/riclep/laravel-storyblok?style=flat-square)](https://scrutinizer-ci.com/g/riclep/laravel-storyblok) 10 | [![Quality Score](https://img.shields.io/scrutinizer/g/riclep/laravel-storyblok.svg?style=flat-square)](https://scrutinizer-ci.com/g/riclep/laravel-storyblok) 11 | 12 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4M2C42W6) 13 | [![Twitter](https://img.shields.io/twitter/follow/riclep.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=riclep) 14 | 15 | 16 | This package allows you to use fantastic [Storyblok headless CMS](https://www.storyblok.com/) with the amazing [Laravel PHP framework](https://laravel.com/). It’s designed to try and feel natural to Laravel developers and part of the ecosystem whilst also converting Storyblok’s API JSON responses into something powerful with minimal effort. 17 | 18 | ### Key Features 19 | 20 | - Pages from Storyblok mapped to PHP Pages classes giving access to the nested content (Blocks) and meta data for SEO, OpenGraph and more. 21 | - Quickly and easily resolve relations and inverse relations between content. 22 | - Each Storyblok component is automatically transformed into a PHP class using a simple naming convention - just match your class and component names. 23 | - Fields in your components are converted to a Field PHP class allowing you to manipulate their data. The package automatically detects common types like richtext fields, assets and markdown. Easily cast fields to classes. 24 | - Asset fields are converted to Assets and Image classes allowing you to manipulate them as required. Images can be easily transformed using Storyblok’s Asset CDN or external services like Imgix. 25 | - Blocks and fields know where they sit in relation to their ancestors and [CSS classes](https://github.com/RicLeP/laravel-storyblok-css) can be created to help your styling. 26 | - The structure of the JSON data is preserved but super powered making it simple to loop over in your views. 27 | - It’s simple to link to the Storyblok visual composer by including one view and printing a string in a Block’s Blade template. 28 | - Request ‘Folders’ of content such as a list of articles or a team of people complete with support for pagination. 29 | - Richer Typography using a [supporting package](https://github.com/RicLeP/laravel-storyblok-typography) utilising PHP Typography. 30 | 31 | 32 | ## Documentation 33 | 34 | [Read the full docs](https://ls.sirric.co.uk/docs) 35 | 36 | [Contribute to the docs](https://github.com/RicLeP/laravel-storyblok-docs/) 37 | 38 | ## Other Packages 39 | 40 | ### Laravel Storyblok Embed 41 | 42 | Embed all types of media in your Storyblok site using only their URL. [Package](https://github.com/RicLeP/laravel-storyblok-embed) [Docs](https://ls.sirric.co.uk/docs/2.19/embedding-media) 43 | 44 | ### Laravel Storyblok Typography 45 | 46 | Improve your content’s typography. [Package](https://github.com/RicLeP/laravel-storyblok-typography) [Docs](https://ls.sirric.co.uk/docs/2.19/typography) 47 | 48 | ### Laravel Storyblok Layout & CSS 49 | 50 | Helpers for layout, block positioning and CSS class name generation. [Package](https://github.com/RicLeP/laravel-storyblok-css) [Docs](https://ls.sirric.co.uk/docs/2.19/css-classes) 51 | 52 | ### Laravel Storyblok Form builder (BETA) 53 | 54 | Build forms with Storyblok complete with Laravel’s validation. [Package](https://github.com/RicLeP/laravel-storyblok-forms) [Docs](https://ls.sirric.co.uk/docs/2.19/laravel-storyblok-forms) 55 | 56 | ### Laravel Storyblok CLI 57 | 58 | Useful Artisan commands to help manage your content? Check out my [Laravel Storyblok CLI package](https://github.com/RicLeP/laravel-storyblok-cli) 59 | 60 | ### Testing 61 | 62 | The tests are mostly up-to-date and cover the majority of the code. A few areas that would require hitting the Storyblok API are not tested. If you have experience mocking API please feel free to contribute tests. 63 | 64 | ### Changelog 65 | 66 | [See it here](CHANGELOG.md) 67 | 68 | ## Contributing 69 | 70 | Please feel free to help expand and improve this project. 71 | 72 | ### Security 73 | 74 | If you discover any security related issues, please email ric@sirric.co.uk instead of using the issue tracker. 75 | 76 | ## Credits 77 | 78 | ![img](https://ls.sirric.co.uk/img/storyblok-ambassador-asset-vert-color.svg) 79 | 80 | - Ric Le Poidevin [GitHub](https://github.com/riclep) / [Twitter](https://twitter.com/riclep) 81 | - [The contributors](https://github.com/RicLeP/laravel-storyblok/graphs/contributors) 😍 82 | - [Storyblok](https://www.storyblok.com/) 😻 83 | - [Laravel](https://laravel.com/) 🥰 84 | - [Built and developed at U&US](https://uandus.co.uk) 💕 85 | 86 | ## License 87 | 88 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 89 | 90 | ## Laravel Package Boilerplate 91 | 92 | This package was generated using the [Laravel Package Boilerplate](https://laravelpackageboilerplate.com). 93 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "riclep/laravel-storyblok", 3 | "description": "A Laravel wrapper around the Storyblok API to provide a familiar experience for Laravel devs", 4 | "keywords": [ 5 | "storyblok", 6 | "laravel", 7 | "cms", 8 | "content management", 9 | "headless cms" 10 | ], 11 | "homepage": "https://github.com/RicLeP/laravel-storyblok/", 12 | "license": "MIT", 13 | "type": "library", 14 | "authors": [ 15 | { 16 | "name": "Richard Le Poidevin", 17 | "email": "ric@sirric.co.uk", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.2|^8.3|^8.4", 23 | "ext-json": "*", 24 | "barryvdh/reflection-docblock": "^2.0", 25 | "embed/embed": "^3.4|^4", 26 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0", 27 | "imgix/imgix-php": "^3.3|^4.0", 28 | "ivopetkov/html5-dom-document-php": "2.*", 29 | "league/commonmark": "^2.0", 30 | "riclep/storyblok-php-client": "^2.7", 31 | "spatie/laravel-ignition": "^2.8", 32 | "spatie/schema-org": "^3.3", 33 | "storyblok/richtext-resolver": "^2.2" 34 | }, 35 | "require-dev": { 36 | "mockery/mockery": "^1.2", 37 | "orchestra/testbench": "^8.0|^9.0|^10.0", 38 | "phpunit/phpunit": "^10|^11.5.3" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Riclep\\Storyblok\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Riclep\\Storyblok\\Tests\\": "tests" 48 | } 49 | }, 50 | "scripts": { 51 | "test": "vendor/bin/phpunit", 52 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 53 | }, 54 | "config": { 55 | "sort-packages": true 56 | }, 57 | "extra": { 58 | "laravel": { 59 | "providers": [ 60 | "Riclep\\Storyblok\\StoryblokServiceProvider" 61 | ], 62 | "aliases": { 63 | "Storyblok": "Riclep\\Storyblok\\StoryblokFacade" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /config/storyblok.php: -------------------------------------------------------------------------------- 1 | env('STORYBLOK_PREVIEW_API_KEY', ''), 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Storyblok Public API key 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Enter your Storyblok Public API key to communicate with their API. 23 | | This key is used when your website is live and debug is off. 24 | | 25 | */ 26 | 'api_public_key' => env('STORYBLOK_PUBLIC_API_KEY', ''), 27 | 28 | /* 29 | |-------------------------------------------------------------------------- 30 | | Specify which Storyblok API region to use 31 | |-------------------------------------------------------------------------- 32 | | 33 | | Defaults to null which should be the original EU region 34 | | 35 | */ 36 | 'api_region' => null, 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Specify which Content Delivery API region-specific base URL to use 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Defaults to api.storyblok.com which should be the original EU region 44 | | 45 | */ 46 | 'delivery_api_base_url' => 'api.storyblok.com', 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Specify which Management API region-specific base URL to use 51 | |-------------------------------------------------------------------------- 52 | | 53 | | Defaults to mapi.storyblok.com which should be the original EU region 54 | | 55 | */ 56 | 'management_api_base_url' => 'mapi.storyblok.com', 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Use SSL when calling the Storyblok API 61 | |-------------------------------------------------------------------------- 62 | | 63 | | Request content from the secure https address or just http 64 | | 65 | */ 66 | 'use_ssl' => true, 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Storyblok draft mode 71 | |-------------------------------------------------------------------------- 72 | | 73 | | Request draft data 74 | | 75 | */ 76 | 'draft' => env('STORYBLOK_DRAFT', false), 77 | 78 | /* 79 | |-------------------------------------------------------------------------- 80 | | Storyblok Personal access token 81 | |-------------------------------------------------------------------------- 82 | | 83 | | Enter your Storyblok Personal access token to access their management API 84 | | 85 | */ 86 | 'oauth_token' => env('STORYBLOK_OAUTH_TOKEN', null), 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Storyblok Space ID 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Enter your Storyblok space ID for use with the management API 94 | | 95 | */ 96 | 'space_id' => env('STORYBLOK_SPACE_ID', null), 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Storyblok debug 101 | |-------------------------------------------------------------------------- 102 | | 103 | | Enable debug mode for Storyblok. This prints useful data to the screen. 104 | | 105 | */ 106 | 'debug' => env('STORYBLOK_DEBUG'), 107 | 108 | /* 109 | |-------------------------------------------------------------------------- 110 | | Enable caching 111 | |-------------------------------------------------------------------------- 112 | | 113 | | Enable caching the Storyblok API response. 114 | | 115 | */ 116 | 'cache' => env('STORYBLOK_CACHE', true), 117 | 118 | /* 119 | |-------------------------------------------------------------------------- 120 | | Cache duration 121 | |-------------------------------------------------------------------------- 122 | | 123 | | Specifies how many minutes to cache responses from Storkyblok for. 124 | | 125 | */ 126 | 'cache_duration' => env('STORYBLOK_DURATION',60), 127 | 128 | /* 129 | |-------------------------------------------------------------------------- 130 | | Enable Storyblok client caching 131 | |-------------------------------------------------------------------------- 132 | | 133 | | Set the cache driver for the Storyblok client. 134 | | 135 | */ 136 | 'sb_cache_driver' => env('STORYBLOK_SB_CACHE_DRIVER', 'file'), 137 | 138 | /* 139 | |-------------------------------------------------------------------------- 140 | | Set Storyblok client cache path 141 | |-------------------------------------------------------------------------- 142 | | 143 | | Set the cache path for the Storyblok client (optional) 144 | | 145 | */ 146 | 'sb_cache_path' => env('STORYBLOK_SB_CACHE_PATH', storage_path('framework/cache/data')), 147 | 148 | /* 149 | |-------------------------------------------------------------------------- 150 | | Set Storyblok client cache duration 151 | |-------------------------------------------------------------------------- 152 | | 153 | | Set the cache duration for the Storyblok client 154 | | 155 | */ 156 | 'sb_cache_lifetime' => env('STORYBLOK_SB_CACHE_LIFETIME', 3600), 157 | 158 | /* 159 | |-------------------------------------------------------------------------- 160 | | Component class namespaces 161 | |-------------------------------------------------------------------------- 162 | | 163 | | A list of name spaces to search when finding Blocks and Fields. They are 164 | | listed in the order searched and loaded. 165 | | 166 | */ 167 | 'component_class_namespace' => ['App\Storyblok\\'], 168 | 169 | /* 170 | |-------------------------------------------------------------------------- 171 | | View folder path 172 | |-------------------------------------------------------------------------- 173 | | 174 | | Sets the folder where views will be stored under /resources/views 175 | | 176 | */ 177 | 'view_path' => 'storyblok.', 178 | 179 | /* 180 | |-------------------------------------------------------------------------- 181 | | Webhook secret 182 | |-------------------------------------------------------------------------- 183 | | 184 | | Webhook from space settings 185 | | https://www.storyblok.com/docs/guide/in-depth/webhooks 186 | | 187 | */ 188 | 'webhook_secret' => env('STORYBLOK_WEBHOOK_SECRET'), 189 | 190 | /* 191 | |-------------------------------------------------------------------------- 192 | | Asset domain 193 | |-------------------------------------------------------------------------- 194 | | 195 | | Storyblok asset URL, can be customized if proxy is setup 196 | | https://www.storyblok.com/docs/custom-assets-domain 197 | | 198 | */ 199 | 'asset_domain' => env('STORYBLOK_ASSET_DOMAIN', 'a.storyblok.com'), 200 | 201 | /* 202 | |-------------------------------------------------------------------------- 203 | | Image service domain 204 | |-------------------------------------------------------------------------- 205 | | 206 | | Can be customized to proxy image service requests over a custom domain 207 | | 208 | */ 209 | 'image_service_domain' => env('STORYBLOK_IMAGE_SERVICE_DOMAIN', 'a.storyblok.com'), 210 | 211 | /* 212 | |-------------------------------------------------------------------------- 213 | | Image transformation driver 214 | |-------------------------------------------------------------------------- 215 | | 216 | | The class used for transforming images Fields / image URLs 217 | | 218 | */ 219 | 'image_transformer' => \Riclep\Storyblok\Support\ImageTransformers\Storyblok::class, 220 | 221 | /* 222 | |-------------------------------------------------------------------------- 223 | | Raster image extensions 224 | |-------------------------------------------------------------------------- 225 | | 226 | | Used to determine if the image field content is a raster image, do not 227 | | include SVGs or other vector formats here. This is used to determine 228 | | if the image should be transformed or not. 229 | | 230 | */ 231 | 'image_extensions' => ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.jfif', '.heic', '.avif'], 232 | 233 | /* 234 | |-------------------------------------------------------------------------- 235 | | Resolve story links in content 236 | |-------------------------------------------------------------------------- 237 | | 238 | | Resolve links to stories when using link and multi link fields, valid 239 | | settings are 'url', 'story' or false 240 | | 241 | */ 242 | 'resolve_links' => false, 243 | 244 | /* 245 | |-------------------------------------------------------------------------- 246 | | Enable editor live preview 247 | |-------------------------------------------------------------------------- 248 | | 249 | | This turns on live preview of changes in the editor if correctly set up 250 | | 251 | */ 252 | 'live_preview' => true, 253 | 254 | /* 255 | |-------------------------------------------------------------------------- 256 | | Editor live preview selector 257 | |-------------------------------------------------------------------------- 258 | | 259 | | Class or ID selector for the HTML element wrapping your live preview content 260 | | 261 | */ 262 | 'live_element' => '.storyblok-live', 263 | 264 | /* 265 | |-------------------------------------------------------------------------- 266 | | Allow live preview links 267 | |-------------------------------------------------------------------------- 268 | | 269 | | Links in the visual editor will be clickable and navigate to the page 270 | | with the Storyblok editing query string appended 271 | | 272 | */ 273 | 'live_links' => true, 274 | 275 | /* 276 | |-------------------------------------------------------------------------- 277 | | Name of the field to be used for settings 278 | |-------------------------------------------------------------------------- 279 | | 280 | | Set the field name to be used to store the generic settings components 281 | | 282 | */ 283 | 'settings_field' => 'settings', 284 | 285 | /* 286 | |-------------------------------------------------------------------------- 287 | | Default date format 288 | |-------------------------------------------------------------------------- 289 | | 290 | | Use any valid PHP date format, applied when casting DateTimes to string 291 | | 292 | */ 293 | 'date_format' => 'H:i:s j F Y', 294 | 295 | /* 296 | |-------------------------------------------------------------------------- 297 | | How deep to go when creating page schema.org data 298 | |-------------------------------------------------------------------------- 299 | | 300 | | As you may be nesting many blocks and linking to other stories, this 301 | | you may want to limit the depth of the schema.org data returned 302 | | 303 | */ 304 | 'schema_org_depth' => 5, 305 | 306 | /* 307 | |-------------------------------------------------------------------------- 308 | | URL Denylist 309 | |-------------------------------------------------------------------------- 310 | | 311 | | URLs that should not be processed by Storyblok. Can be exact matches or 312 | | regular expressions (must be wrapped in forward slashes). This is only 313 | | used when using the packages built-in controller. 314 | | 315 | */ 316 | 'denylist' => [ 317 | '/^\.well-known\/.*$/', // Blocks any URL starting with ".well-known/" 318 | // 'another-bad-slug', 319 | // '/^admin\/.*$/', // Blocks any URL starting with "admin/" 320 | // '/^user\/\d+\/edit$/', // Blocks URLs like "user/123/edit" 321 | // '/\.(php|sql|exe)$/', // Blocks URLs ending with .php, .sql, or .exe 322 | ], 323 | ]; 324 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/Block.php: -------------------------------------------------------------------------------- 1 | _parent = $parent; 75 | $this->preprocess($content); 76 | 77 | if ($parent) { 78 | $this->_componentPath = array_merge($parent->_componentPath, [Str::lower($this->meta()['component'])]); 79 | } 80 | 81 | $this->processFields(); 82 | 83 | if (method_exists($this, 'fieldsReady')) { 84 | $this->fieldsReady(); 85 | } 86 | 87 | // run automatic traits - methods matching initTraitClassName() 88 | foreach (class_uses_recursive($this) as $trait) { 89 | if (method_exists($this, $method = 'init' . class_basename($trait))) { 90 | $this->{$method}(); 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * Returns the every field of content 97 | * 98 | * @return Collection 99 | */ 100 | public function content(): Collection 101 | { 102 | $fields = $this->_fields; 103 | 104 | foreach ($fields as $key => $field) { 105 | if ($field === null) { 106 | $fields[$key] = $this->_defaults[$key] ?? null; 107 | } 108 | } 109 | 110 | return $fields; 111 | } 112 | 113 | /** 114 | * Checks if this Block’s fields contain the specified key 115 | * 116 | * @param $key 117 | * @return bool 118 | */ 119 | public function has($key): bool 120 | { 121 | return $this->_fields->has($key); 122 | } 123 | 124 | /** 125 | * Checks if a ‘Blocks’ fieldtype contains a specific block component 126 | * Pass the $field that contains the blocks and the component type to search for 127 | * 128 | * @param $field 129 | * @param $component 130 | * @return boolean 131 | */ 132 | public function hasChildBlock($field, $component): bool 133 | { 134 | return $this->content()[$field]->contains(function($item) use ($component) { 135 | return $item->meta('component') === $component; 136 | }); 137 | } 138 | 139 | /** 140 | * Returns the parent Block 141 | * 142 | * @return Block 143 | */ 144 | public function parent(): Block|Page|null 145 | { 146 | return $this->_parent; 147 | } 148 | 149 | /** 150 | * Returns the page this Block belongs to 151 | * 152 | * @return Block 153 | */ 154 | public function page(): Block|Page|null 155 | { 156 | if ($this->parent() instanceof Page) { 157 | return $this->parent(); 158 | } 159 | 160 | return $this->parent()->page(); 161 | } 162 | 163 | /** 164 | * Returns the first matching view, passing it the Block and optional data 165 | * 166 | * @param array $with 167 | * @return View 168 | * @throws UnableToRenderException 169 | */ 170 | public function render(array $with = []): View 171 | { 172 | return $this->renderUsing($this->views(), $with); 173 | } 174 | 175 | /** 176 | * Pass an array of views rendering the first match, passing it the Block and optional data 177 | * 178 | * @param array|string $views 179 | * @param array $with 180 | * @return View 181 | * @throws UnableToRenderException 182 | */ 183 | public function renderUsing(array|string $views, array $with = []): View 184 | { 185 | try { 186 | return view()->first(Arr::wrap($views), array_merge(['block' => $this], $with)); 187 | } catch (\Exception $exception) { 188 | throw new UnableToRenderException('None of the views in the given array exist.', $this); 189 | } 190 | } 191 | 192 | /** 193 | * Returns an array of views for the Block based on page’s content type and 194 | * block’s $componentPath. First are page specific views starting with the 195 | * page’s content type followed by those using the block’s component path 196 | * 197 | * Example: 198 | * 199 | * $componentPath = ['page', 'product', 'heroes', 'hero']; 200 | * 201 | * [ 202 | * "storyblok.pages.product.blocks.heroes.hero" 203 | * "storyblok.pages.product.blocks.hero" 204 | * "storyblok.blocks.heroes.hero" 205 | * "storyblok.blocks.product.hero" 206 | * "storyblok.blocks.hero" 207 | * ] 208 | * 209 | * It is recommended to start with the most generic view and create more 210 | * specific ones as and when required 211 | * 212 | * @return array 213 | */ 214 | public function views(): array 215 | { 216 | $componentPath = $this->_componentPath; 217 | array_pop($componentPath); 218 | 219 | $views = array_filter(array_map(function($path) { 220 | if ($path !== 'page') { 221 | return config('storyblok.view_path') . 'blocks.' . $path . '.' . $this->component(); 222 | } 223 | 224 | return null; 225 | }, $componentPath)); 226 | 227 | $views = array_reverse($views); 228 | 229 | $views[] = config('storyblok.view_path') . 'blocks.' . $this->component(); 230 | 231 | $themeViews = $views; 232 | 233 | $themeViews = array_filter(array_map(function($view) { 234 | $theme = $this->page()?->block()->component(); 235 | 236 | if (!strpos($view, $theme)) { 237 | return str_replace('blocks.', 'pages.' . $theme . '.blocks.', $view); 238 | } 239 | 240 | return null; 241 | }, $themeViews)); 242 | 243 | return array_merge($themeViews, $views); 244 | } 245 | 246 | /** 247 | * Returns a component X generations previous 248 | * 249 | * @param $generation int 250 | * @return mixed 251 | */ 252 | public function ancestorComponentName(int $generation): mixed 253 | { 254 | return $this->_componentPath[count($this->_componentPath) - ($generation + 1)]; 255 | } 256 | 257 | /** 258 | * Checks if the current component is a child of another 259 | * 260 | * @param $parent string 261 | * @return bool 262 | */ 263 | public function isChildOf(string $parent): bool 264 | { 265 | return $this->_componentPath[count($this->_componentPath) - 2] === $parent; 266 | } 267 | 268 | /** 269 | * Checks if the component is an ancestor of another 270 | * 271 | * @param $parent string 272 | * @return bool 273 | */ 274 | public function isAncestorOf(string $parent): bool 275 | { 276 | return in_array($parent, $this->parent()->_componentPath, true); 277 | } 278 | 279 | /** 280 | * Returns the current Block’s component name from Storyblok 281 | * 282 | * @return string 283 | */ 284 | public function component(): string 285 | { 286 | return $this->_meta['component']; 287 | } 288 | 289 | 290 | /** 291 | * Returns the HTML comment required for making this Block clickable in 292 | * Storyblok’s visual editor. Don’t forget to set comments to true in 293 | * your Vue.js app configuration. 294 | * 295 | * @param $attribute bool return a data-* attribute or comment for editor link 296 | * @return string 297 | */ 298 | public function editorLink($attribute = false): string 299 | { 300 | if (array_key_exists('_editable', $this->_meta) && config('storyblok.edit_mode')) { 301 | if ($attribute) { 302 | return 'data-blok-c=\'' . str_replace([''], '', $this->_meta['_editable']) . '\''; 303 | } 304 | 305 | return $this->_meta['_editable']; 306 | } 307 | 308 | return ''; 309 | } 310 | 311 | 312 | /** 313 | * Magic accessor to pull content from the _fields collection. Works just like 314 | * Laravel’s model accessors. Matches public methods with the follow naming 315 | * convention getSomeFieldAttribute() - called via $block->some_field 316 | * 317 | * @param $key 318 | * @return null|string 319 | */ 320 | public function __get($key) { 321 | $accessor = 'get' . Str::studly($key) . 'Attribute'; 322 | 323 | if (method_exists($this, $accessor)) { 324 | return $this->$accessor(); 325 | } 326 | 327 | if (array_key_exists($key, $this->_defaults) && $this->has($key) && !$this->_fields[$key]) { 328 | return $this->_defaults[$key]; 329 | } 330 | 331 | if ($this->has($key)) { 332 | return $this->_fields[$key]; 333 | } 334 | 335 | return null; 336 | } 337 | 338 | /** 339 | * Casts the Block as a string - json serializes the $_fields Collection 340 | * 341 | * @return string 342 | */ 343 | public function __toString(): string 344 | { 345 | return (string) $this->jsonSerialize(); 346 | } 347 | 348 | /** 349 | * Loops over every field to get the ball rolling 350 | */ 351 | private function processFields(): void 352 | { 353 | $this->_fields->transform(fn($field, $key) => $this->getFieldType($field, $key)); 354 | } 355 | 356 | /** 357 | * Converts fields into Field Classes based on various properties of their content 358 | * 359 | * @param $field 360 | * @param $key 361 | * @return array|Collection|mixed|Asset|Image|MultiAsset|RichText|Table 362 | * @throws \Storyblok\ApiException 363 | */ 364 | private function getFieldType($field, $key): mixed 365 | { 366 | return (new FieldFactory())->build($this, $field, $key); 367 | } 368 | 369 | /** 370 | * Storyblok returns fields and other meta content at the same level so 371 | * let’s do a little tidying up first 372 | * 373 | * @param $content 374 | */ 375 | private function preprocess($content): void 376 | { 377 | // run pre-process traits - methods matching preprocessTraitClassName() 378 | foreach (class_uses_recursive($this) as $trait) { 379 | if (method_exists($this, $method = 'preprocess' . class_basename($trait))) { 380 | $content = $this->{$method}($content); 381 | } 382 | } 383 | 384 | $fields = ['_editable', '_uid', 'component']; 385 | 386 | $this->_fields = collect(array_diff_key($content, array_flip($fields))); 387 | 388 | // remove non-content keys 389 | $this->_meta = array_intersect_key($content, array_flip($fields)); 390 | } 391 | 392 | /** 393 | * Casting Block to JSON 394 | * 395 | * @return Collection|mixed 396 | */ 397 | public function jsonSerialize(): mixed 398 | { 399 | return $this->content(); 400 | } 401 | 402 | /** 403 | * Let’s up loop over the fields in Blade without needing to 404 | * delve deep into the content collection 405 | * 406 | * @return \Traversable 407 | */ 408 | public function getIterator(): \Traversable { 409 | return $this->_fields; 410 | } 411 | 412 | /** 413 | * @param RequestStory $requestStory 414 | * @param $relation 415 | * @param $className 416 | * @return mixed|null 417 | */ 418 | public function getRelation(RequestStory $requestStory, $relation, $className = null): mixed 419 | { 420 | try { 421 | $response = $requestStory->get($relation); 422 | 423 | if (!$className) { 424 | $class = $this->getChildClassName('Block', $response['content']['component']); 425 | } else { 426 | $class = $className; 427 | } 428 | 429 | $relationClass = new $class($response['content'], $this); 430 | 431 | $relationClass->addMeta([ 432 | 'name' => $response['name'], 433 | 'published_at' => $response['published_at'], 434 | 'full_slug' => $response['full_slug'], 435 | 'page_uuid' => $relation, 436 | ]); 437 | 438 | return $relationClass; 439 | } catch (ApiException $e) { 440 | return null; 441 | } 442 | } 443 | 444 | /** 445 | * Returns an inverse relationship to the current Block. For example if Service has a Multi-Option field 446 | * relationship to People, on People you can request all the Services it has been related to 447 | * 448 | * @param string $foreignRelationshipField 449 | * @param string $foreignRelationshipType 450 | * @param array|null $components 451 | * @param array|null $options 452 | * @param array|null $resolveRelations 453 | * @return array 454 | */ 455 | public function inverseRelation(string $foreignRelationshipField, string $foreignRelationshipType = 'multi', array $components = null, array $options = null, array $resolveRelations = null): array 456 | { 457 | $storyblokClient = resolve('Storyblok\Client'); 458 | 459 | $type = 'any_in_array'; 460 | 461 | if ($foreignRelationshipType === 'single') { 462 | $type = 'in'; 463 | } 464 | 465 | $query = [ 466 | 'filter_query' => [ 467 | $foreignRelationshipField => [$type => $this->meta('page_uuid') ?? $this->page()->uuid()] 468 | ], 469 | ]; 470 | 471 | if ($components) { 472 | $query = array_merge_recursive($query, [ 473 | 'filter_query' => [ 474 | 'component' => ['in' => $components], 475 | ] 476 | ]); 477 | } 478 | 479 | if ($options) { 480 | $query = array_merge_recursive($query, $options); 481 | } 482 | 483 | if ($resolveRelations) { 484 | $storyblokClient->resolveRelations(implode(',', $resolveRelations)); 485 | } 486 | 487 | if (request()->has('_storyblok') || !config('storyblok.cache')) { 488 | $storyblokClient->getStories($query); 489 | 490 | $response = [ 491 | 'headers' => $storyblokClient->getHeaders(), 492 | 'stories' => $storyblokClient->getBody()['stories'], 493 | ]; 494 | } else { 495 | $apiHash = md5(config('storyblok.api_public_key') ?? config('storyblok.api_preview_key')); // unique id for multitenancy applications 496 | 497 | $uniqueTag = md5(serialize($query)); 498 | 499 | $response = Cache::store(config('storyblok.sb_cache_driver'))->remember($foreignRelationshipField . '-' . $foreignRelationshipType . '-' . $apiHash . '-' . $uniqueTag, config('storyblok.cache_duration') * 60, function () use ($storyblokClient, $query) { 500 | $storyblokClient->getStories($query); 501 | 502 | return [ 503 | 'headers' => $storyblokClient->getHeaders(), 504 | 'stories' => $storyblokClient->getBody()['stories'], 505 | ]; 506 | }); 507 | } 508 | 509 | return [ 510 | 'headers' => $response['headers'], 511 | 'stories' => collect($response['stories'])->transform(function ($story) { 512 | $blockClass = $this->getChildClassName('Page', $story['content']['component']); 513 | 514 | return new $blockClass($story); 515 | }), 516 | ]; 517 | } 518 | 519 | /** 520 | * Returns the casts on this Block 521 | * 522 | * @return array 523 | */ 524 | public function getCasts(): array 525 | { 526 | return $this->_casts; 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /src/Console/BlockMakeCommand.php: -------------------------------------------------------------------------------- 1 | doOtherOperations(); 32 | 33 | if ($this->option('blade')) { 34 | $this->createBlade(); 35 | } 36 | 37 | if ($this->option('scss')) { 38 | $this->createScss(); 39 | } 40 | } 41 | 42 | protected function doOtherOperations(): void 43 | { 44 | $class = $this->qualifyClass($this->getNameInput()); 45 | $path = $this->getPath($class); 46 | $content = file_get_contents($path); 47 | 48 | file_put_contents($path, $content); 49 | 50 | $this->getComponentFields($this->getNameInput()); 51 | } 52 | 53 | protected function createBlade(): void 54 | { 55 | $name = Str::kebab($this->getNameInput()); 56 | $path = $this->viewPath( str_replace( '.' , '/' , config('storyblok.view_path') . 'blocks.' ) ); 57 | $stub = file_exists(resource_path('stubs/storyblok/block.blade.stub')) ? resource_path('stubs/storyblok/block.blade.stub') : __DIR__ . '/stubs/block.blade.stub'; 58 | 59 | if (!file_exists($path . $name . '.blade.php')) { 60 | $content = file_get_contents($stub); 61 | 62 | $find = ['DummyClass', 'DummyCssClass']; 63 | $replace = [$this->getNameInput(), $name]; 64 | 65 | $content = str_replace($find, $replace, $content); 66 | 67 | if (!$this->files->exists($path)) { 68 | $this->files->makeDirectory($path); 69 | } 70 | 71 | file_put_contents($path . $name . '.blade.php', $content); 72 | $this->info('Blade created successfully.'); 73 | } else { 74 | $this->error('Blade already exists!'); 75 | } 76 | } 77 | 78 | protected function createScss(): void 79 | { 80 | $name = Str::kebab($this->getNameInput()); 81 | $path = resource_path('sass/blocks/'); 82 | $stub = file_exists(resource_path('stubs/storyblok/block.scss.stub')) ? resource_path('stubs/storyblok/block.scss.stub') : __DIR__ . '/stubs/block.scss.stub'; 83 | 84 | if (!file_exists($path . '_' . $name . '.scss')) { 85 | $content = file_get_contents($stub); 86 | $content = str_replace('DummyClass', $name, $content); 87 | 88 | if (!$this->files->exists($path)) { 89 | $this->files->makeDirectory($path); 90 | } 91 | 92 | file_put_contents($path . '_' . $name . '.scss', $content); 93 | $this->info('SCSS created successfully.'); 94 | } else { 95 | $this->error('SCSS already exists!'); 96 | } 97 | 98 | $appContent = file_get_contents(resource_path('sass/app.scss')); 99 | 100 | preg_match_all("/^@import \"blocks(.*)\r$/m", $appContent, $matches); 101 | 102 | $files = $matches[0]; 103 | $files[] = '@import "blocks/' . $name . '";'; 104 | asort($files); 105 | 106 | $allFilesString = implode("\n", $files); 107 | 108 | $allButFirstFile = $files; 109 | 110 | array_shift($allButFirstFile); 111 | 112 | $appContent = str_replace($allButFirstFile, '', $appContent); 113 | 114 | // clean up all the empty lines left over from the replacement 115 | $appContent = preg_replace("/\n\n+/s","\n",$appContent); 116 | 117 | $appContent = str_replace($files[0], $allFilesString, $appContent); 118 | 119 | file_put_contents(resource_path('sass/app.scss'), $appContent); 120 | } 121 | 122 | protected function getComponentFields($name): void 123 | { 124 | if (config('storyblok.oauth_token')) { 125 | $this->call('ls:sync', [ 126 | 'component' => Str::studly($name), 127 | ]); 128 | } 129 | } 130 | 131 | /** 132 | * Get the console command options. 133 | * 134 | * @return array 135 | */ 136 | protected function getOptions(): array 137 | { 138 | return [ 139 | ['blade', 'b', InputOption::VALUE_NONE, 'Create stub Blade view'], 140 | ['scss', 's', InputOption::VALUE_NONE, 'Create stub SCSS view'], 141 | ]; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Console/BlockSyncCommand.php: -------------------------------------------------------------------------------- 1 | files = $files; 44 | } 45 | 46 | /** 47 | * Execute the console command. 48 | * 49 | * @return void 50 | */ 51 | public function handle(): void 52 | { 53 | $components = []; 54 | if ($this->argument('component')) { 55 | $components = [ 56 | [ 57 | 'class' => $this->argument('component'), 58 | 'component' => Str::of($this->argument('component'))->kebab(), 59 | ] 60 | ]; 61 | } else { 62 | // get all components 63 | if ($this->confirm("Do you wish to update all components in {$this->option('path')}?")) { 64 | $components = $this->getAllComponents(); 65 | } 66 | } 67 | 68 | foreach ($components as $component) { 69 | $this->info("Updating {$component['component']}"); 70 | $this->updateComponent($component); 71 | } 72 | } 73 | 74 | /** 75 | * @return \Illuminate\Support\Collection 76 | */ 77 | protected function getAllComponents(): \Illuminate\Support\Collection 78 | { 79 | $path = $this->option('path'); 80 | 81 | $files = collect($this->files->allFiles($path)); 82 | 83 | return $files->map(fn($file) => [ 84 | 'class' => Str::of($file->getFilename())->replace('.php', ''), 85 | 'component' => Str::of($file->getFilename())->replace('.php', '')->kebab(), 86 | ]); 87 | } 88 | 89 | private function updateComponent($component): void 90 | { 91 | $rootNamespace = "App\Storyblok\Blocks"; 92 | $class = "{$rootNamespace}\\{$component['class']}"; 93 | 94 | $reflection = new \ReflectionClass($class); 95 | $namespace = $reflection->getNamespaceName(); 96 | $path = $this->option('path'); 97 | $originalDoc = $reflection->getDocComment(); 98 | 99 | $filepath = $path.$component['class'].'.php'; 100 | 101 | $phpdoc = new DocBlock($reflection, new Context($namespace)); 102 | 103 | $tags = $phpdoc->getTagsByName('property-read'); 104 | 105 | // Clear old attributes 106 | foreach ($tags as $tag) { 107 | $phpdoc->deleteTag($tag); 108 | } 109 | 110 | // Add new attributes 111 | $fields = $this->getComponentFields($component['component']); 112 | foreach ($fields as $field => $type) { 113 | $tagLine = trim("@property-read {$type} {$field}"); 114 | $tag = Tag::createInstance($tagLine, $phpdoc); 115 | 116 | $phpdoc->appendTag($tag); 117 | } 118 | 119 | // Add default description if none exists 120 | if ( ! $phpdoc->getText()) { 121 | $phpdoc->setText("Class representation for Storyblok {$component['component']} component."); 122 | } 123 | 124 | // Write to file 125 | if ($this->files->exists($filepath)) { 126 | $serializer = new Serializer(); 127 | $updatedBlock = $serializer->getDocComment($phpdoc); 128 | 129 | $content = $this->files->get($filepath); 130 | 131 | $content = str_replace($originalDoc, $updatedBlock, $content); 132 | 133 | $this->files->replace($filepath, $content); 134 | $this->files->chmod($filepath, 0644); // replace() changes permissions 135 | 136 | $this->info('Component updated successfully.'); 137 | } else { 138 | $this->error('Component not yet created...'); 139 | } 140 | } 141 | 142 | protected function getComponentFields($name): array 143 | { 144 | if (config('storyblok.oauth_token')) { 145 | $managementClient = new \Storyblok\ManagementClient( 146 | apiKey:config('storyblok.oauth_token'), 147 | apiEndpoint: config('storyblok.management_api_base_url'), 148 | ssl: config('storyblok.use_ssl'), 149 | ); 150 | 151 | $components = collect($managementClient->get('spaces/'.config('storyblok.space_id').'/components')->getBody()['components']); 152 | 153 | $component = $components->firstWhere('name', $name); 154 | 155 | if( ! $component ){ 156 | $this->error("Storyblok component [{$name}] does not exist."); 157 | 158 | if ($this->confirm('Do you want to create it now?')) { 159 | $this->createStoryblokCompontent($name); 160 | } 161 | } 162 | 163 | $fields = []; 164 | foreach ($component['schema'] as $name => $data) { 165 | if ( ! $this->isIgnoredType($data['type'])) { 166 | $fields[$name] = $this->convertToPhpType($data['type']); 167 | } 168 | } 169 | 170 | return $fields; 171 | } 172 | 173 | $this->error("Please set your management token in the Storyblok config file"); 174 | return []; 175 | } 176 | 177 | /** 178 | * Create a new Storyblok component with given name 179 | * 180 | * @param $component_name 181 | * @throws ApiException 182 | */ 183 | protected function createStoryblokCompontent($component_name){ 184 | $managementClient = new \Storyblok\ManagementClient( 185 | apiKey:config('storyblok.oauth_token'), 186 | apiEndpoint: config('storyblok.management_api_base_url'), 187 | ssl: config('storyblok.use_ssl'), 188 | ); 189 | 190 | $payload = [ 191 | "component" => [ 192 | "name" => $component_name, 193 | "display_name" => str::of( str_replace('-', ' ' ,$component_name) )->ucfirst(), 194 | // "schema" => [], 195 | // "is_root" => false, 196 | // "is_nestable" => true 197 | ] 198 | ]; 199 | 200 | $component = $managementClient->post('spaces/'.config('storyblok.space_id').'/components/', $payload)->getBody(); 201 | 202 | $this->info("Storyblok component created"); 203 | 204 | return $component['component']; 205 | } 206 | 207 | /** 208 | * Convert Storyblok types to PHP native types for proper type-hinting 209 | * 210 | * @param $type 211 | * @return string 212 | */ 213 | protected function convertToPhpType($type): string 214 | { 215 | return match ($type) { 216 | "bloks" => "array", 217 | default => "string", 218 | }; 219 | } 220 | 221 | /** 222 | * There are certain Storyblok types that are not useful to model in our component classes. We can use this to 223 | * filter those types out. 224 | * 225 | * @param $type 226 | * @return bool 227 | */ 228 | protected function isIgnoredType($type): bool 229 | { 230 | $ignored = ['section']; 231 | 232 | return in_array($type, $ignored); 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /src/Console/FolderMakeCommand.php: -------------------------------------------------------------------------------- 1 | doOtherOperations(); 31 | } 32 | 33 | protected function doOtherOperations(): void 34 | { 35 | $class = $this->qualifyClass($this->getNameInput()); 36 | $path = $this->getPath($class); 37 | $content = file_get_contents($path); 38 | 39 | $content = str_replace('DummySlug', Str::kebab($this->getNameInput()), $content); 40 | 41 | file_put_contents($path, $content); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Console/PageMakeCommand.php: -------------------------------------------------------------------------------- 1 | doOtherOperations(); 31 | } 32 | 33 | protected function doOtherOperations(): void 34 | { 35 | $class = $this->qualifyClass($this->getNameInput()); 36 | $path = $this->getPath($class); 37 | $content = file_get_contents($path); 38 | 39 | $content = str_replace('DummySlug', Str::kebab($this->getNameInput()), $content); 40 | 41 | file_put_contents($path, $content); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Console/StubViewsCommand.php: -------------------------------------------------------------------------------- 1 | makeDirectories(); 48 | 49 | $client = new ManagementClient( 50 | apiKey:config('storyblok.oauth_token'), 51 | apiEndpoint: config('storyblok.management_api_base_url'), 52 | ssl: config('storyblok.use_ssl'), 53 | ); 54 | 55 | $components = collect($client->get('spaces/' . config('storyblok.space_id') . '/components/')->getBody()['components']); 56 | 57 | $components->each(function ($component) { 58 | $path = resource_path('views/' . str_replace('.', '/', config('storyblok.view_path')) . 'blocks/'); 59 | $filename = $component['name'] . '.blade.php'; 60 | 61 | if ($this->option('overwrite') || !file_exists($path . $filename)) { 62 | $content = file_get_contents(__DIR__ . '/stubs/blade.stub'); 63 | $content = str_replace([ 64 | '#NAME#', 65 | '#CLASS#' 66 | ], [ 67 | $component['name'], 68 | $this->getChildClassName('Block', $component['name']) 69 | ], $content); 70 | 71 | $body = ''; 72 | 73 | foreach ($component['schema'] as $name => $field) { 74 | $body = $this->writeBlade($field, $name, $body); 75 | } 76 | 77 | $content = str_replace('#BODY#', $body, $content); 78 | 79 | file_put_contents($path . $filename, $content); 80 | 81 | $this->info('Created View: '. $component['name'] . '.blade.php'); 82 | } 83 | }); 84 | 85 | if ($this->option('overwrite') || !file_exists(resource_path('views/storyblok/pages') . '/page.blade.php')) { 86 | File::copy(__DIR__ . '/stubs/page.blade.stub', resource_path('views/storyblok/pages') . '/page.blade.php'); 87 | 88 | $this->info('Created Page: page.blade.php'); 89 | 90 | $this->info('Files created in your views' . DIRECTORY_SEPARATOR . 'storyblok folder.'); 91 | } 92 | } 93 | 94 | /** 95 | * @return void 96 | */ 97 | protected function makeDirectories(): void 98 | { 99 | if (!file_exists(resource_path('views/' . rtrim(config('storyblok.view_path'), '.')))) { 100 | File::makeDirectory(resource_path('views/' . rtrim(config('storyblok.view_path'), '.'))); 101 | } 102 | 103 | if (!file_exists(resource_path('views/' . rtrim(config('storyblok.view_path'), '.') . '/blocks'))) { 104 | File::makeDirectory(resource_path('views/' . rtrim(config('storyblok.view_path'), '.') . '/blocks')); 105 | } 106 | 107 | if (!file_exists(resource_path('views/' . rtrim(config('storyblok.view_path'), '.') . '/pages'))) { 108 | File::makeDirectory(resource_path('views/' . rtrim(config('storyblok.view_path'), '.') . '/pages')); 109 | } 110 | } 111 | 112 | /** 113 | * @param $field 114 | * @param int|string $name 115 | * @param string $body 116 | * @return string 117 | */ 118 | protected function writeBlade($field, int|string $name, string $body): string 119 | { 120 | if (!str_starts_with($name, 'tab-')) { 121 | switch ($field['type']) { 122 | case 'options': 123 | case 'bloks': 124 | $body .= "\t" . '@foreach ($block->' . $name . ' as $childBlock)' . "\n"; 125 | $body .= "\t\t" . '{{ $childBlock->render() }}' . "\n"; 126 | $body .= "\t" . '@endforeach' . "\n"; 127 | break; 128 | case 'datetime': 129 | $body .= "\t" . '' . "\n"; 130 | break; 131 | case 'number': 132 | case 'text': 133 | $body .= "\t" . '

{{ $block->' . $name . ' }}

' . "\n"; 134 | break; 135 | case 'multilink': 136 | $body .= "\t" . '' . "\n"; 137 | break; 138 | case 'textarea': 139 | case 'richtext': 140 | $body .= "\t" . '
{!! $block->' . $name . ' !!}
' . "\n"; 141 | break; 142 | case 'asset': 143 | if (array_key_exists('filetypes', $field) && in_array('images', $field['filetypes'], true)) { 144 | $body .= "\t" . '@if ($block->' . $name . '->hasFile())' . "\n"; 145 | $body .= "\t\t" . '' . "\n"; 146 | $body .= "\t" . '@endif' . "\n"; 147 | } else { 148 | $body .= "\t" . 'Download' . "\n"; 149 | } 150 | break; 151 | case 'image': 152 | $body .= "\t" . '@if ($block->' . $name . '->hasFile())' . "\n"; 153 | $body .= "\t\t" . '' . "\n"; 154 | $body .= "\t" . '@endif' . "\n"; 155 | break; 156 | case 'file': 157 | $body .= "\t" . '@if ($block->' . $name . '->hasFile())' . "\n"; 158 | $body .= "\t\t" . '{{ $block->' . $name . '->filename }}' . "\n"; 159 | $body .= "\t" . '@endif' . "\n"; 160 | break; 161 | default: 162 | $body .= "\t" . '{{ $block->' . $name . ' }}' . "\n"; 163 | } 164 | } 165 | 166 | $body .= "\n"; 167 | return $body; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Console/stubs/blade.stub: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 |
editorLink(true) !!}> 9 | #BODY# 10 |
11 | -------------------------------------------------------------------------------- /src/Console/stubs/block.blade.stub: -------------------------------------------------------------------------------- 1 | 5 | 6 |
editorLink(true) !!}> 7 | 8 |
9 | -------------------------------------------------------------------------------- /src/Console/stubs/block.scss.stub: -------------------------------------------------------------------------------- 1 | 2 | 3 | .DummyClass { 4 | 5 | } -------------------------------------------------------------------------------- /src/Console/stubs/block.stub: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | @section('title') 12 | {{ $story->meta('name') }} 13 | @stop 14 | 15 | 16 |
17 | {{-- 18 | // assuming you have a field called ‘blocks’ in your story 19 | // this will loop over and render them all for you 20 | 21 | @foreach ($story->blocks as $childBlock) 22 | {{ $childBlock->render() }} 23 | @endforeach 24 | --}} 25 |
26 | -------------------------------------------------------------------------------- /src/Console/stubs/page.stub: -------------------------------------------------------------------------------- 1 | url = $url; 28 | $message = $message ?: "The URL '{$url}' is denylisted and cannot be accessed"; 29 | 30 | parent::__construct($message, 404); 31 | } 32 | 33 | /** 34 | * Get the solution for this exception. 35 | * 36 | * @return \Spatie\Ignition\Contracts\Solution 37 | */ 38 | public function getSolution(): Solution 39 | { 40 | return BaseSolution::create('URL is denylisted') 41 | ->setSolutionDescription('This URL has been denylisted by the application. If you believe this is an error, check the denylist configuration.') 42 | ->setDocumentationLinks([ 43 | 'Laravel Storyblok docs' => 'https://ls.sirric.co.uk/docs/', 44 | ]); 45 | } 46 | 47 | /** 48 | * Get the denylisted URL. 49 | * 50 | * @return string 51 | */ 52 | public function getUrl(): string 53 | { 54 | return $this->url; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Exceptions/UnableToRenderException.php: -------------------------------------------------------------------------------- 1 | data) === 'App\Storyblok\Block') { 24 | return new CreateMissingBlockSolution($this->data); 25 | } 26 | 27 | if (count($this->data->_componentPath) === 1) { 28 | if (get_class($this->data) === 'App\Storyblok\Page') { 29 | $title = 'Create a view or custom Page class'; 30 | $description = 'Create one of the following views: `[' . implode(', ', $this->data->views()) . ']` or a create Page class called `App\Storyblok\Pages\\' . Str::studly($this->data->block()->component()) . '` and override the `views()` method implementing your own view finding logic.'; 31 | } else { 32 | $title = 'Create a view or implement view logic'; 33 | $description = 'Create one of the following views: `[' . implode(', ', $this->data->views()) . ']` or override the `views()` method in `App\Storyblok\Pages\\' . Str::studly($this->data->block()->component()) . '` and implement your own view finding logic.'; 34 | } 35 | } else { 36 | $title = 'Create a view or implement view logic'; 37 | $description = 'Create one of the following views: `[' . implode(', ', $this->data->views()) . ']` or override the `views()` method in `App\Storyblok\Blocks\\' . Str::studly($this->data->meta()['component']) . '` and implement your own view finding logic.'; 38 | } 39 | 40 | return BaseSolution::create($title) 41 | ->setSolutionDescription($description) 42 | ->setDocumentationLinks([ 43 | 'Laravel Storyblok docs' => 'https://ls.sirric.co.uk/docs/', 44 | ]); 45 | } 46 | 47 | 48 | } -------------------------------------------------------------------------------- /src/Field.php: -------------------------------------------------------------------------------- 1 | init(); 33 | } 34 | } 35 | 36 | /** 37 | * Returns the content of the Field 38 | * 39 | * @return array|string 40 | */ 41 | public function content(): mixed 42 | { 43 | return $this->content; 44 | } 45 | 46 | /** 47 | * Returns the Block this Field belongs to 48 | * 49 | * @return Block 50 | */ 51 | public function block(): Block 52 | { 53 | return $this->block; 54 | } 55 | 56 | /** 57 | * Checks if the requested key is in the Field’s content 58 | * 59 | * @param $key 60 | * @return bool 61 | */ 62 | public function has($key): bool 63 | { 64 | return array_key_exists($key, $this->content); 65 | } 66 | 67 | 68 | /** 69 | * Allows key/value pairs to be passed into the Field such as CSS 70 | * classes when rendering __toString or another content. 71 | * Example: {{ $field->with(['classes' => 'my-class']) }} 72 | * 73 | * @param $with 74 | * @return Field 75 | */ 76 | public function with($with): Field 77 | { 78 | $this->with = $with; 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * Magic accessor to pull content from the content. Works just like 85 | * Laravel’s model accessors. 86 | * 87 | * @param $key 88 | * @return false|mixed|string 89 | */ 90 | public function __get($key) { 91 | $accessor = 'get' . Str::studly($key) . 'Attribute'; 92 | 93 | if (method_exists($this, $accessor)) { 94 | return $this->$accessor(); 95 | } 96 | 97 | try { 98 | if ($this->has($key)) { 99 | return $this->content[$key]; 100 | } 101 | 102 | return false; 103 | } catch (Exception $e) { 104 | return 'Caught exception: ' . $e->getMessage(); 105 | } 106 | } 107 | 108 | /** 109 | * Prints the Field as a string 110 | * 111 | * @return string 112 | */ 113 | abstract public function __toString(): string; 114 | } -------------------------------------------------------------------------------- /src/FieldFactory.php: -------------------------------------------------------------------------------- 1 | classField($block, $field, $key); 26 | 27 | if ($isClassField) { 28 | return $isClassField; 29 | } 30 | 31 | // single item relations 32 | if (Str::isUuid($field) && ($block->_autoResolveRelations || in_array($key, $block->_resolveRelations, true) || array_key_exists($key, $block->_resolveRelations))) { 33 | 34 | if (array_key_exists($key, $block->_resolveRelations)) { 35 | return $block->getRelation(new RequestStory(), $field, $block->_resolveRelations[$key]); 36 | } 37 | 38 | return $block->getRelation(new RequestStory(), $field); 39 | } 40 | 41 | // complex fields 42 | if (is_array($field) && !empty($field)) { 43 | return $this->arrayField($block, $field, $key); 44 | } 45 | 46 | // legacy and string image fields 47 | if ($this->isStringImageField($field)) { 48 | return new Image($field, $block); 49 | } 50 | 51 | // strings or anything else - do nothing 52 | return $field; 53 | } 54 | 55 | /** 56 | * @param $block 57 | * @param $field 58 | * @param $key 59 | * @return mixed 60 | */ 61 | protected function classField($block, $field, $key): mixed 62 | { 63 | // does the Block assign any $_casts? This is key (field) => value (class) 64 | if (array_key_exists($key, $block->getCasts())) { 65 | $casts = $block->getCasts(); 66 | return new $casts[$key]($field, $block); 67 | } 68 | 69 | // find Fields specific to this Block matching: BlockNameFieldName 70 | if ($class = $block->getChildClassName('Field', $block->component() . '_' . $key)) { 71 | return new $class($field, $block); 72 | } 73 | 74 | // auto-match Field classes 75 | if ($class = $block->getChildClassName('Field', $key)) { 76 | return new $class($field, $block); 77 | } 78 | 79 | return false; 80 | } 81 | 82 | /** 83 | * If given an array field we need more processing to determine the class 84 | * 85 | * @param $block 86 | * @param $field 87 | * @param $key 88 | * @return \Illuminate\Support\Collection|mixed|Asset|Image|MultiAsset|RichText|Table 89 | */ 90 | protected function arrayField($block, $field, $key): mixed 91 | { 92 | // match link fields 93 | if (array_key_exists('linktype', $field)) { 94 | $class = 'Riclep\Storyblok\Fields\\' . Str::studly($field['linktype']) . 'Link'; 95 | 96 | return new $class($field, $block); 97 | } 98 | 99 | // match rich-text fields 100 | if (array_key_exists('type', $field) && $field['type'] === 'doc') { 101 | return new RichText($field, $block); 102 | } 103 | 104 | // match asset fields - detecting raster images 105 | if (array_key_exists('fieldtype', $field) && $field['fieldtype'] === 'asset') { 106 | // legacy and string image fields 107 | if ($this->isStringImageField($field['filename'])) { 108 | return new Image($field, $block); 109 | } 110 | 111 | if ($this->isSvgImageField($field['filename'])) { 112 | return new SvgImage($field, $block); 113 | } 114 | 115 | return new Asset($field, $block); 116 | } 117 | 118 | // match table fields 119 | if (array_key_exists('fieldtype', $field) && $field['fieldtype'] === 'table') { 120 | return new Table($field, $block); 121 | } 122 | 123 | if (array_key_exists(0, $field)) { 124 | return $this->relationField($block, $field, $key); 125 | } 126 | 127 | // just return the array 128 | return $field; 129 | } 130 | 131 | protected function relationField($block, $field, $key) { 132 | // it’s an array of relations - request them if we’re auto or manual resolving 133 | if (Str::isUuid($field[0])) { 134 | if ($block->_autoResolveRelations || array_key_exists($key, $block->_resolveRelations) || in_array($key, $block->_resolveRelations, true)) { 135 | 136 | // they’re passing a custom class 137 | if (array_key_exists($key, $block->_resolveRelations)) { 138 | $relations = collect($field)->transform(fn($relation) => $block->getRelation(new RequestStory(), $relation, $block->_resolveRelations[$key])); 139 | } else { 140 | $relations = collect($field)->transform(fn($relation) => $block->getRelation(new RequestStory(), $relation)); 141 | } 142 | 143 | if ($block->_filterRelations) { 144 | $relations = $relations->filter(); 145 | } 146 | 147 | return $relations; 148 | } 149 | } 150 | 151 | // has child items - single option, multi option and Blocks fields 152 | if (is_array($field[0])) { 153 | // resolved relationships - entire story is returned, we just want the content and a few meta items 154 | if (array_key_exists('content', $field[0])) { 155 | return collect($field)->transform(function ($relation) use ($block) { 156 | $class = $block->getChildClassName('Block', $relation['content']['component']); 157 | $relationClass = new $class($relation['content'], $block); 158 | 159 | $relationClass->addMeta([ 160 | 'name' => $relation['name'], 161 | 'published_at' => $relation['published_at'], 162 | 'full_slug' => $relation['full_slug'], 163 | ]); 164 | 165 | return $relationClass; 166 | }); 167 | } 168 | 169 | // this field holds blocks! 170 | if (array_key_exists('component', $field[0])) { 171 | return collect($field)->transform(function ($childBlock) use ($block) { 172 | $class = $block->getChildClassName('Block', $childBlock['component']); 173 | 174 | return new $class($childBlock, $block); 175 | }); 176 | } 177 | 178 | // multi assets 179 | if (array_key_exists('filename', $field[0])) { 180 | return new MultiAsset($field, $block); 181 | } 182 | } 183 | 184 | return $field; 185 | } 186 | 187 | /** 188 | * Check if given string is a string image field 189 | * 190 | * @param $filename 191 | * @return boolean 192 | */ 193 | public function isStringImageField($filename): bool 194 | { 195 | $allowed_extensions = config('storyblok.image_extensions'); 196 | 197 | return is_string($filename) && Str::of($filename)->lower()->endsWith($allowed_extensions); 198 | } 199 | 200 | /** 201 | * Check if given string is a string image field 202 | * 203 | * @param $filename 204 | * @return boolean 205 | */ 206 | public function isSvgImageField($filename): bool 207 | { 208 | $allowed_extensions = ['.svg', '.svgz']; 209 | 210 | return is_string($filename) && Str::of($filename)->lower()->endsWith($allowed_extensions); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Fields/Asset.php: -------------------------------------------------------------------------------- 1 | content['filename'])) { 19 | $this->content['filename'] = str_replace('a.storyblok.com', config('storyblok.asset_domain'), $this->content['filename']); 20 | } 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | if ($this->content['filename']) { 26 | return $this->content['filename']; 27 | } 28 | 29 | return ''; 30 | } 31 | 32 | /** 33 | * Checks a file was uploaded 34 | * 35 | * @return bool 36 | */ 37 | public function hasFile(): bool 38 | { 39 | if (!array_key_exists('filename', $this->content)) { 40 | return false; 41 | } 42 | 43 | return (bool) $this->content['filename']; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Fields/AssetLink.php: -------------------------------------------------------------------------------- 1 | url; 14 | } 15 | } -------------------------------------------------------------------------------- /src/Fields/DateTime.php: -------------------------------------------------------------------------------- 1 | content) { 15 | return ''; 16 | } 17 | 18 | if (property_exists($this, 'format')) { 19 | return $this->content->format($this->format); 20 | } 21 | 22 | return config('storyblok.date_format') ? $this->content->format(config('storyblok.date_format')) : $this->content->toDatetimeString(); 23 | } 24 | 25 | /** 26 | * Converts the field to a carbon object 27 | */ 28 | protected function init(): void 29 | { 30 | $this->content = $this->content ? Carbon::parse($this->content) : null; 31 | } 32 | } -------------------------------------------------------------------------------- /src/Fields/EmailLink.php: -------------------------------------------------------------------------------- 1 | email; 14 | } 15 | } -------------------------------------------------------------------------------- /src/Fields/Image.php: -------------------------------------------------------------------------------- 1 | upgradeStringFields($content); 44 | parent::__construct($this->content, $block); 45 | } else { 46 | parent::__construct($content, $block); 47 | } 48 | 49 | $this->transformerClass = config('storyblok.image_transformer'); 50 | 51 | $transformerClass = $this->transformerClass; 52 | $this->transformer = new $transformerClass($this); 53 | 54 | if (method_exists($this->transformer, 'init')) { 55 | $this->transformer->init(); 56 | } 57 | 58 | if (method_exists($this, 'transformations')) { 59 | $this->transformations(); 60 | } 61 | } 62 | 63 | /** 64 | * Get the width of the image or transformed image 65 | * 66 | * @param bool $original 67 | * @return int 68 | */ 69 | public function width(bool $original = false): int 70 | { 71 | return $this->transformer->width($original); 72 | } 73 | 74 | /** 75 | * Get the height of the image or transformed image 76 | * 77 | * @param bool $original 78 | * @return int 79 | */ 80 | public function height(bool $original = false): int 81 | { 82 | return $this->transformer->height($original); 83 | } 84 | 85 | /** 86 | * Get the mime of the image or transformed image 87 | * 88 | * @param bool $original 89 | * @return string 90 | */ 91 | public function mime(bool $original = false): string 92 | { 93 | return $this->transformer->mime($original); 94 | } 95 | 96 | /** 97 | * Create a new or get a transformation of the image 98 | * 99 | * @param $tranformation 100 | * @return mixed 101 | */ 102 | public function transform($tranformation = null): mixed 103 | { 104 | if ($tranformation) { 105 | if (array_key_exists($tranformation, $this->transformations) ) { 106 | return new ImageTransformation($this->transformations[$tranformation]); 107 | } 108 | return false; 109 | } 110 | 111 | $transformerClass = $this->transformerClass; 112 | $this->transformer = new $transformerClass($this); 113 | 114 | if (method_exists($this->transformer, 'init')) { 115 | $this->transformer->init(); 116 | } 117 | 118 | return $this->transformer; 119 | } 120 | 121 | /** 122 | * Set the driver to use for transformations 123 | * 124 | * @param $transformer 125 | * @return mixed 126 | */ 127 | public function transformer($transformer): mixed 128 | { 129 | $this->transformerClass = $transformer; 130 | 131 | return $this; 132 | } 133 | 134 | 135 | /** 136 | * Returns a picture element tag for this image and 137 | * ant transforms defined on the image class 138 | * 139 | * @param string $alt 140 | * @param $default 141 | * @param array $attributes 142 | * @param string $view 143 | * @param bool $reverseTagOrder 144 | * @return string 145 | */ 146 | public function picture(string $alt = '', $default = null, array $attributes = [], string $view = 'laravel-storyblok::picture-element', bool $reverseTagOrder = false): string 147 | { 148 | if ($default) { 149 | $imgSrc = (string) $this->transformations[$default]['src']; 150 | } else { 151 | $imgSrc = $this->filename; 152 | } 153 | 154 | // srcset seems to work the opposite way to picture elements when working out sizes 155 | if ($reverseTagOrder) { 156 | $transformations = array_reverse($this->transformations); 157 | } else { 158 | $transformations = $this->transformations; 159 | } 160 | 161 | return view($view, [ 162 | 'alt' => $alt, 163 | 'attributes' => $attributes, 164 | 'default' => $default, 165 | 'imgSrc' => $imgSrc, 166 | 'transformations' => $transformations, 167 | ])->render(); 168 | } 169 | 170 | /** 171 | * Returns an image tag with srcset attribute 172 | * 173 | * @param string $alt 174 | * @param $default 175 | * @param array $attributes 176 | * @param string $view 177 | * @return string 178 | */ 179 | public function srcset(string $alt = '', $default = null, array $attributes = [], string $view = 'laravel-storyblok::srcset'): string 180 | { 181 | return $this->picture($alt, $default, $attributes, $view ?? 'laravel-storyblok::srcset'); 182 | } 183 | 184 | /** 185 | * Allows setting of new transformations on this image. Optionally 186 | * return a new image so the original is not mutated 187 | * 188 | * @param $transformations 189 | * @param bool $mutate 190 | * @return $this|Image 191 | */ 192 | public function setTransformations($transformations, bool $mutate = true): Image|self 193 | { 194 | if ($mutate) { 195 | $this->transformations = $transformations; 196 | 197 | return $this; 198 | } 199 | 200 | $class = get_class($this); // don’t mutate original object 201 | $image = new $class($this->content, $this->block); 202 | $image->transformations = $transformations; 203 | 204 | return $image; 205 | } 206 | 207 | /** 208 | * Reads the focus property if available and returns a string that can be used for CSS 209 | * object-position or background-position. The default should be any valid value for 210 | * the CSS property being used. Rigid will use hard alignments to edges. 211 | * 212 | * @param $default 213 | * @param $rigid 214 | * @return string 215 | */ 216 | public function focalPointAlignment($default = 'center', $rigid = false): string 217 | { 218 | if (!$this->focus) { 219 | return $default; 220 | } 221 | 222 | preg_match_all('/\d+/', $this->focus, $matches); 223 | 224 | $leftPercent = round(($matches[0][0] / $this->width(true)) * 100); 225 | $topPercent = round(($matches[0][1] / $this->height(true)) * 100); 226 | 227 | if ($rigid) { 228 | if ($leftPercent > 66) { 229 | $horizontalAlignment = 'right'; 230 | } else if ($leftPercent > 33) { 231 | $horizontalAlignment = 'center'; 232 | } else { 233 | $horizontalAlignment = 'left'; 234 | } 235 | 236 | if ($topPercent > 66) { 237 | $verticalAlignment = 'bottom'; 238 | } else if ($topPercent > 33) { 239 | $verticalAlignment = 'center'; 240 | } else { 241 | $verticalAlignment = 'top'; 242 | } 243 | 244 | return $horizontalAlignment . ' ' . $verticalAlignment; 245 | } 246 | 247 | return $leftPercent . '% ' . $topPercent . '%'; 248 | } 249 | 250 | /** 251 | * Converts string fields into full image fields 252 | * 253 | * @param $content 254 | * @return void 255 | */ 256 | protected function upgradeStringFields($content): void 257 | { 258 | $this->content = [ 259 | 'filename' => $content, 260 | 'alt' => null, 261 | 'copyright' => null, 262 | 'fieldtype' => 'asset', 263 | 'focus' => null, 264 | 'name' => '', 265 | 'title' => null, 266 | ]; 267 | } 268 | } -------------------------------------------------------------------------------- /src/Fields/Link.php: -------------------------------------------------------------------------------- 1 | cached_url; 17 | } 18 | } -------------------------------------------------------------------------------- /src/Fields/Markdown.php: -------------------------------------------------------------------------------- 1 | 'escape', 24 | 'allow_unsafe_links' => false, 25 | 'max_nesting_level' => 100, 26 | ]; 27 | 28 | $environment = new Environment($config); 29 | $environment->addExtension(new CommonMarkCoreExtension()); 30 | $environment->addExtension(new TableExtension()); 31 | 32 | $converter = new MarkdownConverter($environment); 33 | 34 | return (string) $converter->convert($this->content); 35 | } 36 | } -------------------------------------------------------------------------------- /src/Fields/MultiAsset.php: -------------------------------------------------------------------------------- 1 | content->map(function ($item) { 28 | if (is_object($item) && $item->has('filename')) { 29 | return $item->filename; 30 | } 31 | })->filter()->implode(','); 32 | } 33 | 34 | /** 35 | * Attempts to determine the types of assets that have been linked 36 | */ 37 | public function init(): void 38 | { 39 | if ($this->hasFiles()) { 40 | $this->content = collect($this->content())->transform(function ($file) { 41 | if (Str::endsWith(Str::lower($file['filename']), ['.jpg', '.jpeg', '.png', '.gif', '.webp'])) { 42 | if ($class = $this->getChildClassName('Field', $this->block()->component() . 'Image')) { 43 | return new $class($file, $this->block()); 44 | } 45 | 46 | return new Image($file, $this->block()); 47 | } 48 | 49 | if ($class = $this->getChildClassName('Field', $this->block()->component() . 'Asset')) { 50 | return new $class($file, $this->block()); 51 | } 52 | 53 | return new Asset($file, $this->block()); 54 | }); 55 | } 56 | } 57 | 58 | /** 59 | * Checks if files are uploaded 60 | * 61 | * @return bool 62 | */ 63 | public function hasFiles(): bool 64 | { 65 | return (bool) $this->content(); 66 | } 67 | 68 | /* 69 | * Methods for ArrayAccess Trait - allows us to dig straight down to the content collection when calling a key on the Object 70 | * */ 71 | public function offsetSet($offset, $value): void 72 | { 73 | if (is_null($offset)) { 74 | $this->content[] = $value; 75 | } else { 76 | $this->content[$offset] = $value; 77 | } 78 | } 79 | 80 | public function offsetExists($offset): bool 81 | { 82 | return isset($this->content[$offset]); 83 | } 84 | 85 | public function offsetUnset($offset): void 86 | { 87 | unset($this->content[$offset]); 88 | } 89 | 90 | public function offsetGet($offset): mixed 91 | { 92 | return isset($this->content[$offset]) ? $this->content[$offset] : null; 93 | } 94 | 95 | /* 96 | * Methods for Iterator trait allowing us to foreach over a collection of 97 | * Blocks and return their content. This makes accessing child content 98 | * in Blade much cleaner 99 | * */ 100 | public function current(): mixed 101 | { 102 | return $this->content[$this->iteratorIndex]; 103 | } 104 | 105 | public function next(): void 106 | { 107 | $this->iteratorIndex++; 108 | } 109 | 110 | public function rewind(): void 111 | { 112 | $this->iteratorIndex = 0; 113 | } 114 | 115 | public function key(): int 116 | { 117 | return $this->iteratorIndex; 118 | } 119 | 120 | public function valid(): bool 121 | { 122 | return isset($this->content[$this->iteratorIndex]); 123 | } 124 | 125 | /* 126 | * Countable trait 127 | * */ 128 | public function count(): int 129 | { 130 | return $this->content->count(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Fields/RichText.php: -------------------------------------------------------------------------------- 1 | content['content'] as $node) { 24 | if ($node['type'] === 'blok' && isset($node['attrs']['body']) && is_array($node['attrs']['body'])) { 25 | foreach ($node['attrs']['body'] as $blockContent) { 26 | $class = $this->getChildClassName('Block', $blockContent['component']); 27 | $block = new $class($blockContent, $this->block()); 28 | 29 | $content[] = $block; 30 | } 31 | } else { 32 | $content[] = $richtextResolver->render(["content" => [$node]]); 33 | } 34 | } 35 | 36 | $this->content = collect($content); 37 | } 38 | 39 | /** 40 | * Converts the data to HTML when printed. If there is an inline Component 41 | * it will use it’s render method. 42 | * 43 | * @return string 44 | */ 45 | public function __toString(): string 46 | { 47 | $html = ""; 48 | 49 | foreach ($this->content as $content) { 50 | if (is_string($content)) { 51 | $html .= $content; 52 | } else { 53 | $html .= $content->render(); 54 | } 55 | } 56 | 57 | return $html; 58 | } 59 | } -------------------------------------------------------------------------------- /src/Fields/StoryLink.php: -------------------------------------------------------------------------------- 1 | anchor) { 16 | return $this->cached_url . '#' . $this->anchor; 17 | } 18 | 19 | return $this->cached_url; 20 | } 21 | } -------------------------------------------------------------------------------- /src/Fields/SvgImage.php: -------------------------------------------------------------------------------- 1 | tag 19 | */ 20 | protected string $cssClass = ''; 21 | 22 | /** 23 | * @var array|int the column numbers to convert to headers 24 | */ 25 | protected array|int $headerColumns = []; 26 | 27 | protected function toHtml($table): string 28 | { 29 | $html = 'cssClass ? 'class="' . $this->cssClass . '"' : null) . '>'; 30 | 31 | if ($this->caption) { 32 | if (is_array($this->caption)) { 33 | $html .= ''; 34 | } else { 35 | $html .= ''; 36 | } 37 | } 38 | 39 | $html .= ''; 40 | 41 | foreach ($table['thead'] as $header) { 42 | $html .= ''; 43 | } 44 | 45 | $html .= ''; 46 | 47 | foreach ($table['tbody'] as $row) { 48 | $html .= ''; 49 | 50 | foreach ($row['body'] as $column => $cell) { 51 | if ($this->headerColumns && in_array(($column + 1), Arr::wrap($this->headerColumns))) { 52 | $html .= ''; 53 | } else { 54 | $html .= ''; 55 | } 56 | } 57 | 58 | $html .= ''; 59 | } 60 | 61 | return $html . '
' . $this->caption[0] . '' . $this->caption . '
' . nl2br($header['value']) . '
' . nl2br($cell['value']) . '' . nl2br($cell['value']) . '
'; 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return $this->toHtml($this->content); 67 | } 68 | 69 | public function caption($caption): self 70 | { 71 | $this->caption = $caption; 72 | 73 | return $this; 74 | } 75 | 76 | public function cssClass($cssClass): self 77 | { 78 | $this->cssClass = $cssClass; 79 | 80 | return $this; 81 | } 82 | } -------------------------------------------------------------------------------- /src/Fields/Textarea.php: -------------------------------------------------------------------------------- 1 | autoParagraph($this->content); 19 | } 20 | 21 | /** 22 | * Performs the actual transformation 23 | * 24 | * @param $text 25 | * @return string 26 | */ 27 | private function autoParagraph($text): string 28 | { 29 | if ($text) { 30 | $paragraphs = explode("\n", $text); 31 | return '

' . implode('

', array_filter($paragraphs)) . '

'; 32 | } 33 | 34 | return ''; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Fields/UrlLink.php: -------------------------------------------------------------------------------- 1 | cached_url; 15 | } 16 | } -------------------------------------------------------------------------------- /src/Folder.php: -------------------------------------------------------------------------------- 1 | stories, 86 | $this->totalStories, 87 | $this->perPage, 88 | $page, 89 | [ 90 | 'path' => LengthAwarePaginator::resolveCurrentPath(), 91 | 'pageName' => $pageName, 92 | ] 93 | ); 94 | } 95 | 96 | 97 | /** 98 | * Reads a content of the returned stories, processing each one 99 | * 100 | * @return Folder 101 | */ 102 | public function read(): Folder 103 | { 104 | $stories = $this->get()->transform(function ($story) { 105 | $blockClass = $this->getChildClassName('Page', $story['content']['component']); 106 | 107 | return new $blockClass($story); 108 | }); 109 | 110 | $this->stories = $stories; 111 | 112 | return $this; 113 | } 114 | 115 | 116 | /** 117 | * Sets the slug of the folder to request 118 | * 119 | * @param string $slug 120 | * @return Folder 121 | */ 122 | public function slug(string $slug): Folder 123 | { 124 | $this->slug = $slug; 125 | 126 | return $this; 127 | } 128 | 129 | 130 | /** 131 | * The field and order in which we want to sort the stories by 132 | * 133 | * @param string $sortBy 134 | * @param string|null $sortOrder 135 | * @return Folder 136 | */ 137 | public function sort(string $sortBy, string $sortOrder = null): Folder 138 | { 139 | $this->sortBy = $sortBy; 140 | 141 | if ($sortOrder) { 142 | $this->sortOrder = $sortOrder; 143 | } 144 | 145 | return $this; 146 | } 147 | 148 | 149 | /** 150 | * Sort ascending 151 | */ 152 | public function asc(): Folder 153 | { 154 | $this->sortOrder = 'asc'; 155 | 156 | return $this; 157 | } 158 | 159 | 160 | /** 161 | * Sort descending 162 | */ 163 | public function desc(): Folder 164 | { 165 | $this->sortOrder = 'desc'; 166 | 167 | return $this; 168 | } 169 | 170 | 171 | /** 172 | * Define the settings for the API call 173 | * 174 | * @param array $settings 175 | */ 176 | public function settings(array $settings): void 177 | { 178 | $this->settings = $settings; 179 | } 180 | 181 | 182 | /** 183 | * Returns the total number of stories for this page 184 | * 185 | * @return int 186 | */ 187 | public function count(): int 188 | { 189 | return $this->stories->count() ?? 0; 190 | } 191 | 192 | 193 | /** 194 | * Sets the number of items per page 195 | * 196 | * @param $perPage 197 | * @return Folder 198 | */ 199 | public function perPage($perPage): Folder 200 | { 201 | $this->perPage = $perPage; 202 | 203 | return $this; 204 | } 205 | 206 | 207 | /** 208 | * Caches the response and returns just the bit we want 209 | * 210 | * @return Collection 211 | */ 212 | protected function get() 213 | { 214 | if (request()->has('_storyblok') || !config('storyblok.cache')) { 215 | $response = $this->makeRequest(); 216 | } else { 217 | $apiHash = md5(config('storyblok.api_public_key') ?? config('storyblok.api_preview_key')); // unique id for multitenancy applications 218 | $uniqueTag = md5(serialize($this->getSettings())); 219 | 220 | $response = Cache::store(config('storyblok.sb_cache_driver'))->remember($this->cacheKey . $this->slug . '-' . $apiHash . '-' . $uniqueTag, config('storyblok.cache_duration') * 60, function () { 221 | return $this->makeRequest(); 222 | }); 223 | } 224 | 225 | $this->totalStories = $response['headers']['Total'][0]; 226 | 227 | return collect($response['stories']); 228 | } 229 | 230 | 231 | /** 232 | * Makes the actual request 233 | * 234 | * @return array 235 | */ 236 | protected function makeRequest(): array 237 | { 238 | $storyblokClient = resolve('Storyblok\Client'); 239 | 240 | $storyblokClient = $storyblokClient->getStories($this->getSettings()); 241 | 242 | return [ 243 | 'headers' => $storyblokClient->getHeaders(), 244 | 'stories' => $storyblokClient->getBody()['stories'], 245 | ]; 246 | } 247 | 248 | /** 249 | * Returns the settings for the folder 250 | * 251 | * @return array 252 | */ 253 | protected function getSettings(): array 254 | { 255 | return array_merge([ 256 | 'is_startpage' => $this->startPage, 257 | 'sort_by' => $this->sortBy . ':' . $this->sortOrder, 258 | 'starts_with' => $this->slug, 259 | 'page' => $this->currentPage, 260 | 'per_page' => $this->perPage, 261 | ], $this->settings); 262 | } 263 | 264 | /** 265 | * Returns the Stories as an array 266 | * 267 | * @return array 268 | */ 269 | public function toArray(): array 270 | { 271 | return $this->stories->toArray(); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Http/Controllers/LiveContentController.php: -------------------------------------------------------------------------------- 1 | input('data'); 23 | 24 | if (!isset($data['story'])) { 25 | throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json(['message' => 'Story not found'], 404)); 26 | } 27 | 28 | config(['storyblok.edit_mode' => true]); 29 | 30 | $page = Storyblok::setData($data['story'])->render(); 31 | $dom = new HTML5DOMDocument(); 32 | $dom->loadHTML($page, HTML5DOMDocument::ALLOW_DUPLICATE_IDS); 33 | 34 | return $dom->querySelector(config('storyblok.live_element'))->innerHTML; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Http/Controllers/StoryblokController.php: -------------------------------------------------------------------------------- 1 | isDenylisted($slug)) { 21 | throw new \Riclep\Storyblok\Exceptions\DenylistedUrlException($slug); 22 | } 23 | 24 | return Storyblok::read($slug)->render(); 25 | } 26 | 27 | /** 28 | * Deletes the cached API responses 29 | */ 30 | public function destroy(): void 31 | { 32 | if (Cache::getStore() instanceof \Illuminate\Cache\TaggableStore) { 33 | Cache::store(config('storyblok.sb_cache_driver'))->tags('storyblok')->flush(); 34 | } else { 35 | Cache::store(config('storyblok.sb_cache_driver'))->flush(); 36 | } 37 | } 38 | 39 | /** 40 | * Check if the slug is blacklisted. 41 | * 42 | * @param string $slug 43 | * @return bool 44 | */ 45 | protected function isDenylisted(string $slug): bool 46 | { 47 | foreach (config('storyblok.denylist', []) as $pattern) { 48 | if ($pattern === $slug) { 49 | return true; 50 | } 51 | 52 | if (strlen($pattern) > 1 && $pattern[0] === '/' && substr($pattern, -1) === '/') { 53 | if (preg_match($pattern, $slug)) { 54 | return true; 55 | } 56 | } 57 | } 58 | 59 | return false; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Http/Controllers/WebhookController.php: -------------------------------------------------------------------------------- 1 | header("webhook-signature"); 23 | 24 | // Check if the header is neccessary and if it is set 25 | if (!empty($webhookSecret) && $requestSignature === null) { 26 | throw new BadRequestHttpException("Header not set"); 27 | } 28 | 29 | // Skip signature check if no secret configured 30 | if (!empty($webhookSecret)) { 31 | $expectedSignature = hash_hmac("sha1", $request->getContent(), $webhookSecret); 32 | 33 | if ($requestSignature !== $expectedSignature) { 34 | throw new BadRequestHttpException("Signature has invalid format"); 35 | } 36 | } 37 | 38 | if ($request->all()["action"] === "published") { 39 | StoryblokPublished::dispatch($request->all()); 40 | } elseif ($request->all()["action"] === "unpublished" || $request->all()["action"] === "deleted") { 41 | StoryblokUnpublished::dispatch($request->all()); 42 | } 43 | 44 | return ["success" => true]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/Middleware/StoryblokEditor.php: -------------------------------------------------------------------------------- 1 | ajax() && ($content = $response->getOriginalContent()) instanceof View) { 22 | return $response->setContent($content->renderSections()['content']); 23 | } 24 | 25 | return $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Listeners/ClearCache.php: -------------------------------------------------------------------------------- 1 | tags('storyblok')->flush(); 20 | } else { 21 | // Cache::flush(); 22 | Cache::store(config('storyblok.sb_cache_driver'))->flush(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Page.php: -------------------------------------------------------------------------------- 1 | story = $story; 46 | 47 | $this->preprocess(); 48 | 49 | $this->block = $this->createBlock($this->story['content']); 50 | 51 | // run automatic traits - methods matching initTraitClassName() 52 | foreach (class_uses_recursive($this) as $trait) { 53 | if (method_exists($this, $method = 'init' . class_basename($trait))) { 54 | $this->{$method}(); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Returns a view populated with the story’s content. Additional data can 61 | * be passed in using an associative array 62 | * 63 | * @param array $additionalContent 64 | * @return View 65 | * @throws UnableToRenderException 66 | */ 67 | public function render($additionalContent = []): View 68 | { 69 | try { 70 | return view()->first($this->views(), array_merge(['story' => $this], $additionalContent)); 71 | } catch (\Exception $exception) { 72 | throw new UnableToRenderException('None of the views in the given array exist.', $this); 73 | } 74 | } 75 | 76 | /** 77 | * Returns a list of possible arrays for this page based on the Storyblok 78 | * contentType component’s name 79 | * 80 | * @return array 81 | */ 82 | public function views(): array 83 | { 84 | $views = array_map(function($path) { 85 | return config('storyblok.view_path') . 'pages.' . $path; 86 | }, $this->block()->_componentPath); 87 | 88 | return array_reverse(array_unique($views)); 89 | } 90 | 91 | /** 92 | * Returns the story for this Page 93 | * 94 | * @return array 95 | */ 96 | public function story(): array 97 | { 98 | return $this->story; 99 | } 100 | 101 | /** 102 | * Return a lovely Carbon object of the first published date 103 | * 104 | * @return Carbon 105 | */ 106 | public function publishedAt(): Carbon 107 | { 108 | return Carbon::parse($this->story['first_published_at']); 109 | } 110 | 111 | /** 112 | * A Carbon object for the most recent publish date 113 | * 114 | * @return Carbon 115 | */ 116 | public function updatedAt(): Carbon 117 | { 118 | return Carbon::parse($this->story['published_at']); 119 | } 120 | 121 | /** 122 | * Returns the full slug for the page 123 | * 124 | * @return string 125 | */ 126 | public function slug(): string 127 | { 128 | return $this->story['full_slug']; 129 | } 130 | 131 | /** 132 | * Returns all the tags, sorting them if so desired 133 | * 134 | * @param bool $alphabetical 135 | * @return mixed 136 | */ 137 | public function tags(bool $alphabetical = false): mixed 138 | { 139 | if ($alphabetical) { 140 | sort($this->story['tag_list']); 141 | } 142 | 143 | return $this->story['tag_list']; 144 | } 145 | 146 | /** 147 | * Checks if this page has the matching tag 148 | * 149 | * @param $tag 150 | * @return bool 151 | */ 152 | public function hasTag($tag): bool 153 | { 154 | return in_array($tag, $this->tags()); 155 | } 156 | 157 | /** 158 | * Returns the page’s contentType Block - this is the component type 159 | * used for the page in Storyblok 160 | * 161 | * @return Block 162 | */ 163 | public function block(): Block 164 | { 165 | return $this->block; 166 | } 167 | 168 | /** 169 | * Magic getter to return fields from the contentType block for this page 170 | * without having to reach into the page. 171 | * 172 | * @param $name 173 | * @return bool|string 174 | */ 175 | public function __get($name) { 176 | // check for accessor on the root block 177 | $accessor = 'get' . Str::studly($name) . 'Attribute'; 178 | 179 | if (method_exists($this->block(), $accessor)) { 180 | return $this->block()->$accessor(); 181 | } 182 | 183 | // check for attribute on the root block 184 | if ($this->block()->has($name)) { 185 | return $this->block()->{$name}; 186 | } 187 | } 188 | 189 | /** 190 | * Does a bit of housekeeping before processing the data 191 | * from Storyblok any further 192 | */ 193 | protected function preprocess(): void 194 | { 195 | $this->addMeta([ 196 | '_uid' => $this->story['uuid'], 197 | 'name' => $this->story['name'], 198 | 'tags' => $this->story['tag_list'], 199 | 'slug' => $this->story['full_slug'], 200 | 'published_at' => Carbon::parse($this->story['first_published_at']), 201 | 'updated_at' => Carbon::parse($this->story['published_at']), 202 | ]); 203 | } 204 | 205 | /** 206 | * Creates the Block for the page’s contentType component 207 | * 208 | * @param $content 209 | * @return mixed 210 | */ 211 | private function createBlock($content): mixed 212 | { 213 | $class = $this->getChildClassName('Block', $content['component']); 214 | 215 | return new $class($content, $this); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/RequestStory.php: -------------------------------------------------------------------------------- 1 | has('_storyblok') || !config('storyblok.cache')) { 42 | $response = $this->makeRequest($slugOrUuid); 43 | } else { 44 | $cache = Cache::getFacadeRoot(); 45 | 46 | if (Cache::getStore() instanceof \Illuminate\Cache\TaggableStore) { 47 | $cache = $cache->tags('storyblok'); 48 | } 49 | 50 | $api_hash = md5(config('storyblok.api_public_key') ?? config('storyblok.api_preview_key')); 51 | $response = $cache->store(config('storyblok.sb_cache_driver'))->remember($slugOrUuid . '_' . $api_hash , config('storyblok.cache_duration') * 60, function () use ($slugOrUuid) { 52 | return $this->makeRequest($slugOrUuid); 53 | }); 54 | } 55 | 56 | return $response['story']; 57 | } 58 | 59 | /** 60 | * Prepares the relations so the format is correct for the API call 61 | * 62 | * @param $resolveRelations 63 | */ 64 | public function resolveRelations($resolveRelations): void 65 | { 66 | $this->resolveRelations = implode(',', $resolveRelations); 67 | } 68 | 69 | /** 70 | * Set the language and fallback language to use for this Story, will default to ‘default’ 71 | * 72 | * @param string|null $language 73 | * @param string|null $fallbackLanguage 74 | */ 75 | public function language($language, $fallbackLanguage = null) { 76 | $this->language = $language; 77 | $this->fallbackLanguage = $fallbackLanguage; 78 | } 79 | 80 | /** 81 | * Makes the API request 82 | * 83 | * @param $slugOrUuid 84 | * @return array 85 | * @throws ApiException 86 | */ 87 | private function makeRequest($slugOrUuid): array 88 | { 89 | $storyblokClient = resolve('Storyblok\Client'); 90 | 91 | if ($this->resolveRelations) { 92 | $storyblokClient = $storyblokClient->resolveRelations($this->resolveRelations); 93 | } 94 | 95 | if (config('storyblok.resolve_links')) { 96 | $storyblokClient = $storyblokClient->resolveLinks(config('storyblok.resolve_links')); 97 | } 98 | 99 | if ($this->language) { 100 | $storyblokClient = $storyblokClient->language($this->language); 101 | } 102 | 103 | if ($this->fallbackLanguage) { 104 | $storyblokClient = $storyblokClient->fallbackLanguage($this->fallbackLanguage); 105 | } 106 | 107 | if (Str::isUuid($slugOrUuid)) { 108 | $storyblokClient = $storyblokClient->getStoryByUuid($slugOrUuid); 109 | } else { 110 | $storyblokClient = $storyblokClient->getStoryBySlug($slugOrUuid); 111 | } 112 | 113 | return $storyblokClient->getBody(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Solutions/CreateMissingBlockSolution.php: -------------------------------------------------------------------------------- 1 | data = $data; 16 | } 17 | 18 | public function getSolutionTitle(): string 19 | { 20 | if (get_class($this->data) === 'App\Storyblok\Block') { 21 | return 'Create a view or custom Block class'; 22 | } 23 | 24 | return 'Create a view or implement view logic'; 25 | } 26 | 27 | public function getSolutionDescription(): string 28 | { 29 | if (get_class($this->data) === 'App\Storyblok\Block') { 30 | return 'Create one of the following views: `[' . implode(', ', $this->data->views()) . ']` or a create Block class called `App\Storyblok\Blocks\\' . Str::studly($this->data->meta()['component']) . '` and override the `views()` method implementing your own view finding logic. You can also scaffold all your views using `artisan ls:stub-views`.'; 31 | } 32 | 33 | return 'Create one of the following views: `[' . implode(', ', $this->data->views()) . ']` or override the `views()` method in `App\Storyblok\Blocks\\' . Str::studly($this->data->meta()['component']) . '` and implement your own view finding logic. You can also scaffold all your views using `artisan ls:stub-views`.'; 34 | } 35 | 36 | public function getDocumentationLinks(): array 37 | { 38 | return [ 39 | 'Laravel Storyblok docs' => 'https://ls.sirric.co.uk/docs/', 40 | ]; 41 | } 42 | 43 | public function getSolutionActionDescription(): string 44 | { 45 | return 'We can try to solve this exception by running a little code'; 46 | } 47 | 48 | public function getRunButtonText(): string 49 | { 50 | return 'Create ' . Str::studly($this->data->meta()['component']) . ' Block class'; 51 | } 52 | 53 | public function run(array $parameters = []): void 54 | { 55 | Artisan::call('ls:block', $parameters); 56 | } 57 | 58 | public function getRunParameters(): array 59 | { 60 | return [ 61 | 'name' => Str::studly($this->data->meta()['component']), 62 | ]; 63 | } 64 | } -------------------------------------------------------------------------------- /src/Storyblok.php: -------------------------------------------------------------------------------- 1 | resolveRelations($resolveRelations); 31 | } 32 | 33 | if ($language) { 34 | $storyblokRequest->language($language, $fallbackLanguage); 35 | } 36 | 37 | $response = $storyblokRequest->get($slug); 38 | 39 | $class = $this->getChildClassName('Page', $response['content']['component']); 40 | 41 | return new $class($response); 42 | } 43 | 44 | /** 45 | * @param $data 46 | * @return mixed 47 | */ 48 | public function setData($data): mixed 49 | { 50 | $response = $data; 51 | 52 | $class = $this->getChildClassName('Page', $response['content']['component']); 53 | 54 | return new $class($response); 55 | } 56 | } -------------------------------------------------------------------------------- /src/StoryblokFacade.php: -------------------------------------------------------------------------------- 1 | loadRoutesFrom(__DIR__.'/routes/api.php'); 21 | 22 | $this->loadViewsFrom(__DIR__.'/resources/views', 'laravel-storyblok'); 23 | 24 | if ($this->app->runningInConsole()) { 25 | $this->publishes([ 26 | __DIR__.'/../config/storyblok.php' => config_path('storyblok.php'), 27 | __DIR__ . '/../stubs/Page.stub' => app_path('Storyblok') . '/Page.php', 28 | __DIR__ . '/../stubs/Block.stub' => app_path('Storyblok') . '/Block.php', 29 | __DIR__ . '/../stubs/Asset.stub' => app_path('Storyblok') . '/Asset.php', 30 | __DIR__ . '/../stubs/Folder.stub' => app_path('Storyblok') . '/Folder.php', 31 | ], 'storyblok'); 32 | } 33 | 34 | $this->commands([ 35 | BlockMakeCommand::class, 36 | BlockSyncCommand::class, 37 | FolderMakeCommand::class, 38 | PageMakeCommand::class, 39 | StubViewsCommand::class 40 | ]); 41 | } 42 | 43 | /** 44 | * Register the application services. 45 | */ 46 | public function register(): void 47 | { 48 | // Automatically apply the package configuration 49 | $this->mergeConfigFrom(__DIR__.'/../config/storyblok.php', 'storyblok'); 50 | 51 | // Register the main class to use with the facade 52 | $this->app->singleton('storyblok', function () { 53 | return new Storyblok; 54 | }); 55 | 56 | ////////////TODO should this be a middleware? 57 | $storyblokRequest = request()->get('_storyblok_tk'); 58 | if (!empty($storyblokRequest)) { 59 | $pre_token = $storyblokRequest['space_id'] . ':' . config('storyblok.api_preview_key') . ':' . $storyblokRequest['timestamp']; 60 | $token = sha1($pre_token); 61 | if ($token == $storyblokRequest['token'] && (int)$storyblokRequest['timestamp'] > strtotime('now') - 3600) { 62 | config(['storyblok.edit_mode' => true]); 63 | config(['storyblok.draft' => true]); 64 | } 65 | } 66 | 67 | // register the Storyblok client, checking if we are in edit more of the dev requests draft content 68 | $client = new Client( 69 | config('storyblok.draft') ? config('storyblok.api_preview_key') : config('storyblok.api_public_key'), 70 | config('storyblok.delivery_api_base_url'), "v2", config('storyblok.use_ssl'), config('storyblok.api_region') 71 | ); 72 | 73 | // if we’re in Storyblok’s edit mode let’s save that in the config for easy access 74 | $client->editMode(config('storyblok.draft')); 75 | 76 | // the client's cache needs to be set or else the client's private isCache() will always return false 77 | if( config('storyblok.draft') == false && config('storyblok.cache') == true){ 78 | $client->setCache(config('storyblok.sb_cache_driver'), [ 79 | 'path' => config('storyblok.sb_cache_path'), 80 | 'default_lifetime' => config('storyblok.sb_cache_lifetime'), 81 | ]); 82 | }; 83 | 84 | // This singleton allows to retrieve the driver set has default from the manager 85 | $this->app->singleton('image-transformer.driver', function ($app) { 86 | return $app['image-transformer']->driver(); 87 | }); 88 | 89 | $this->app->instance('Storyblok\Client', $client); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Support/ImageTransformation.php: -------------------------------------------------------------------------------- 1 | $transformation[$offset]); 23 | } 24 | 25 | /** 26 | * @param $offset 27 | * @return mixed|null 28 | */ 29 | public function offsetGet($offset): mixed 30 | { 31 | return $this->transformation[$offset] ?? null; 32 | } 33 | 34 | /** 35 | * @param $offset 36 | * @param $value 37 | * @return void 38 | */ 39 | public function offsetSet($offset, $value): void 40 | { 41 | if (is_null($offset)) { 42 | $this->transformation[] = $value; 43 | } else { 44 | $this->transformation[$offset] = $value; 45 | } 46 | } 47 | 48 | /** 49 | * @param $offset 50 | * @return void 51 | */ 52 | public function offsetUnset($offset): void 53 | { 54 | unset($this->transformation[$offset]); 55 | } 56 | 57 | /** 58 | * Allows direct access to the Image Transformer object and it’s __toString 59 | * 60 | * @return string 61 | */ 62 | public function __toString(): string 63 | { 64 | return (string) $this->transformation['src']; 65 | } 66 | } -------------------------------------------------------------------------------- /src/Support/ImageTransformers/BaseTransformer.php: -------------------------------------------------------------------------------- 1 | null, 16 | 'width' => null, 17 | 'extension' => null, 18 | 'mime' => null, 19 | ]; 20 | 21 | /** 22 | * Stores all the transformations 23 | * 24 | * @var array 25 | */ 26 | protected array $transformations = []; 27 | 28 | /** 29 | * @return string The transformed image URL 30 | */ 31 | abstract public function buildUrl(): string; 32 | 33 | 34 | /** 35 | * Extracts meta details for the current image such as width 36 | * and height, mime and anything else of use 37 | * 38 | * @return void 39 | */ 40 | abstract protected function extractMetaDetails(): void; 41 | 42 | 43 | /** 44 | * @param Image $image 45 | */ 46 | public function __construct(protected Image $image) { 47 | 48 | if (method_exists('preprocess', $this)) { 49 | $this->preprocess(); 50 | } 51 | } 52 | 53 | /** 54 | * Returns the width of the transformed image. Optionally you 55 | * can request the original width 56 | * 57 | * @param bool $original 58 | * @return int 59 | */ 60 | public function width(bool $original = false): int 61 | { 62 | if ($original) { 63 | return (int) $this->meta['width']; 64 | } 65 | 66 | // the width was not set so we need to compute it 67 | if (array_key_exists('width', $this->transformations) && $this->transformations['width'] === 0) { 68 | $scalePercent = round(($this->height() / $this->height(true) * 100), 2); 69 | 70 | return round((int) $this->meta['width'] / 100 * $scalePercent); 71 | } 72 | 73 | return $this->transformations['width'] ?? (int) $this->meta['width']; 74 | } 75 | 76 | /** 77 | * Returns the height of the transformed image. Optionally you 78 | * can request the original height 79 | * 80 | * @param bool $original 81 | * @return int 82 | */ 83 | public function height(bool $original = false): ?int 84 | { 85 | if ($original) { 86 | return (int) $this->meta['height']; 87 | } 88 | 89 | // the height was not set so we need to compute it 90 | if (array_key_exists('height', $this->transformations) && $this->transformations['height'] === 0) { 91 | $scalePercent = round(($this->width() / $this->width(true) * 100), 2); 92 | 93 | return round((int) $this->meta['height'] / 100 * $scalePercent); 94 | } 95 | 96 | 97 | return $this->transformations['height'] ?? $this->meta['height']; 98 | } 99 | 100 | /** 101 | * Returns the mime of the transformed image. Optionally you 102 | * can request the original mime 103 | * 104 | * @param bool $original 105 | * @return string 106 | */ 107 | public function mime(bool $original = false): ?string 108 | { 109 | if ($original) { 110 | return $this->meta['mime']; 111 | } 112 | 113 | return $this->transformations['mime'] ?? $this->meta['mime']; 114 | } 115 | 116 | /** 117 | * Returns the mime from a particular file extension 118 | * 119 | * @param $extension 120 | * @return string 121 | */ 122 | protected function setMime($extension): string 123 | { 124 | return $extension === 'jpg' ? 'image/jpeg' : 'image/' . $extension; 125 | } 126 | 127 | /** 128 | * Casts the image transformation as a sting using the 129 | * buildUrl method 130 | * 131 | * @return string 132 | */ 133 | public function __toString(): string 134 | { 135 | return $this->buildUrl(); 136 | } 137 | } -------------------------------------------------------------------------------- /src/Support/ImageTransformers/Imgix.php: -------------------------------------------------------------------------------- 1 | transformations = array_merge($this->transformations, [ 21 | 'w' => $width, 22 | 'h' => $height, 23 | ]); 24 | 25 | return $this; 26 | } 27 | 28 | /** 29 | * Fit the image in the given dimensions 30 | * 31 | * @param string $mode 32 | * @param array $options 33 | * @return $this 34 | */ 35 | public function fit(string $mode, array $options = []): self 36 | { 37 | $this->transformations = array_merge($this->transformations, [ 38 | 'fit' => $mode 39 | ]); 40 | 41 | $this->transformations = array_merge($this->transformations, $options); 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Specify the crop type to use for the image 48 | * 49 | * @param string $mode 50 | * @param array $options 51 | * @return $this 52 | */ 53 | public function crop(string $mode, array $options = []): self 54 | { 55 | $this->transformations = array_merge($this->transformations, [ 56 | 'crop' => $mode 57 | ]); 58 | 59 | $this->transformations = array_merge($this->transformations, $options); 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Set the image format you want returned 66 | * 67 | * @param string $format 68 | * @param int|null $quality 69 | * @return $this 70 | */ 71 | public function format(string $format, int $quality = null): self 72 | { 73 | if ($format === 'auto') { 74 | $this->transformations = array_merge($this->transformations, [ 75 | 'auto' => 'format', 76 | ]); 77 | } else { 78 | $this->transformations = array_merge($this->transformations, [ 79 | 'fm' => $format, 80 | ]); 81 | 82 | if ($quality !== null) { 83 | $this->transformations = array_merge($this->transformations, [ 84 | 'q' => $quality, 85 | ]); 86 | } 87 | } 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Manually set any options you want for the transformation as 94 | * and array of key value pairs 95 | * 96 | * @param array $options 97 | * @return $this 98 | */ 99 | public function options(array $options): self 100 | { 101 | $this->transformations = array_merge($this->transformations, $options); 102 | 103 | return $this; 104 | } 105 | 106 | 107 | /** 108 | * Returns an imgix URL using their builder 109 | * 110 | * @return string 111 | */ 112 | public function buildUrl(): string 113 | { 114 | $builder = new UrlBuilder(config('storyblok.imgix_domain')); 115 | $builder->setUseHttps(true); 116 | $builder->setSignKey(config('storyblok.imgix_token')); 117 | 118 | return $builder->createURL($this->image->content()['filename'], $this->transformations); 119 | } 120 | 121 | /** 122 | * Gets the image meta from the given Storyblok URL 123 | * 124 | * @return void 125 | */ 126 | protected function extractMetaDetails(): void 127 | { 128 | $path = $this->image->content()['filename']; 129 | 130 | preg_match_all('/(?\d+)x(?\d+).+\.(?[a-z]{3,4})/mi', $path, $dimensions, PREG_SET_ORDER, 0); 131 | 132 | if ($dimensions) { 133 | if (Str::endsWith(strtolower($this->image->content()['filename']), '.svg')) { 134 | $this->meta = [ 135 | 'height' => $dimensions[0]['height'], 136 | 'width' => $dimensions[0]['width'], 137 | 'extension' => 'svg', 138 | 'mime' => 'image/svg+xml', 139 | ]; 140 | } else { 141 | $this->meta = [ 142 | 'height' => $dimensions[0]['height'], 143 | 'width' => $dimensions[0]['width'], 144 | 'extension' => strtolower($dimensions[0]['extension']), 145 | 'mime' => $this->setMime(strtolower($dimensions[0]['extension'])), 146 | ]; 147 | } 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Support/ImageTransformers/Storyblok.php: -------------------------------------------------------------------------------- 1 | extractMetaDetails(); 19 | 20 | return $this; 21 | } 22 | 23 | /** 24 | * Resizes the image and sets the focal point 25 | * 26 | * @param int $width 27 | * @param int $height 28 | * @param string|null $focus 29 | * @return $this 30 | */ 31 | public function resize(int $width = 0, int $height = 0, string $focus = null): self 32 | { 33 | $this->transformations = array_merge($this->transformations, [ 34 | 'width' => $width, 35 | 'height' => $height, 36 | ]); 37 | 38 | if ($focus) { 39 | if ($focus === 'auto') { 40 | if ($this->image->focus) { 41 | $focus = 'focal-point'; 42 | } else { 43 | $focus = 'smart'; 44 | } 45 | } 46 | 47 | $this->transformations = array_merge($this->transformations, [ 48 | 'focus' => $focus, 49 | ]); 50 | } 51 | 52 | return $this; 53 | } 54 | 55 | /** 56 | * Fits the image in the given width and height 57 | * 58 | * @param int $width 59 | * @param int $height 60 | * @param string $fill 61 | * @return $this 62 | */ 63 | public function fitIn(int $width = 0, int $height = 0, string $fill = 'transparent'): self 64 | { 65 | $this->transformations = array_merge($this->transformations, [ 66 | 'width' => $width, 67 | 'height' => $height, 68 | 'fill' => $fill, 69 | 'fit-in' => true, 70 | ]); 71 | 72 | // has to be an image that supports transparency 73 | if ($fill === 'transparent') { 74 | $this->format('webp'); 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Set the image format you want returned 82 | * 83 | * @param string $format 84 | * @param int|null $quality 85 | * @return $this 86 | */ 87 | public function format(string $format, int $quality = null): self 88 | { 89 | $this->transformations = array_merge($this->transformations, [ 90 | 'format' => $format, 91 | 'mime' => $this->setMime($format), 92 | ]); 93 | 94 | if ($quality !== null) { 95 | $this->transformations = array_merge($this->transformations, [ 96 | 'quality' => $quality, 97 | ]); 98 | } 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * Make the image greyscale 105 | * 106 | * @return $this 107 | */ 108 | public function grayscale(): self 109 | { 110 | $this->transformations = array_merge($this->transformations, [ 111 | 'grayscale' => true, 112 | ]); 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Set the image blur amount 119 | * 120 | * @param int $amount 121 | * @return $this 122 | */ 123 | public function blur(int $amount): self 124 | { 125 | $this->transformations = array_merge($this->transformations, [ 126 | 'blur' => $amount, 127 | ]); 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * Set the image brightness, use negative values to darken 134 | * 135 | * @param int $amount 136 | * @return $this 137 | */ 138 | public function brightness(int $amount): self 139 | { 140 | $this->transformations = array_merge($this->transformations, [ 141 | 'brightness' => $amount, 142 | ]); 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * Rotate the image, the allowed values are 90, 180 and 270 149 | * 150 | * @param int $amount 151 | * @return $this 152 | * @throws \Exception 153 | */ 154 | public function rotate(int $amount): self 155 | { 156 | $allowedRotations = [90, 180, 270]; 157 | 158 | if (!in_array($amount, $allowedRotations)) { 159 | throw new \Exception('Invalid rotation amount. Must be 90, 180 or 270'); 160 | } 161 | 162 | $this->transformations = array_merge($this->transformations, [ 163 | 'rotate' => $amount, 164 | ]); 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Zooms and crops an image based on focal point. If there is no focal point it will use the center of the image 171 | * 172 | * @param int $zoomLevel 173 | * @param int $width 174 | * @param int $height 175 | * @return string 176 | */ 177 | public function zoomCrop(int $zoomLevel, int $width, int $height): string 178 | { 179 | if ($this->transformations === 'svg') { 180 | return $this->assetDomain($this->image->content()['filename']); 181 | } 182 | 183 | if ($this->width() >= $this->height()) { 184 | $cropBuffer = $this->width() * 100 / $zoomLevel; 185 | } else { 186 | $cropBuffer = $this->height() * 100 / $zoomLevel; 187 | } 188 | 189 | if ($this->image->focus) { 190 | $focalPointCoords = explode('x', explode(':', $this->image->focus)[0]); 191 | $focalPoint = [$focalPointCoords[0], $focalPointCoords[1]]; 192 | } else { 193 | $focalPoint = [$this->width() / 2, $this->height() / 2]; 194 | } 195 | 196 | $cropLeft = max(round($focalPoint[0] - $cropBuffer / 2), 0); 197 | $cropTop = max(round($focalPoint[1] - $cropBuffer / 2), 0); 198 | $cropRight = min(round($cropLeft + $cropBuffer), $this->width()); 199 | $cropBottom = min(round($cropTop + $cropBuffer), $this->height()); 200 | 201 | $croppedUrl = '/' . $cropLeft . "x" . $cropTop . ":" . $cropRight . "x" . $cropBottom . "/" . $width . "x" . $height; 202 | 203 | 204 | if ($this->hasFilters()) { 205 | $croppedUrl .= $this->applyFilters(); 206 | } 207 | 208 | return $this->assetDomain($croppedUrl); 209 | } 210 | 211 | /** 212 | * Creates the Storyblok image service URL 213 | * 214 | * @return string 215 | */ 216 | public function buildUrl(): string 217 | { 218 | if ($this->transformations === 'svg') { 219 | return $this->assetDomain($this->image->content()['filename']); 220 | } 221 | 222 | $transforms = ''; 223 | 224 | if (array_key_exists('fit-in', $this->transformations)) { 225 | $transforms .= '/fit-in'; 226 | } 227 | 228 | if (array_key_exists('width', $this->transformations)) { 229 | $transforms .= '/' . $this->transformations['width'] . 'x' . $this->transformations['height']; 230 | } 231 | 232 | if (array_key_exists('focus', $this->transformations) && $this->transformations['focus'] === 'smart') { 233 | $transforms .= '/smart'; 234 | } 235 | 236 | if ($this->hasFilters()) { 237 | $transforms .= $this->applyFilters(); 238 | } 239 | 240 | return $this->assetDomain($transforms); 241 | } 242 | 243 | /** 244 | * Checks if any filters were applied to the transformation 245 | * 246 | * @return bool 247 | */ 248 | protected function hasFilters(): bool 249 | { 250 | $keys = ['format', 'quality', 'fill', 'focus', 'blur', 'brightness', 'rotate', 'grayscale']; 251 | 252 | foreach ($keys as $key) { 253 | if (array_key_exists($key, $this->transformations)) { 254 | if ($key === 'focus' && $this->transformations['focus'] !== 'focal-point') { 255 | continue; 256 | } 257 | return true; 258 | } 259 | } 260 | 261 | return false; 262 | } 263 | 264 | /** 265 | * Applies the filters to the image service URL 266 | * 267 | * @return string 268 | */ 269 | protected function applyFilters(): string 270 | { 271 | $filters = ''; 272 | 273 | if (array_key_exists('filters', $this->transformations)) { 274 | dd('ff'); 275 | } 276 | 277 | if (array_key_exists('format', $this->transformations)) { 278 | $filters .= ':format(' . $this->transformations['format'] . ')'; 279 | } 280 | 281 | if (array_key_exists('quality', $this->transformations)) { 282 | $filters .= ':quality(' . $this->transformations['quality'] . ')'; 283 | } 284 | 285 | if (array_key_exists('fill', $this->transformations)) { 286 | $filters .= ':fill(' . $this->transformations['fill'] . ')'; 287 | } 288 | 289 | if (array_key_exists('focus', $this->transformations) && $this->transformations['focus'] === 'focal-point' && $this->image->content()['focus']) { 290 | $filters .= ':focal(' . $this->image->content()['focus'] . ')'; 291 | } 292 | 293 | if (array_key_exists('blur', $this->transformations)) { 294 | $filters .= ':blur(' . $this->transformations['blur'] . ')'; 295 | } 296 | 297 | if (array_key_exists('brightness', $this->transformations)) { 298 | $filters .= ':brightness(' . $this->transformations['brightness'] . ')'; 299 | } 300 | 301 | if (array_key_exists('rotate', $this->transformations)) { 302 | $filters .= ':rotate(' . $this->transformations['rotate'] . ')'; 303 | } 304 | 305 | if (array_key_exists('grayscale', $this->transformations)) { 306 | $filters .= ':grayscale()'; 307 | } 308 | 309 | if ($filters) { 310 | $filters = '/filters' . $filters; 311 | } 312 | 313 | return $filters; 314 | } 315 | 316 | /** 317 | * Extracts meta details from the image. With Storyblok we can get a 318 | * few things from the URL 319 | * 320 | * @return void 321 | */ 322 | protected function extractMetaDetails(): void 323 | { 324 | $path = $this->image->content()['filename']; 325 | 326 | preg_match_all('/(?\d+)x(?\d+).+\.(?[a-z]{3,4})/mi', $path, $dimensions, PREG_SET_ORDER, 0); 327 | 328 | if ($dimensions) { 329 | if (Str::endsWith(strtolower($this->image->content()['filename']), '.svg')) { 330 | $this->meta = [ 331 | 'height' => $dimensions[0]['height'], 332 | 'width' => $dimensions[0]['width'], 333 | 'extension' => 'svg', 334 | 'mime' => 'image/svg+xml', 335 | ]; 336 | } else { 337 | $this->meta = [ 338 | 'height' => $dimensions[0]['height'], 339 | 'width' => $dimensions[0]['width'], 340 | 'extension' => strtolower($dimensions[0]['extension']), 341 | 'mime' => $this->setMime(strtolower($dimensions[0]['extension'])), 342 | ]; 343 | } 344 | } 345 | } 346 | 347 | /** 348 | * Sets the asset domain 349 | * 350 | * @param $options 351 | * @return string 352 | */ 353 | protected function assetDomain($options = null): string 354 | { 355 | $resource = str_replace(config('storyblok.asset_domain'), config('storyblok.image_service_domain'), $this->image->content()['filename']); 356 | 357 | if ($options) { 358 | return $resource . '/m' . $options; 359 | } 360 | 361 | return $resource; 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/Support/ImageTransformers/StoryblokLegacy.php: -------------------------------------------------------------------------------- 1 | image->content()['filename']); 16 | return '//' . config('storyblok.image_service_domain') . $options . $resource; 17 | } 18 | } -------------------------------------------------------------------------------- /src/Support/ImageTransformers/StoryblokSvg.php: -------------------------------------------------------------------------------- 1 | assetDomain(); 11 | } 12 | 13 | protected function extractMetaDetails(): void 14 | { 15 | $path = $this->image->content()['filename']; 16 | 17 | preg_match_all('/(?\d+)x(?\d+).+\.(?[a-z]{3,4})/mi', $path, $dimensions, PREG_SET_ORDER, 0); 18 | 19 | $this->meta = [ 20 | 'height' => $dimensions[0]['height'], 21 | 'width' => $dimensions[0]['width'], 22 | 'extension' => 'svg', 23 | 'mime' => 'image/svg+xml', 24 | ]; 25 | } 26 | 27 | public function resize(int $width, int $height = null): static 28 | { 29 | return $this; 30 | } 31 | 32 | public function fitIn(int $width = 0, int $height = 0, string $fill = 'transparent'): static 33 | { 34 | return $this; 35 | } 36 | 37 | public function format(string $format, int $quality = null): static 38 | { 39 | return $this; 40 | } 41 | 42 | /** 43 | * Sets the asset domain 44 | * 45 | * @param $options 46 | * @return string 47 | */ 48 | protected function assetDomain($options = null): string 49 | { 50 | $resource = str_replace(config('storyblok.asset_domain'), config('storyblok.image_service_domain'), $this->image->content()['filename']); 51 | 52 | return $resource; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Support/NullPage.php: -------------------------------------------------------------------------------- 1 | _meta)) { 25 | return $this->_meta[$key]; 26 | } 27 | 28 | return $default; 29 | } 30 | 31 | return $this->_meta; 32 | } 33 | 34 | /** 35 | * Adds items to the meta content optionally replacing existing keys 36 | * 37 | * @param $fields 38 | */ 39 | public function addMeta($fields, $replace = false): void 40 | { 41 | if ($replace) { 42 | $this->_meta = array_merge($this->_meta, $fields); 43 | } 44 | $this->_meta = array_merge($fields, $this->_meta); 45 | } 46 | 47 | /** 48 | * Replaces a meta item 49 | * 50 | * @param $key 51 | * @param $value 52 | */ 53 | public function replaceMeta($key, $value): void 54 | { 55 | $this->_meta[$key] = $value; 56 | } 57 | 58 | /** 59 | * Returns the UUID of the Block 60 | * @return string 61 | */ 62 | public function uuid(): string 63 | { 64 | return $this->meta('_uid'); 65 | } 66 | } -------------------------------------------------------------------------------- /src/Traits/HasSettings.php: -------------------------------------------------------------------------------- 1 | _settings = collect($content[config('storyblok.settings_field')]) 22 | ->keyBy(fn($setting) => Str::slug($setting['component'], '_')) 23 | ->map(fn($setting) => collect(array_diff_key($setting, array_flip(['_editable', '_uid', 'component']))) 24 | ->map(function ($setting) { 25 | if ($this->isCommaSeparatedList($setting)) { 26 | return $this->isCommaSeparatedList($setting); 27 | } 28 | 29 | return $setting; 30 | }) 31 | ); 32 | } 33 | 34 | // remove the processed item for future items 35 | unset($content[config('storyblok.settings_field')]); 36 | 37 | return $content; 38 | } 39 | 40 | /** 41 | * @param $string 42 | * @return array|false|int[]|string[] 43 | */ 44 | protected function isCommaSeparatedList($string): array|bool 45 | { 46 | if (!preg_match('/^[\w]+(,[\w]*)+$/', $string)) { 47 | return false; 48 | } 49 | 50 | return array_map(function($item) { 51 | $item = trim($item); 52 | 53 | // return $item as a int if it is a string of an int 54 | if (preg_match('/^\d+$/', $item)) { 55 | return (int) $item; 56 | } 57 | 58 | return $item; 59 | }, explode(',', $string)); 60 | } 61 | 62 | /** 63 | * @param $setting 64 | * @return mixed 65 | */ 66 | public function settings($setting = null): mixed 67 | { 68 | if ($setting) { 69 | return $this->_settings[$setting]; 70 | } 71 | 72 | return $this->_settings; 73 | } 74 | 75 | /** 76 | * @param $setting 77 | * @return false|mixed 78 | */ 79 | public function hasSetting($setting): mixed 80 | { 81 | if ($this->_settings?->has($setting)) { 82 | return $this->_settings[$setting]; 83 | } 84 | 85 | return false; 86 | } 87 | 88 | /** 89 | * @return false|mixed 90 | */ 91 | public function hasSettings(): mixed 92 | { 93 | return $this->_settings; 94 | } 95 | } -------------------------------------------------------------------------------- /src/Traits/SchemaOrg.php: -------------------------------------------------------------------------------- 1 | page(); 21 | } 22 | 23 | if ($page && count($this->_componentPath) <= config('storyblok.schema_org_depth')) { 24 | $this->addschemaOrg($page); 25 | } 26 | } 27 | 28 | } 29 | 30 | /** 31 | * Returns the JavaScript JSON-LD string 32 | * 33 | * @return string 34 | */ 35 | public function schemaOrgScript(): string 36 | { 37 | $schemaJson = ''; 38 | 39 | if (array_key_exists('schema_org', $this->meta())) { 40 | foreach ($this->meta()['schema_org'] as $schema) { 41 | $schemaJson .= $schema->toScript(); 42 | } 43 | } 44 | 45 | return $schemaJson; 46 | 47 | } 48 | 49 | /** 50 | * Adds the schema to the meta of the current page 51 | * 52 | * @param $page 53 | */ 54 | protected function addschemaOrg($page): void 55 | { 56 | $currentSchemaOrg = $page->meta('schema_org'); 57 | 58 | if ($schema = $this->schemaOrg()) { 59 | $currentSchemaOrg[] = $schema; 60 | } 61 | 62 | $page->replaceMeta('schema_org', $currentSchemaOrg ?? []); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/resources/views/editor-bridge.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @if (config('storyblok.edit_mode')) 3 | 4 | 5 | 6 | 86 | @endif 87 | -------------------------------------------------------------------------------- /src/resources/views/embeds/youtube.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | {!! $embed->code !!} 4 |
-------------------------------------------------------------------------------- /src/resources/views/picture-element.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @foreach($transformations as $key => $transformation) 3 | @if ($default !== $key) 4 | 5 | @endif 6 | @endforeach 7 | 8 | {{ $alt }} $value) {{$attribute}}="{{$value}}" @endforeach> 9 | -------------------------------------------------------------------------------- /src/resources/views/srcset.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{ $alt }} $value) {{$attribute}}="{{$value}}" @endforeach> -------------------------------------------------------------------------------- /src/routes/api.php: -------------------------------------------------------------------------------- 1 | name('storyblok.clear-cache'); 20 | 21 | Route::post('/api/laravel-storyblok/webhook/publish', WebhookController::class . '@publish'); -------------------------------------------------------------------------------- /stubs/Asset.stub: -------------------------------------------------------------------------------- 1 |