Repository: GoogleForCreators/web-stories-wp Branch: main Commit: 65b196460fbf Files: 4493 Total size: 25.1 MB Directory structure: gitextract_qcnlwv5k/ ├── .allstar/ │ └── branch_protection.yaml ├── .browserslistrc ├── .distignore ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .git-blame-ignore-revs ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ ├── enhancement.md │ │ ├── epic.md │ │ └── task.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── SUPPORT.md │ ├── dependabot.yml │ ├── release.yml │ └── workflows/ │ ├── build-and-deploy.yml │ ├── codeql-analysis.yml │ ├── deploy-storybook.yml │ ├── lint-css-js-md.yml │ ├── lint-i18n.yml │ ├── lint-php.yml │ ├── lint-plugin-check.yml │ ├── npm-release.yml │ ├── plugin-release.yml │ ├── scorecards.yml │ ├── tests-e2e.yml │ ├── tests-karma-dashboard.yml │ ├── tests-karma-editor.yml │ ├── tests-unit-js.yml │ ├── tests-unit-php.yml │ ├── update-browserslist.yml │ ├── update-google-fonts.yml │ ├── update-product-schema.yml │ └── update-templates.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .markdownlint.json ├── .markdownlintignore ├── .npmpackagejsonlintrc.json ├── .npmrc ├── .nvmrc ├── .oxlintrc.json ├── .phpstorm.config.js ├── .phpstorm.meta.php ├── .prettierignore ├── .prettierrc ├── .storybook/ │ ├── main.cjs │ ├── manager-head.html │ ├── preview-head.html │ ├── preview.js │ └── stories/ │ └── playground/ │ ├── dashboard/ │ │ ├── index.js │ │ └── theme.js │ └── story-editor/ │ ├── api/ │ │ ├── fonts.js │ │ ├── index.js │ │ ├── media.js │ │ └── story.js │ ├── constants.js │ ├── getDummyMedia.js │ ├── header/ │ │ ├── buttons/ │ │ │ ├── index.js │ │ │ ├── preview.js │ │ │ └── saveButton.js │ │ └── index.js │ ├── index.js │ └── preview.js ├── .stylelintignore ├── .stylelintrc ├── .wordpress-org/ │ ├── README.md │ └── blueprints/ │ └── blueprint.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── __mocks__/ │ ├── colorthief.js │ ├── node:fs.js │ └── react-moveable.js ├── __static__/ │ ├── README.md │ ├── asteroid.ogg │ ├── beach.webm │ └── ranger9.ogg ├── babel.config.cjs ├── bin/ │ ├── deploy-to-test-environment.sh │ ├── install-wp-tests.sh │ ├── local-env/ │ │ ├── docker-compose.yml │ │ ├── includes.sh │ │ ├── install-wordpress.sh │ │ ├── launch-containers.sh │ │ ├── start.sh │ │ ├── stop.sh │ │ └── uploads.ini │ ├── schemas/ │ │ └── story.json │ ├── setup-local-npm-registry.sh │ ├── stop-local-npm-registry.sh │ └── verdaccio-config.yml ├── blocks/ │ └── embed/ │ └── block.json ├── codecov.yml ├── composer.json ├── docs/ │ ├── README.md │ ├── accessibility-guidelines.md │ ├── accessibility-testing.md │ ├── animations.md │ ├── architecture.md │ ├── browser-support.md │ ├── canvas.md │ ├── cdn.md │ ├── checklist.md │ ├── code-style.md │ ├── design-docs.md │ ├── design-panel-push-update-flow.md │ ├── design-system.md │ ├── devtools.md │ ├── e2e-tests.md │ ├── environment-variables.md │ ├── external-template-creation.md │ ├── feature-flags.md │ ├── getting-started.md │ ├── glossary.md │ ├── integration-tests.md │ ├── local-environment.md │ ├── migrations.md │ ├── onboarding.md │ ├── page-templates.md │ ├── performance.md │ ├── quick-actions.md │ ├── right-click-menu.md │ ├── storybook.md │ ├── svgs.md │ ├── testing-environments.md │ ├── testing-qa.md │ ├── third-party-integration/ │ │ ├── dashboard/ │ │ │ ├── README.md │ │ │ ├── api-callbacks.md │ │ │ ├── getting-started.md │ │ │ ├── integration-layer.md │ │ │ └── tutorial.md │ │ └── story-editor/ │ │ ├── README.md │ │ ├── api-callbacks.md │ │ ├── getting-started.md │ │ ├── integration-layer.md │ │ └── tutorial.md │ ├── unit-tests.md │ ├── web-stories-embeds.md │ └── workflows.md ├── includes/ │ ├── AMP/ │ │ ├── Canonical_Sanitizer.php │ │ ├── Integration/ │ │ │ └── AMP_Story_Sanitizer.php │ │ ├── Meta_Sanitizer.php │ │ ├── Optimization.php │ │ ├── Output_Buffer.php │ │ ├── Sanitization.php │ │ ├── Story_Sanitizer.php │ │ ├── Tag_And_Attribute_Sanitizer.php │ │ └── Traits/ │ │ └── Sanitization_Utils.php │ ├── AMP_Story_Player_Assets.php │ ├── AdSense.php │ ├── Ad_Manager.php │ ├── Admin/ │ │ ├── Activation_Notice.php │ │ ├── Admin.php │ │ ├── Cross_Origin_Isolation.php │ │ ├── Customizer.php │ │ ├── Dashboard.php │ │ ├── Editor.php │ │ ├── Google_Fonts.php │ │ ├── Meta_Boxes.php │ │ ├── PluginActionLinks.php │ │ ├── PluginRowMeta.php │ │ ├── Site_Health.php │ │ └── TinyMCE.php │ ├── Analytics.php │ ├── Assets.php │ ├── Block/ │ │ └── Web_Stories_Block.php │ ├── Context.php │ ├── Database_Upgrader.php │ ├── Decoder.php │ ├── Demo_Content.php │ ├── Discovery.php │ ├── Embed_Base.php │ ├── Exception/ │ │ ├── FailedToMakeInstance.php │ │ ├── InvalidEventProperties.php │ │ ├── InvalidService.php │ │ ├── SanitizationException.php │ │ └── WebStoriesException.php │ ├── Experiments.php │ ├── Font_Post_Type.php │ ├── Infrastructure/ │ │ ├── Conditional.php │ │ ├── Delayed.php │ │ ├── HasMeta.php │ │ ├── HasRequirements.php │ │ ├── Injector/ │ │ │ ├── FallbackInstantiator.php │ │ │ ├── InjectionChain.php │ │ │ └── SimpleInjector.php │ │ ├── Injector.php │ │ ├── Instantiator.php │ │ ├── Plugin.php │ │ ├── PluginActivationAware.php │ │ ├── PluginDeactivationAware.php │ │ ├── PluginUninstallAware.php │ │ ├── Registerable.php │ │ ├── Service.php │ │ ├── ServiceBasedPlugin.php │ │ ├── ServiceContainer/ │ │ │ ├── LazilyInstantiatedService.php │ │ │ └── SimpleServiceContainer.php │ │ ├── ServiceContainer.php │ │ ├── SiteInitializationAware.php │ │ └── SiteRemovalAware.php │ ├── Integrations/ │ │ ├── AMP.php │ │ ├── Conditional_Featured_Image.php │ │ ├── Core_Themes_Support.php │ │ ├── Ezoic.php │ │ ├── Jetpack.php │ │ ├── New_Relic.php │ │ ├── NextGen_Gallery.php │ │ ├── Plugin_Status.php │ │ ├── ShortPixel.php │ │ ├── Site_Kit.php │ │ └── WooCommerce.php │ ├── Interfaces/ │ │ ├── Field.php │ │ ├── FieldState.php │ │ ├── FieldStateFactory.php │ │ ├── Migration.php │ │ ├── Product_Query.php │ │ └── Renderer.php │ ├── KSES.php │ ├── Locale.php │ ├── Media/ │ │ ├── Base_Color.php │ │ ├── Blurhash.php │ │ ├── Cropping.php │ │ ├── Image_Sizes.php │ │ ├── Media_Source_Taxonomy.php │ │ ├── SVG.php │ │ ├── Types.php │ │ └── Video/ │ │ ├── Captions.php │ │ ├── Is_Gif.php │ │ ├── Muting.php │ │ ├── Optimization.php │ │ ├── Poster.php │ │ └── Trimming.php │ ├── Mgid.php │ ├── Migrations/ │ │ ├── Add_Media_Source.php │ │ ├── Add_Media_Source_Editor.php │ │ ├── Add_Media_Source_Gif_Conversion.php │ │ ├── Add_Media_Source_Page_Template.php │ │ ├── Add_Media_Source_Recording.php │ │ ├── Add_Media_Source_Source_Image.php │ │ ├── Add_Media_Source_Source_Video.php │ │ ├── Add_Media_Source_Video_Optimization.php │ │ ├── Add_Poster_Generation_Media_Source.php │ │ ├── Add_Stories_Caps.php │ │ ├── Add_VideoPress_Poster_Generation_Media_Source.php │ │ ├── Migrate_Base.php │ │ ├── Migration_Meta_To_Term.php │ │ ├── Remove_Broken_Text_Styles.php │ │ ├── Remove_Incorrect_Tracking_Id.php │ │ ├── Remove_Unneeded_Attachment_Meta.php │ │ ├── Replace_Conic_Style_Presets.php │ │ ├── Rewrite_Flush.php │ │ ├── Set_Legacy_Analytics_Usage_Flag.php │ │ ├── Unify_Color_Presets.php │ │ ├── Update_1.php │ │ └── Update_Publisher_Logos.php │ ├── Model/ │ │ └── Story.php │ ├── Page_Template_Post_Type.php │ ├── Plugin.php │ ├── PluginFactory.php │ ├── Post_Type_Base.php │ ├── REST_API/ │ │ ├── Embed_Controller.php │ │ ├── Font_Controller.php │ │ ├── Hotlinking_Controller.php │ │ ├── Link_Controller.php │ │ ├── Page_Template_Controller.php │ │ ├── Products_Controller.php │ │ ├── Publisher_Logos_Controller.php │ │ ├── REST_Controller.php │ │ ├── Status_Check_Controller.php │ │ ├── Stories_Autosaves_Controller.php │ │ ├── Stories_Base_Controller.php │ │ ├── Stories_Controller.php │ │ ├── Stories_Lock_Controller.php │ │ ├── Stories_Media_Controller.php │ │ ├── Stories_Settings_Controller.php │ │ ├── Stories_Taxonomies_Controller.php │ │ ├── Stories_Terms_Controller.php │ │ └── Stories_Users_Controller.php │ ├── Register_Widget.php │ ├── Remove_Transients.php │ ├── Renderer/ │ │ ├── Archives.php │ │ ├── Feed.php │ │ ├── Oembed.php │ │ ├── Single.php │ │ ├── Stories/ │ │ │ ├── Carousel_Renderer.php │ │ │ ├── FieldState/ │ │ │ │ ├── BaseFieldState.php │ │ │ │ ├── CarouselView.php │ │ │ │ ├── CircleView.php │ │ │ │ ├── GridView.php │ │ │ │ └── ListView.php │ │ │ ├── FieldStateFactory/ │ │ │ │ └── Factory.php │ │ │ ├── Fields/ │ │ │ │ └── BaseField.php │ │ │ ├── Generic_Renderer.php │ │ │ └── Renderer.php │ │ └── Story/ │ │ ├── Embed.php │ │ ├── HTML.php │ │ ├── Image.php │ │ └── Singleton.php │ ├── Service_Base.php │ ├── Services.php │ ├── Settings.php │ ├── Shopping/ │ │ ├── Product.php │ │ ├── Product_Meta.php │ │ ├── Shopify_Query.php │ │ ├── Shopping_Vendors.php │ │ └── WooCommerce_Query.php │ ├── Shortcode/ │ │ ├── Embed_Shortcode.php │ │ └── Stories_Shortcode.php │ ├── Stories_Script_Data.php │ ├── Story_Archive.php │ ├── Story_Post_Type.php │ ├── Story_Query.php │ ├── Story_Revisions.php │ ├── Taxonomy/ │ │ ├── Category_Taxonomy.php │ │ ├── Tag_Taxonomy.php │ │ └── Taxonomy_Base.php │ ├── Tracking.php │ ├── User/ │ │ ├── Capabilities.php │ │ └── Preferences.php │ ├── Widgets/ │ │ └── Stories.php │ ├── compat/ │ │ ├── Web_Stories_Compatibility.php │ │ └── amp.php │ ├── data/ │ │ ├── fonts/ │ │ │ └── fonts.json │ │ └── stories/ │ │ └── demo.json │ ├── functions.php │ ├── namespace.php │ ├── polyfills/ │ │ └── mbstring.php │ └── templates/ │ ├── admin/ │ │ ├── activation-notice.php │ │ ├── dashboard.php │ │ ├── edit-story.php │ │ └── experiments.php │ └── frontend/ │ ├── embed-web-story.php │ └── single-web-story.php ├── jest-puppeteer.config.cjs ├── jsconfig.json ├── karma-dashboard.config.cjs ├── karma-story-editor.config.cjs ├── package.json ├── packages/ │ ├── activation-notice/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── components/ │ │ │ │ │ ├── dismiss.tsx │ │ │ │ │ ├── image.tsx │ │ │ │ │ ├── link.tsx │ │ │ │ │ ├── messageContent.tsx │ │ │ │ │ ├── number.tsx │ │ │ │ │ ├── paragraph.tsx │ │ │ │ │ ├── step1.tsx │ │ │ │ │ ├── step2.tsx │ │ │ │ │ ├── step3.tsx │ │ │ │ │ ├── successMessage.tsx │ │ │ │ │ └── test/ │ │ │ │ │ ├── dismiss.js │ │ │ │ │ ├── step1.js │ │ │ │ │ ├── step2.js │ │ │ │ │ ├── step3.js │ │ │ │ │ └── successMessage.js │ │ │ │ ├── config/ │ │ │ │ │ ├── configProvider.tsx │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useConfig.ts │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── stories/ │ │ │ │ └── index.js │ │ │ ├── testUtils/ │ │ │ │ ├── index.js │ │ │ │ └── renderWithTheme.js │ │ │ ├── theme.ts │ │ │ └── typings/ │ │ │ └── styled-components.d.ts │ │ └── tsconfig.json │ ├── animation/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── AMPAnimations.tsx │ │ │ │ ├── AMPKeyframes.tsx │ │ │ │ ├── AMPWrapper.tsx │ │ │ │ ├── WAAPIWrapper.tsx │ │ │ │ ├── animationMachine.ts │ │ │ │ ├── context.ts │ │ │ │ ├── fullSizeAbsolute.ts │ │ │ │ ├── generateKeyframesMap.ts │ │ │ │ ├── index.ts │ │ │ │ ├── provider.tsx │ │ │ │ ├── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── test/ │ │ │ │ │ ├── WAAPIWrapper.tsx │ │ │ │ │ ├── animationProvider.tsx │ │ │ │ │ └── generateKeyframesMap.ts │ │ │ │ ├── types.ts │ │ │ │ └── useStoryAnimationContext.ts │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── outputs/ │ │ │ │ ├── animationOutput.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── keyframesOutput.tsx │ │ │ │ ├── output.tsx │ │ │ │ └── types.ts │ │ │ ├── parts/ │ │ │ │ ├── createAnimation.tsx │ │ │ │ ├── createAnimationPart.ts │ │ │ │ ├── defaultFields.ts │ │ │ │ ├── effects/ │ │ │ │ │ ├── backgroundPan.ts │ │ │ │ │ ├── backgroundPanAndZoom.ts │ │ │ │ │ ├── backgroundZoom.ts │ │ │ │ │ ├── drop.ts │ │ │ │ │ ├── fadeIn.ts │ │ │ │ │ ├── flyIn.tsx │ │ │ │ │ ├── pan.ts │ │ │ │ │ ├── pulse.ts │ │ │ │ │ ├── rotateIn.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── drop.js │ │ │ │ │ │ ├── fadeIn.js │ │ │ │ │ │ ├── flyIn.js │ │ │ │ │ │ ├── pan.js │ │ │ │ │ │ ├── pulse.js │ │ │ │ │ │ ├── rotateIn.js │ │ │ │ │ │ ├── twirlIn.js │ │ │ │ │ │ ├── whooshIn.js │ │ │ │ │ │ └── zoom.js │ │ │ │ │ ├── twirlIn.tsx │ │ │ │ │ ├── whooshIn.tsx │ │ │ │ │ └── zoom.tsx │ │ │ │ ├── emptyAnimationPart.tsx │ │ │ │ ├── getAnimationEffectFields.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── simple/ │ │ │ │ │ ├── blinkOn.ts │ │ │ │ │ ├── bounce.ts │ │ │ │ │ ├── fade.ts │ │ │ │ │ ├── flip.ts │ │ │ │ │ ├── floatOn.ts │ │ │ │ │ ├── move.ts │ │ │ │ │ ├── pulse.ts │ │ │ │ │ ├── spin.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── blinkOn.js │ │ │ │ │ │ ├── bounce.js │ │ │ │ │ │ ├── fade.js │ │ │ │ │ │ ├── flip.js │ │ │ │ │ │ ├── floatOn.js │ │ │ │ │ │ ├── move.js │ │ │ │ │ │ ├── pulse.js │ │ │ │ │ │ ├── spin.js │ │ │ │ │ │ └── zoom.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── pulse.ts │ │ │ │ │ └── zoom.ts │ │ │ │ └── types.ts │ │ │ ├── storybookUtils/ │ │ │ │ ├── index.js │ │ │ │ └── utils/ │ │ │ │ └── ampBoilerplate.js │ │ │ ├── types/ │ │ │ │ ├── animation.ts │ │ │ │ ├── effects.ts │ │ │ │ ├── element.ts │ │ │ │ ├── index.ts │ │ │ │ ├── keyframes.ts │ │ │ │ ├── propTypes.ts │ │ │ │ └── storyAnimationState.ts │ │ │ ├── typings/ │ │ │ │ └── global.d.ts │ │ │ └── utils/ │ │ │ ├── createKeyframeEffect.ts │ │ │ ├── defaultUnit.ts │ │ │ ├── generateLookupMap.ts │ │ │ ├── getDefaultFieldValue.ts │ │ │ ├── getElementOffsets.ts │ │ │ ├── getGlobalSpace.ts │ │ │ ├── getInitialStyleFromKeyframes.ts │ │ │ ├── getOffPageOffset.ts │ │ │ ├── getTotalDuration.ts │ │ │ ├── index.ts │ │ │ ├── orderByKeys.ts │ │ │ ├── padArray.ts │ │ │ ├── sanitizeTimings.ts │ │ │ └── test/ │ │ │ ├── createKeyframeEffect.ts │ │ │ ├── defaultUnit.ts │ │ │ ├── generateLookupMap.ts │ │ │ ├── getElementOffsets.ts │ │ │ ├── getGlobalSpace.ts │ │ │ ├── getInitialStyleFromKeyframes.ts │ │ │ ├── getOffPageOffset.ts │ │ │ ├── getTotalDuration.ts │ │ │ ├── orderByKeys.ts │ │ │ ├── padArray.ts │ │ │ └── sanitizeTimings.ts │ │ └── tsconfig.json │ ├── commander/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── utils/ │ │ │ ├── appendRevisionToVersion.ts │ │ │ ├── bundlePlugin.ts │ │ │ ├── copyFiles.ts │ │ │ ├── createBuild.ts │ │ │ ├── deleteExistingZipFiles.ts │ │ │ ├── generateZipFile.ts │ │ │ ├── getCurrentVersionNumber.ts │ │ │ ├── getIgnoredFiles.ts │ │ │ ├── index.ts │ │ │ ├── resizeSvgPath.ts │ │ │ ├── test/ │ │ │ │ ├── appendRevisionToVersion.js │ │ │ │ ├── bundlePlugin.js │ │ │ │ ├── copyFiles.js │ │ │ │ ├── createBuild.js │ │ │ │ ├── deleteExistingZipFiles.js │ │ │ │ ├── generateZipFile.js │ │ │ │ ├── getCurrentVersionNumber.js │ │ │ │ ├── getIgnoredFiles.js │ │ │ │ ├── resizeSvgPaths.js │ │ │ │ ├── updateCdnUrl.js │ │ │ │ └── updateVersionNumbers.js │ │ │ ├── updateCdnUrl.ts │ │ │ └── updateVersionNumbers.ts │ │ └── tsconfig.json │ ├── dashboard/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ ├── apiProvider.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── apiProvider.js │ │ │ │ │ ├── useApi.js │ │ │ │ │ ├── useApiAlerts.js │ │ │ │ │ ├── useStoryApi.js │ │ │ │ │ ├── useTaxonomyApi.js │ │ │ │ │ ├── useTemplateApi.js │ │ │ │ │ └── useUsersApi.js │ │ │ │ ├── config/ │ │ │ │ │ ├── configProvider.tsx │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useConfig.ts │ │ │ │ ├── index.js │ │ │ │ ├── reducer/ │ │ │ │ │ ├── story/ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── storyReducer.js │ │ │ │ │ ├── templates/ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── templateReducer.js │ │ │ │ │ └── test/ │ │ │ │ │ ├── story.js │ │ │ │ │ └── templates.js │ │ │ │ ├── router/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ ├── routerProvider.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── route.js │ │ │ │ │ │ ├── router.js │ │ │ │ │ │ └── routerProvider.js │ │ │ │ │ └── useRouteHistory.ts │ │ │ │ ├── serializers/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── templates.js │ │ │ │ │ └── test/ │ │ │ │ │ └── templates.js │ │ │ │ ├── textContent/ │ │ │ │ │ └── index.js │ │ │ │ └── views/ │ │ │ │ ├── apiAlerts/ │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── constants.js │ │ │ │ ├── exploreTemplates/ │ │ │ │ │ ├── content/ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── templateGridItem.js │ │ │ │ │ │ ├── templateGridView.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── content.js │ │ │ │ │ ├── filters/ │ │ │ │ │ │ ├── TemplateFiltersProvider/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── templateFiltersProvider.js │ │ │ │ │ │ └── useTemplateFilters.js │ │ │ │ │ ├── header/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── header.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── exploreTemplates.karma.js │ │ │ │ │ ├── modal/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── templateDetails/ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ ├── content/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── header.js │ │ │ │ │ │ └── karma/ │ │ │ │ │ │ └── templateDetails.karma.js │ │ │ │ │ ├── templateFilters.js │ │ │ │ │ └── test/ │ │ │ │ │ └── templateFilters.js │ │ │ │ ├── filters/ │ │ │ │ │ ├── reducer.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── reducer.js │ │ │ │ │ └── types.js │ │ │ │ ├── index.js │ │ │ │ ├── myStories/ │ │ │ │ │ ├── content/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── listView/ │ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── storiesView/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── storyGridItem/ │ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── storyDisplayContent.js │ │ │ │ │ │ ├── storyGridView/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── storyListItem/ │ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ ├── content.js │ │ │ │ │ │ └── storiesView.js │ │ │ │ │ ├── filters/ │ │ │ │ │ │ ├── StoryFiltersProvider/ │ │ │ │ │ │ │ ├── author/ │ │ │ │ │ │ │ │ └── useAuthorFilter.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── taxonomy/ │ │ │ │ │ │ │ └── useTaxonomyFilters.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── storyFiltersProvider.js │ │ │ │ │ │ │ ├── useAuthorFilter.js │ │ │ │ │ │ │ └── useTaxonomyFilter.js │ │ │ │ │ │ └── useStoryFilters.js │ │ │ │ │ ├── header/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── storyStatusToggle.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── header.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── karma/ │ │ │ │ │ ├── myStories.karma.js │ │ │ │ │ └── myStoriesList.karma.js │ │ │ │ ├── shared/ │ │ │ │ │ ├── bodyViewOptions.js │ │ │ │ │ ├── emptyContentMessage.js │ │ │ │ │ ├── grid/ │ │ │ │ │ │ └── components.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── pageHeading.js │ │ │ │ │ ├── sortDropDown.js │ │ │ │ │ └── stories/ │ │ │ │ │ ├── emptyContentMessage.js │ │ │ │ │ └── pageHeading.js │ │ │ │ └── utils/ │ │ │ │ ├── composeTemplateFilter.js │ │ │ │ ├── getSearchOptions.js │ │ │ │ ├── getTemplateFilters.js │ │ │ │ ├── index.js │ │ │ │ └── test/ │ │ │ │ ├── composeTemplateFilter.js │ │ │ │ ├── getSearchOptions.js │ │ │ │ └── getTemplateFilters.js │ │ │ ├── components/ │ │ │ │ ├── cardGallery/ │ │ │ │ │ ├── components.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── cardGrid/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── card-grid.js │ │ │ │ ├── cardGridItem/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── cardGridItem.js │ │ │ │ ├── colorList/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── contentGutter/ │ │ │ │ │ └── index.js │ │ │ │ ├── dialog/ │ │ │ │ │ ├── dialog.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── fileUpload/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── fileUpload.js │ │ │ │ ├── index.js │ │ │ │ ├── inlineInputForm/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── inlineInputForm.js │ │ │ │ ├── interfaceSkeleton/ │ │ │ │ │ └── index.js │ │ │ │ ├── layout/ │ │ │ │ │ ├── fixed.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── provider.js │ │ │ │ │ ├── scrollable.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── useLayoutContext.js │ │ │ │ │ └── useLayoutContext.js │ │ │ │ ├── navProvider.js │ │ │ │ ├── pageStructure/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leftRail.js │ │ │ │ │ ├── menuButton.js │ │ │ │ │ ├── navigationComponents.js │ │ │ │ │ ├── pageStructureComponents.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── menuButton.js │ │ │ │ │ └── test/ │ │ │ │ │ └── pageStructure.js │ │ │ │ ├── popoverMenu/ │ │ │ │ │ ├── story-menu-generator.js │ │ │ │ │ └── test/ │ │ │ │ │ └── story-menu-generator.js │ │ │ │ ├── scrollToTop/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── scrollToTop.js │ │ │ │ ├── storyMenu/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── storyMenu.js │ │ │ │ ├── table/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── table.js │ │ │ │ ├── tooltip/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── tooltip.js │ │ │ │ ├── types.js │ │ │ │ └── viewStyleBar/ │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── index.js │ │ │ │ └── test/ │ │ │ │ └── viewStyleBar.js │ │ │ ├── constants/ │ │ │ │ ├── components.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pageStructure.ts │ │ │ │ ├── stories.ts │ │ │ │ └── templates.ts │ │ │ ├── dashboard.js │ │ │ ├── dataUtils/ │ │ │ │ ├── formattedStoriesArray.js │ │ │ │ ├── formattedTaxonomiesArray.js │ │ │ │ ├── formattedTaxonomyTermsObject.js │ │ │ │ ├── formattedTemplatesArray.js │ │ │ │ └── formattedUsersObject.js │ │ │ ├── getDefaultConfig.js │ │ │ ├── icons/ │ │ │ │ ├── index.ts │ │ │ │ └── stories/ │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── karma/ │ │ │ │ ├── apiProviderFixture.js │ │ │ │ ├── fixture.js │ │ │ │ └── integrationLayerTesting/ │ │ │ │ └── config.karma.js │ │ │ ├── propTypes.js │ │ │ ├── storybookUtils/ │ │ │ │ └── index.js │ │ │ ├── testUtils/ │ │ │ │ ├── groupTemplatesByTag.js │ │ │ │ ├── index.js │ │ │ │ ├── mockApiProvider.js │ │ │ │ └── renderWithProviders.js │ │ │ ├── theme.js │ │ │ ├── types/ │ │ │ │ ├── configProvider.ts │ │ │ │ ├── index.ts │ │ │ │ └── routerProvider.ts │ │ │ ├── typings/ │ │ │ │ └── svg.d.ts │ │ │ └── utils/ │ │ │ ├── groupBy.js │ │ │ ├── index.js │ │ │ ├── keyboardOnlyOutline.js │ │ │ ├── noop.js │ │ │ ├── test/ │ │ │ │ ├── groupBy.js │ │ │ │ ├── useDashboardResultsLabel.js │ │ │ │ ├── usePagePreviewSize.js │ │ │ │ ├── useStoryView.js │ │ │ │ └── useTemplateView.js │ │ │ ├── titleFormatted.js │ │ │ ├── useDashboardResultsLabel.js │ │ │ ├── usePagePreviewSize.js │ │ │ ├── useStoryView.js │ │ │ └── useTemplateView.js │ │ └── tsconfig.json │ ├── date/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── convertFormatString.ts │ │ │ ├── format.ts │ │ │ ├── formatDate.ts │ │ │ ├── formatDistance.ts │ │ │ ├── formatTime.ts │ │ │ ├── getOptions.ts │ │ │ ├── getRelativeDisplayDate.ts │ │ │ ├── getTimeZoneString.ts │ │ │ ├── hasLeadingZeros.ts │ │ │ ├── index.ts │ │ │ ├── is12Hour.ts │ │ │ ├── settings.ts │ │ │ ├── test/ │ │ │ │ ├── convertFormatString.ts │ │ │ │ ├── format.ts │ │ │ │ ├── formatDate.ts │ │ │ │ ├── formatTime.ts │ │ │ │ ├── getRelativeDisplayDate.ts │ │ │ │ ├── getTimeZoneString.ts │ │ │ │ ├── hasLeadingZeros.ts │ │ │ │ ├── is12Hour.ts │ │ │ │ └── toUTCDate.ts │ │ │ ├── third_party/ │ │ │ │ └── buildLocalizeFn.ts │ │ │ └── toUTCDate.ts │ │ └── tsconfig.json │ ├── design-system/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── banner/ │ │ │ │ │ ├── banner.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── button/ │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── button.js │ │ │ │ │ └── toggleButton.tsx │ │ │ │ ├── checkbox/ │ │ │ │ │ ├── checkbox.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── checkbox.js │ │ │ │ ├── chip/ │ │ │ │ │ ├── chip.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── index.js │ │ │ │ ├── circularProgress/ │ │ │ │ │ ├── circular.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── contextMenu/ │ │ │ │ │ ├── animationContainer.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── button.tsx │ │ │ │ │ │ ├── group.tsx │ │ │ │ │ │ ├── icon.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── item.tsx │ │ │ │ │ │ ├── label.ts │ │ │ │ │ │ ├── link.tsx │ │ │ │ │ │ ├── separator.tsx │ │ │ │ │ │ ├── shortcut.ts │ │ │ │ │ │ ├── styles.ts │ │ │ │ │ │ ├── subMenuTrigger.tsx │ │ │ │ │ │ └── suffix.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── contextMenu.tsx │ │ │ │ │ ├── contextMenuProvider/ │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── provider.tsx │ │ │ │ │ │ └── useContextMenu.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styled.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── contextMenu.js │ │ │ │ │ └── types.ts │ │ │ │ ├── datalist/ │ │ │ │ │ ├── container.tsx │ │ │ │ │ ├── datalist.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── defaultRenderer.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list.tsx │ │ │ │ │ │ └── styled.ts │ │ │ │ │ ├── searchInput.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── dialog/ │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── dialog.js │ │ │ │ ├── disclosure/ │ │ │ │ │ ├── disclosure.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dropDown/ │ │ │ │ │ ├── components.ts │ │ │ │ │ ├── dropdown.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select/ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── select.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── dropDown.js │ │ │ │ │ │ ├── select.js │ │ │ │ │ │ └── useDropDown.js │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useDropDown.ts │ │ │ │ ├── hex/ │ │ │ │ │ ├── hex.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── index.ts │ │ │ │ ├── infiniteScroller/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── infiniteScroller.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── infiniteScroller.js │ │ │ │ ├── input/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── numericInput.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styled.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── input.js │ │ │ │ │ │ ├── useNumericInput.js │ │ │ │ │ │ └── validation.js │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── useNumericInput.tsx │ │ │ │ │ └── validation.ts │ │ │ │ ├── keyboard/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── gridview/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── getArrowDir.js │ │ │ │ │ │ │ ├── getColumn.js │ │ │ │ │ │ │ ├── getIndex.js │ │ │ │ │ │ │ └── getRow.js │ │ │ │ │ │ ├── useGridViewKeys.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── keyboard.tsx │ │ │ │ │ ├── keys.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── createShortcutAriaLabel.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── prettifyShortcut.js │ │ │ │ │ └── utils.ts │ │ │ │ ├── loadingBar/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── loadingBar.tsx │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── loadingSpinner/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── loadingSpinner.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── loadingSpinner.js │ │ │ │ ├── mediaInput/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mediaInput.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── mediaInput.js │ │ │ │ │ └── types.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── components.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ ├── defaultListItem.tsx │ │ │ │ │ │ ├── emptyList.tsx │ │ │ │ │ │ ├── groupLabel.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list.tsx │ │ │ │ │ │ └── listGroup.tsx │ │ │ │ │ ├── menu.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── menu.js │ │ │ │ │ │ ├── useDropDownMenu.js │ │ │ │ │ │ └── utils.js │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── useDropDownMenu.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── modal/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── modal.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styles.ts │ │ │ │ │ └── test/ │ │ │ │ │ └── modal.js │ │ │ │ ├── notificationBubble/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notificationBubble.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── types.ts │ │ │ │ ├── pill/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── pill.tsx │ │ │ │ │ ├── pillGroup.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── pill.js │ │ │ │ ├── popup/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── popup.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── popup.js │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── getOffset.ts │ │ │ │ │ ├── getTransforms.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── radio/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── radio.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── radio.js │ │ │ │ ├── search/ │ │ │ │ │ ├── components.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── input/ │ │ │ │ │ │ ├── components.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── input.tsx │ │ │ │ │ ├── search.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── input.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── input.js │ │ │ │ │ │ ├── search.js │ │ │ │ │ │ └── useSearch.js │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useSearch.ts │ │ │ │ ├── slider/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── slider.tsx │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── snackbar/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── snackbarContainer.tsx │ │ │ │ │ ├── snackbarMessage.tsx │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── snackbarContainer.js │ │ │ │ │ │ └── snackbarMessage.js │ │ │ │ │ └── types.ts │ │ │ │ ├── swatch/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── swatch.tsx │ │ │ │ ├── switch/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── switch.tsx │ │ │ │ │ └── test/ │ │ │ │ │ └── switch.js │ │ │ │ ├── textArea/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── textArea.js │ │ │ │ │ └── textArea.tsx │ │ │ │ ├── toggle/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── toggle.js │ │ │ │ │ └── toggle.tsx │ │ │ │ ├── tooltip/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── tail.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── tooltip.js │ │ │ │ │ └── tooltip.tsx │ │ │ │ ├── typography/ │ │ │ │ │ ├── display/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── headline/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── link/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styles.ts │ │ │ │ │ └── text/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ └── visuallyHidden.ts │ │ │ ├── contexts/ │ │ │ │ ├── index.ts │ │ │ │ ├── popup/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── popupProvider.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── usePopup.ts │ │ │ │ └── snackbar/ │ │ │ │ ├── context.ts │ │ │ │ ├── index.ts │ │ │ │ ├── snackbarProvider.tsx │ │ │ │ └── useSnackbar.ts │ │ │ ├── icons/ │ │ │ │ ├── index.ts │ │ │ │ └── stories/ │ │ │ │ └── index.js │ │ │ ├── images/ │ │ │ │ ├── index.ts │ │ │ │ └── stories/ │ │ │ │ └── index.js │ │ │ ├── index.ts │ │ │ ├── stories/ │ │ │ │ └── index.js │ │ │ ├── storybookUtils/ │ │ │ │ ├── darkThemeProvider.js │ │ │ │ ├── index.js │ │ │ │ └── sampleData.js │ │ │ ├── testUtils/ │ │ │ │ ├── index.js │ │ │ │ ├── queryById.js │ │ │ │ ├── renderWithProviders.js │ │ │ │ └── sampleData.js │ │ │ ├── theme/ │ │ │ │ ├── borders.ts │ │ │ │ ├── breakpoint.ts │ │ │ │ ├── colors.ts │ │ │ │ ├── constants/ │ │ │ │ │ ├── breakpoints.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── global/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── styles.ts │ │ │ │ ├── helpers/ │ │ │ │ │ ├── centerContent.ts │ │ │ │ │ ├── expandPresetStyles.ts │ │ │ │ │ ├── fullSize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── outline.ts │ │ │ │ │ ├── scrollbar.ts │ │ │ │ │ ├── transparentBg.ts │ │ │ │ │ └── visuallyHidden.ts │ │ │ │ ├── index.ts │ │ │ │ ├── stories/ │ │ │ │ │ ├── colors.js │ │ │ │ │ └── index.js │ │ │ │ ├── theme.ts │ │ │ │ ├── types.ts │ │ │ │ └── typography.ts │ │ │ ├── types/ │ │ │ │ ├── keyboard.ts │ │ │ │ ├── snackbar.ts │ │ │ │ ├── theme.ts │ │ │ │ └── typography.ts │ │ │ ├── typings/ │ │ │ │ ├── styled.d.ts │ │ │ │ └── svg.d.ts │ │ │ └── utils/ │ │ │ ├── constants.ts │ │ │ ├── deepMerge.ts │ │ │ ├── directions.ts │ │ │ ├── getKeyboardMovement.ts │ │ │ ├── index.ts │ │ │ ├── isNullOrUndefinedOrEmptyString.ts │ │ │ ├── labelAccessibilityValidator.ts │ │ │ ├── localStore.ts │ │ │ ├── noop.ts │ │ │ ├── panelSections.ts │ │ │ ├── panelTypes.ts │ │ │ ├── sessionStore.ts │ │ │ ├── test/ │ │ │ │ ├── deepMerge.js │ │ │ │ ├── labelAccessibilityValidator.js │ │ │ │ ├── uniqueEntriesByKey.js │ │ │ │ ├── useLiveRegion.js │ │ │ │ └── useMouseDownOutside.js │ │ │ ├── uniqueEntriesByKey.ts │ │ │ ├── useForwardedRef.ts │ │ │ ├── useLiveRegion.ts │ │ │ └── useMouseDownOutsideRef.ts │ │ └── tsconfig.json │ ├── dom/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── ensureFontLoaded.ts │ │ │ ├── escapeHTML.ts │ │ │ ├── index.ts │ │ │ ├── loadStylesheet.ts │ │ │ ├── stripHTML.ts │ │ │ └── test/ │ │ │ └── escapeHTML.ts │ │ └── tsconfig.json │ ├── e2e-test-utils/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── activatePlugin.js │ │ ├── activateRTL.js │ │ ├── addRequestInterception.js │ │ ├── addTextElement.js │ │ ├── checkVersion.js │ │ ├── config.js │ │ ├── createNewPost.js │ │ ├── createNewStory.js │ │ ├── createNewTerm.js │ │ ├── createURL.js │ │ ├── customFonts.js │ │ ├── deactivatePlugin.js │ │ ├── deactivateRTL.js │ │ ├── deleteAllMedia.js │ │ ├── deleteMedia.js │ │ ├── deleteWidgets.js │ │ ├── disableCheckbox.js │ │ ├── editStoryWithTitle.js │ │ ├── enableCheckbox.js │ │ ├── experimentalFeatures.js │ │ ├── getFileName.js │ │ ├── index.js │ │ ├── insertStoryTitle.js │ │ ├── insertWidget.js │ │ ├── isCurrentURL.js │ │ ├── loadPostEditor.js │ │ ├── loginUser.js │ │ ├── logoutUser.js │ │ ├── minWPVersionRequired.js │ │ ├── previewStory.js │ │ ├── publishPost.js │ │ ├── publishStory.js │ │ ├── setAnalyticsCode.js │ │ ├── shopping.js │ │ ├── takeSnapshot.js │ │ ├── toggleVideoOptimization.js │ │ ├── toolbarProfileOption.js │ │ ├── trashAllPosts.js │ │ ├── trashAllTerms.js │ │ ├── triggerHighPriorityChecklistSection.js │ │ ├── uploadFile.js │ │ ├── uploadMedia.js │ │ ├── uploadPublisherLogo.js │ │ ├── user.js │ │ ├── visitAdminPage.js │ │ ├── visitBlockWidgetScreen.js │ │ ├── visitDashboard.js │ │ ├── visitSettings.js │ │ ├── withPlugin.js │ │ ├── withRTL.js │ │ └── withUser.js │ ├── e2e-tests/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── assets/ │ │ │ ├── README.md │ │ │ ├── small-video.webm │ │ │ └── test.vtt │ │ ├── config/ │ │ │ └── bootstrap.js │ │ ├── jest.config.js │ │ ├── plugins/ │ │ │ ├── disable-block-directory.php │ │ │ ├── disable-gravatar.php │ │ │ ├── locked-post-mock.php │ │ │ ├── site-kit-adsense.php │ │ │ ├── site-kit-analytics.php │ │ │ ├── status-check-200-invalid.php │ │ │ ├── status-check-403.php │ │ │ ├── status-check-500.php │ │ │ ├── web-stories-cors-error.php │ │ │ ├── web-stories-disable-default-templates.php │ │ │ ├── web-stories-disable-media3p.php │ │ │ ├── web-stories-embed.php │ │ │ ├── web-stories-hotlink.php │ │ │ ├── web-stories-meta-box.php │ │ │ └── web-stories-taxonomies.php │ │ ├── puppeteerEnvironment.js │ │ ├── specs/ │ │ │ ├── dashboard/ │ │ │ │ ├── adminMenu.js │ │ │ │ ├── dashboard.js │ │ │ │ ├── documentTitle.js │ │ │ │ ├── myStories.js │ │ │ │ ├── noJS.js │ │ │ │ ├── settings/ │ │ │ │ │ ├── adminUser/ │ │ │ │ │ │ ├── analytics.js │ │ │ │ │ │ ├── customFonts.js │ │ │ │ │ │ ├── dataRemovalSettings.js │ │ │ │ │ │ ├── monetization.js │ │ │ │ │ │ ├── publisherLogo.js │ │ │ │ │ │ ├── shoppingProvider.js │ │ │ │ │ │ ├── telemetryBanner.js │ │ │ │ │ │ └── videoSettings.js │ │ │ │ │ ├── authorUser.js │ │ │ │ │ ├── contributorUser.js │ │ │ │ │ └── editorUser.js │ │ │ │ ├── telemetryBanner.js │ │ │ │ └── templates/ │ │ │ │ └── useTemplate.js │ │ │ ├── editor/ │ │ │ │ ├── authorUser.js │ │ │ │ ├── backgroundAudio.js │ │ │ │ ├── contributorUser.js │ │ │ │ ├── editor.js │ │ │ │ ├── floatingMenu.js │ │ │ │ ├── fontCheck.js │ │ │ │ ├── media/ │ │ │ │ │ ├── hotlinking.js │ │ │ │ │ ├── insert3PMedia.js │ │ │ │ │ ├── insertMediaFromDialog.js │ │ │ │ │ ├── insertMediaFromLibrary.js │ │ │ │ │ ├── insertMovVideo.js │ │ │ │ │ ├── insertWebMVideo.js │ │ │ │ │ └── svg.js │ │ │ │ ├── metaBoxes.js │ │ │ │ ├── noJS.js │ │ │ │ ├── pageTemplates.js │ │ │ │ ├── passwordProtected.js │ │ │ │ ├── pendingStories.js │ │ │ │ ├── prePublishChecklist/ │ │ │ │ │ ├── adminUser.js │ │ │ │ │ └── contributorUser.js │ │ │ │ ├── publishPanel/ │ │ │ │ │ └── adminUser.js │ │ │ │ ├── publishingFlow.js │ │ │ │ ├── saveStory.js │ │ │ │ ├── shopping/ │ │ │ │ │ ├── productMenu.js │ │ │ │ │ ├── schema.json │ │ │ │ │ └── woocommerce.js │ │ │ │ ├── sidebar.js │ │ │ │ ├── statusCheck.js │ │ │ │ ├── storyDetailsModal/ │ │ │ │ │ ├── adminUser.js │ │ │ │ │ └── contributorUser.js │ │ │ │ ├── taxonomy.js │ │ │ │ └── templates.js │ │ │ ├── integrations/ │ │ │ │ ├── amp.js │ │ │ │ └── sitekit.js │ │ │ └── wordpress/ │ │ │ ├── adminMenu.js │ │ │ ├── archive.js │ │ │ ├── blockWidget.js │ │ │ ├── corsCheck.js │ │ │ ├── getStartedStory.js │ │ │ ├── pluginActivation.js │ │ │ ├── postLocking.js │ │ │ ├── quickEdit.js │ │ │ ├── tinymce.js │ │ │ ├── webStoriesBlock.js │ │ │ └── widget.js │ │ └── utils/ │ │ ├── constants.js │ │ └── index.js │ ├── element-library/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── audioSticker/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.tsx │ │ │ │ ├── output.tsx │ │ │ │ └── test/ │ │ │ │ └── output.js │ │ │ ├── constants.ts │ │ │ ├── elementTypes.ts │ │ │ ├── gif/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── frame.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.ts │ │ │ │ ├── output.tsx │ │ │ │ └── test/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── output.js.snap │ │ │ │ └── output.js │ │ │ ├── image/ │ │ │ │ ├── constants.ts │ │ │ │ ├── edit.tsx │ │ │ │ ├── frame.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.ts │ │ │ │ ├── output.tsx │ │ │ │ └── test/ │ │ │ │ └── output.js │ │ │ ├── index.ts │ │ │ ├── media/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── editCropMoveable.tsx │ │ │ │ ├── editPanMoveable.tsx │ │ │ │ ├── frame.tsx │ │ │ │ ├── imageDisplay.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── output.tsx │ │ │ │ ├── scalePanel.tsx │ │ │ │ ├── textContent.ts │ │ │ │ ├── util.ts │ │ │ │ └── videoImage.tsx │ │ │ ├── product/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.tsx │ │ │ │ └── output.tsx │ │ │ ├── shape/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.ts │ │ │ │ ├── output.tsx │ │ │ │ └── test/ │ │ │ │ └── output.js │ │ │ ├── shared/ │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shared.ts │ │ │ │ ├── useCSSVarColorTransformHandler.ts │ │ │ │ ├── useColorTransformHandler.ts │ │ │ │ └── visibleImage.tsx │ │ │ ├── sticker/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.ts │ │ │ │ └── output.tsx │ │ │ ├── test/ │ │ │ │ └── index.js │ │ │ ├── text/ │ │ │ │ ├── constants.ts │ │ │ │ ├── display.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── frame.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── layer.ts │ │ │ │ ├── output.tsx │ │ │ │ ├── outputWithUnits.tsx │ │ │ │ ├── test/ │ │ │ │ │ ├── output.js │ │ │ │ │ └── util.js │ │ │ │ ├── textContent.ts │ │ │ │ ├── updateForResizeEvent.ts │ │ │ │ └── util.ts │ │ │ ├── typings/ │ │ │ │ ├── global.d.ts │ │ │ │ ├── images.d.ts │ │ │ │ ├── styled.d.ts │ │ │ │ └── svg.d.ts │ │ │ ├── utils/ │ │ │ │ ├── noop.ts │ │ │ │ └── textMeasurements.tsx │ │ │ └── video/ │ │ │ ├── captions.tsx │ │ │ ├── constants.ts │ │ │ ├── controls.tsx │ │ │ ├── display.tsx │ │ │ ├── edit.tsx │ │ │ ├── frame.tsx │ │ │ ├── icon.tsx │ │ │ ├── index.ts │ │ │ ├── layer.ts │ │ │ ├── onDropHandler.ts │ │ │ ├── output.tsx │ │ │ ├── playPauseButton.tsx │ │ │ ├── test/ │ │ │ │ └── output.js │ │ │ └── trim.tsx │ │ └── tsconfig.json │ ├── elements/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── elementType.ts │ │ │ ├── index.ts │ │ │ ├── propTypes.ts │ │ │ ├── types/ │ │ │ │ ├── data.ts │ │ │ │ ├── element.ts │ │ │ │ ├── elementDefinition.ts │ │ │ │ ├── elementType.ts │ │ │ │ ├── index.ts │ │ │ │ ├── media.ts │ │ │ │ ├── page.ts │ │ │ │ ├── story.ts │ │ │ │ └── taxonomies.ts │ │ │ └── utils/ │ │ │ ├── createNewElement.ts │ │ │ ├── createPage.ts │ │ │ ├── duplicateElement.ts │ │ │ ├── duplicatePage.ts │ │ │ ├── elementIs.ts │ │ │ ├── getDefinitionForType.ts │ │ │ ├── getLayerName.ts │ │ │ ├── getOffsetCoordinates.ts │ │ │ ├── getTransformFlip.ts │ │ │ ├── index.ts │ │ │ └── isElementBelowLimit.ts │ │ └── tsconfig.json │ ├── eslint-import-resolver/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ └── index.cjs │ ├── fonts/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── cli.ts │ │ │ └── utils/ │ │ │ ├── buildFonts.ts │ │ │ ├── constants.ts │ │ │ ├── getFontFallback.ts │ │ │ ├── getFontMetrics.ts │ │ │ ├── normalizeFont.ts │ │ │ ├── test/ │ │ │ │ ├── buildFonts.js │ │ │ │ ├── getFontFallback.js │ │ │ │ ├── getFontMetrics.js │ │ │ │ └── normalizeFont.js │ │ │ └── types.ts │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── fonts.json │ │ │ ├── index.ts │ │ │ ├── test/ │ │ │ │ └── curatedFonts.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── getFontCSS.ts │ │ │ ├── getGoogleFontURL.ts │ │ │ ├── index.ts │ │ │ └── test/ │ │ │ ├── getFontCSS.js │ │ │ └── getGoogleFontURL.js │ │ └── tsconfig.json │ ├── glider/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── i18n/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ ├── sprintf.ts │ │ │ ├── test/ │ │ │ │ ├── transformNode.tsx │ │ │ │ ├── translateToExclusiveList.ts │ │ │ │ ├── translateToInclusiveList.ts │ │ │ │ └── translateWithMarkup.tsx │ │ │ ├── transformNode.ts │ │ │ ├── translateToExclusiveList.ts │ │ │ ├── translateToInclusiveList.ts │ │ │ ├── translateWithMarkup.tsx │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── jest-amp/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── index.js │ │ ├── toBeValidAMP.js │ │ ├── toBeValidAMPStoryElement.js │ │ ├── toBeValidAMPStoryPage.js │ │ └── utils.js │ ├── jest-puppeteer-amp/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── index.js │ │ └── toBeValidAMP.js │ ├── jest-resolver/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ └── index.cjs │ ├── karma-failed-tests-reporter/ │ │ ├── .eslintrc │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ └── index.cjs │ ├── karma-fixture/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── actPromise.js │ │ ├── client_with_context.html │ │ ├── componentStub.js │ │ ├── events.js │ │ ├── index.js │ │ └── init.js │ ├── karma-puppeteer-client/ │ │ ├── .eslintrc │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── client.js │ │ └── index.cjs │ ├── karma-puppeteer-launcher/ │ │ ├── .eslintrc │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── index.cjs │ │ ├── mouseWithDnd.cjs │ │ └── snapshot.cjs │ ├── masks/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── borderedMaskedElement.tsx │ │ │ ├── constants.ts │ │ │ ├── display.tsx │ │ │ ├── frame.tsx │ │ │ ├── index.ts │ │ │ ├── masks.ts │ │ │ ├── output.tsx │ │ │ ├── test/ │ │ │ │ ├── constants.js │ │ │ │ └── output.js │ │ │ ├── types.ts │ │ │ ├── typings/ │ │ │ │ └── styled-components.d.ts │ │ │ └── utils/ │ │ │ └── elementBorder.ts │ │ └── tsconfig.json │ ├── media/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── aspectRatiosApproximatelyMatch.ts │ │ │ ├── blob.ts │ │ │ ├── blobToFile.ts │ │ │ ├── calculateSrcSet.ts │ │ │ ├── createFileReader.ts │ │ │ ├── createResource.ts │ │ │ ├── fetchRemoteBlob.ts │ │ │ ├── fetchRemoteFile.ts │ │ │ ├── formatDuration.ts │ │ │ ├── formatMsToHMS.ts │ │ │ ├── getCanvasBlob.ts │ │ │ ├── getFileBasename.ts │ │ │ ├── getFileNameFromUrl.ts │ │ │ ├── getFirstFrameOfVideo.ts │ │ │ ├── getFocalFromOffset.ts │ │ │ ├── getImageDimensions.ts │ │ │ ├── getImageFromVideo.ts │ │ │ ├── getMediaSizePositionProps.ts │ │ │ ├── getMsFromHMS.ts │ │ │ ├── getResourceSize.ts │ │ │ ├── getSmallestUrlForWidth.ts │ │ │ ├── getTypeFromMime.ts │ │ │ ├── getVideoLength.ts │ │ │ ├── getVideoLengthDisplay.ts │ │ │ ├── hasVideoGotAudio.ts │ │ │ ├── index.ts │ │ │ ├── isAnimatedGif.ts │ │ │ ├── mimeTypes.ts │ │ │ ├── preloadImage.ts │ │ │ ├── preloadVideo.ts │ │ │ ├── preloadVideoMetadata.ts │ │ │ ├── readFile.ts │ │ │ ├── resourceIs.ts │ │ │ ├── resourceList.ts │ │ │ ├── seekVideo.ts │ │ │ ├── test/ │ │ │ │ ├── calculateSrcSet.ts │ │ │ │ ├── formatMsToHMS.ts │ │ │ │ ├── getFileBasename.ts │ │ │ │ ├── getMsFromHMS.ts │ │ │ │ ├── getResourceSize.ts │ │ │ │ ├── getSmallestUrlForWidth.ts │ │ │ │ ├── getVideoLengthDisplay.ts │ │ │ │ ├── isAnimatedGif.ts │ │ │ │ ├── mimeTypes.ts │ │ │ │ └── resourceList.ts │ │ │ ├── types/ │ │ │ │ ├── audioResource.ts │ │ │ │ ├── gifResource.ts │ │ │ │ ├── imageResource.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mediaElement.ts │ │ │ │ ├── propTypes.ts │ │ │ │ ├── resource.ts │ │ │ │ ├── resourceCache.ts │ │ │ │ ├── resourceInput.ts │ │ │ │ ├── resourceType.ts │ │ │ │ ├── sequenceResource.ts │ │ │ │ └── videoResource.ts │ │ │ └── typings/ │ │ │ └── dom.d.ts │ │ └── tsconfig.json │ ├── migration/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── scripts/ │ │ │ ├── cli.js │ │ │ └── utils/ │ │ │ ├── test/ │ │ │ │ └── updateTemplates.js │ │ │ └── updateTemplates.js │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── migrate.ts │ │ │ ├── migrations/ │ │ │ │ ├── test/ │ │ │ │ │ ├── v0001_storyDataArrayToObject.js │ │ │ │ │ ├── v0002_dataPixelTo1080.js │ │ │ │ │ ├── v0003_fullbleedToFill.js │ │ │ │ │ ├── v0004_mediaElementToResource.js │ │ │ │ │ ├── v0004_squareToShape.js │ │ │ │ │ ├── v0005_setOpacity.js │ │ │ │ │ ├── v0006_colorToPattern.js │ │ │ │ │ ├── v0007_setFlip.js │ │ │ │ │ ├── v0008_paddingToObject.js │ │ │ │ │ ├── v0009_defaultBackground.js │ │ │ │ │ ├── v0010_dataPixelTo440.js │ │ │ │ │ ├── v0011_pageAdvancement.js │ │ │ │ │ ├── v0012_setBackgroundTextMode.js │ │ │ │ │ ├── v0013_videoIdToId.js │ │ │ │ │ ├── v0014_oneTapLinkDeprecate.js │ │ │ │ │ ├── v0015_fontObjects.js │ │ │ │ │ ├── v0016_isFullbleedDeprecate.js │ │ │ │ │ ├── v0017_inlineTextProperties.js │ │ │ │ │ ├── v0018_defaultBackgroundElement.js │ │ │ │ │ ├── v0019_conicToLinear.js │ │ │ │ │ ├── v0020_isFillDeprecate.js │ │ │ │ │ ├── v0021_backgroundColorToPage.js │ │ │ │ │ ├── v0022_dataPixelTo412.js │ │ │ │ │ ├── v0023_convertOverlayPattern.js │ │ │ │ │ ├── v0024_blobsToSingleBlob.js │ │ │ │ │ ├── v0025_singleAnimationTarget.js │ │ │ │ │ ├── v0026_backgroundOverlayToOverlay.js │ │ │ │ │ ├── v0027_videoDuration.js │ │ │ │ │ ├── v0028_mark3pVideoAsOptimized.js │ │ │ │ │ ├── v0029_unifyGifResources.js │ │ │ │ │ ├── v0030_mark3pVideoAsMuted.js │ │ │ │ │ ├── v0031_normalizeResourceSizes.js │ │ │ │ │ ├── v0032_pageOutlinkTheme.js │ │ │ │ │ ├── v0033_removeTitleFromResource.js │ │ │ │ │ ├── v0034_removeUnusedBackgroundProps.js │ │ │ │ │ ├── v0035_markVideoAsExternal.js │ │ │ │ │ ├── v0036_changeBaseColorToHex.js │ │ │ │ │ ├── v0038_camelCaseResourceSizes.js │ │ │ │ │ ├── v0038_removeTransientMediaProperties.js │ │ │ │ │ ├── v0039_backgroundAudioFormatting.js │ │ │ │ │ ├── v0040_andadaFontToAndadaPro.js │ │ │ │ │ ├── v0041_removeFontProperties.js │ │ │ │ │ ├── v0042_removeTrackName.js │ │ │ │ │ ├── v0043_removeTagNames.js │ │ │ │ │ ├── v0044_unusedProperties.js │ │ │ │ │ ├── v0045_globalPageAdvancement.js │ │ │ │ │ ├── v0046_removeRedundantScalingProperties.js │ │ │ │ │ ├── v0047_fixBrokenTemplates.js │ │ │ │ │ └── v0048_removeBasedOnFromElements.js │ │ │ │ ├── v0001_storyDataArrayToObject.ts │ │ │ │ ├── v0002_dataPixelTo1080.ts │ │ │ │ ├── v0003_fullbleedToFill.ts │ │ │ │ ├── v0004_mediaElementToResource.ts │ │ │ │ ├── v0004_squareToShape.ts │ │ │ │ ├── v0005_setOpacity.ts │ │ │ │ ├── v0006_colorToPattern.ts │ │ │ │ ├── v0007_setFlip.ts │ │ │ │ ├── v0008_paddingToObject.ts │ │ │ │ ├── v0009_defaultBackground.ts │ │ │ │ ├── v0010_dataPixelTo440.ts │ │ │ │ ├── v0011_pageAdvancement.ts │ │ │ │ ├── v0012_setBackgroundTextMode.ts │ │ │ │ ├── v0013_videoIdToId.ts │ │ │ │ ├── v0014_oneTapLinkDeprecate.ts │ │ │ │ ├── v0015_fontObjects.ts │ │ │ │ ├── v0016_isFullbleedDeprecate.ts │ │ │ │ ├── v0017_inlineTextProperties.ts │ │ │ │ ├── v0018_defaultBackgroundElement.ts │ │ │ │ ├── v0019_conicToLinear.ts │ │ │ │ ├── v0020_isFillDeprecate.ts │ │ │ │ ├── v0021_backgroundColorToPage.ts │ │ │ │ ├── v0022_dataPixelTo412.ts │ │ │ │ ├── v0023_convertOverlayPattern.ts │ │ │ │ ├── v0024_blobsToSingleBlob.ts │ │ │ │ ├── v0025_singleAnimationTarget.ts │ │ │ │ ├── v0026_backgroundOverlayToOverlay.ts │ │ │ │ ├── v0027_videoDuration.ts │ │ │ │ ├── v0028_mark3pVideoAsOptimized.ts │ │ │ │ ├── v0029_unifyGifResources.ts │ │ │ │ ├── v0030_mark3pVideoAsMuted.ts │ │ │ │ ├── v0031_normalizeResourceSizes.ts │ │ │ │ ├── v0032_pageOutlinkTheme.ts │ │ │ │ ├── v0033_removeTitleFromResources.ts │ │ │ │ ├── v0034_removeUnusedBackgroundProps.ts │ │ │ │ ├── v0035_markVideoAsExternal.ts │ │ │ │ ├── v0036_changeBaseColorToHex.ts │ │ │ │ ├── v0037_removeTransientMediaProperties.ts │ │ │ │ ├── v0038_camelCaseResourceSizes.ts │ │ │ │ ├── v0039_backgroundAudioFormatting.ts │ │ │ │ ├── v0040_andadaFontToAndadaPro.ts │ │ │ │ ├── v0041_removeFontProperties.ts │ │ │ │ ├── v0042_removeTrackName.ts │ │ │ │ ├── v0043_removeTagNames.ts │ │ │ │ ├── v0044_unusedProperties.ts │ │ │ │ ├── v0045_globalPageAdvancement.ts │ │ │ │ ├── v0046_removeRedundantScalingProperties.ts │ │ │ │ ├── v0047_fixBrokenTemplates.ts │ │ │ │ └── v0048_removeBasedOnFromElements.ts │ │ │ └── types/ │ │ │ ├── element.ts │ │ │ ├── index.ts │ │ │ ├── pattern.ts │ │ │ ├── resource.ts │ │ │ └── story.ts │ │ └── tsconfig.json │ ├── moveable/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── areEventsDragging.ts │ │ │ ├── cropStyle.ts │ │ │ ├── index.ts │ │ │ ├── moveStyle.ts │ │ │ ├── moveable.tsx │ │ │ ├── overlay/ │ │ │ │ ├── context.ts │ │ │ │ ├── index.tsx │ │ │ │ └── withOverlay.tsx │ │ │ ├── test/ │ │ │ │ └── areEventsDragging.ts │ │ │ └── typings/ │ │ │ ├── styled-components.d.ts │ │ │ └── svg-imports.d.ts │ │ └── tsconfig.json │ ├── output/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ └── withLink/ │ │ │ │ ├── index.tsx │ │ │ │ └── test/ │ │ │ │ └── output.tsx │ │ │ ├── constants.ts │ │ │ ├── element.tsx │ │ │ ├── index.ts │ │ │ ├── page.tsx │ │ │ ├── story.tsx │ │ │ ├── test/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── page.tsx.snap │ │ │ │ │ └── textElement.tsx.snap │ │ │ │ ├── _utils/ │ │ │ │ │ └── constants.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── story.tsx │ │ │ │ └── textElement.tsx │ │ │ ├── types.ts │ │ │ ├── typings/ │ │ │ │ ├── global.d.ts │ │ │ │ ├── jest.d.ts │ │ │ │ └── svg.d.ts │ │ │ └── utils/ │ │ │ ├── ampBoilerplate.tsx │ │ │ ├── backgroundAudio.tsx │ │ │ ├── fontDeclarations.tsx │ │ │ ├── getAutoAdvanceAfter.ts │ │ │ ├── getLongestMediaElement.ts │ │ │ ├── getPreloadResources.ts │ │ │ ├── getStoryMarkup.tsx │ │ │ ├── getTextElementTagNames.ts │ │ │ ├── getUsedAmpExtensions.ts │ │ │ ├── outlink.tsx │ │ │ ├── populateElementFontData.ts │ │ │ ├── shoppingAttachment.tsx │ │ │ ├── styles.tsx │ │ │ └── test/ │ │ │ ├── fontDeclarations.tsx │ │ │ ├── getAutoAdvanceAfter.ts │ │ │ ├── getLongestMediaElement.ts │ │ │ ├── getPreloadResources.ts │ │ │ ├── getStoryMarkup.ts │ │ │ ├── getTextElementTagNames.ts │ │ │ ├── getUsedAmpExtensions.ts │ │ │ └── populateElementFontData.ts │ │ └── tsconfig.json │ ├── patterns/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── convertToCSS.ts │ │ │ ├── createSolid.ts │ │ │ ├── createSolidFromString.ts │ │ │ ├── generatePatternStyles.ts │ │ │ ├── getColorFromGradientStyle.ts │ │ │ ├── getGradientStyleFromColor.ts │ │ │ ├── getHexFromSolid.ts │ │ │ ├── getHexFromSolidArray.ts │ │ │ ├── getHexFromValue.ts │ │ │ ├── getOpaquePattern.ts │ │ │ ├── getPreviewText.ts │ │ │ ├── getSolidFromHex.ts │ │ │ ├── hasGradient.ts │ │ │ ├── hasOpacity.ts │ │ │ ├── index.ts │ │ │ ├── isHexColorString.ts │ │ │ ├── isPatternEqual.ts │ │ │ ├── test/ │ │ │ │ ├── convertToCSS.ts │ │ │ │ ├── createSolid.ts │ │ │ │ ├── createSolidFromString.ts │ │ │ │ ├── generatePatternStyles.ts │ │ │ │ ├── getColorFromGradientStyle.ts │ │ │ │ ├── getHexFromValue.ts │ │ │ │ ├── getOpaquePattern.ts │ │ │ │ ├── getPreviewText.ts │ │ │ │ ├── getSolidFromHex.ts │ │ │ │ └── hasOpacity.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── react/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── context.ts │ │ │ ├── index.ts │ │ │ ├── react-dom.ts │ │ │ ├── react.ts │ │ │ ├── renderToStaticMarkup.ts │ │ │ ├── shallowEqual.ts │ │ │ ├── test/ │ │ │ │ ├── context.js │ │ │ │ └── useCombinedRefs.js │ │ │ ├── typings/ │ │ │ │ └── global.d.ts │ │ │ ├── useBatchingCallback.ts │ │ │ ├── useCombinedRefs.ts │ │ │ ├── useContextSelector.ts │ │ │ ├── useFocusOut.ts │ │ │ ├── useInitializedValue.ts │ │ │ ├── usePrevious.ts │ │ │ ├── useResizeEffect.ts │ │ │ ├── useUnmount.ts │ │ │ └── useWhyDidYouUpdate.ts │ │ └── tsconfig.json │ ├── rich-text/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── context.ts │ │ │ ├── customConstants.ts │ │ │ ├── customExport.ts │ │ │ ├── customImport.ts │ │ │ ├── customInlineDisplay.ts │ │ │ ├── draftUtils.ts │ │ │ ├── editor.tsx │ │ │ ├── fauxSelection.ts │ │ │ ├── formatters/ │ │ │ │ ├── color.ts │ │ │ │ ├── convert.ts │ │ │ │ ├── gradientColor.ts │ │ │ │ ├── index.ts │ │ │ │ ├── italic.ts │ │ │ │ ├── letterSpacing.ts │ │ │ │ ├── test/ │ │ │ │ │ ├── _utils.ts │ │ │ │ │ ├── color.tsx │ │ │ │ │ ├── italic.tsx │ │ │ │ │ ├── letterSpacing.tsx │ │ │ │ │ ├── underline.tsx │ │ │ │ │ ├── uppercase.tsx │ │ │ │ │ └── weight.tsx │ │ │ │ ├── underline.ts │ │ │ │ ├── uppercase.ts │ │ │ │ ├── util.ts │ │ │ │ └── weight.ts │ │ │ ├── getFontVariants.ts │ │ │ ├── getPastedBlocks.ts │ │ │ ├── getStateInfo.ts │ │ │ ├── getTextColors.ts │ │ │ ├── htmlManipulation.ts │ │ │ ├── index.ts │ │ │ ├── provider.tsx │ │ │ ├── styleManipulation.ts │ │ │ ├── test/ │ │ │ │ ├── getFontVariants.ts │ │ │ │ ├── getPastedBlocks.ts │ │ │ │ ├── getTextColors.ts │ │ │ │ └── styleManipulation.ts │ │ │ ├── types.ts │ │ │ ├── typings/ │ │ │ │ └── jest.d.ts │ │ │ ├── useHandlePastedText.ts │ │ │ ├── usePasteTextContent.ts │ │ │ ├── useRichText.ts │ │ │ ├── useSelectionManipulation.ts │ │ │ ├── util.ts │ │ │ └── utils/ │ │ │ ├── getCaretCharacterOffsetWithin.ts │ │ │ └── getValidHTML.ts │ │ └── tsconfig.json │ ├── stickers/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── 12-hours-in-barcelona/ │ │ │ │ ├── index.ts │ │ │ │ ├── list.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── yellowLocationPin.tsx │ │ │ ├── a-day-in-the-life/ │ │ │ │ ├── alarmClock.tsx │ │ │ │ ├── brownRice.tsx │ │ │ │ ├── dumbbells.tsx │ │ │ │ ├── fish.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── jumpRope.tsx │ │ │ │ ├── whiteBag.tsx │ │ │ │ └── yellowLeaves.tsx │ │ │ ├── ace-hotel-kyoto-review/ │ │ │ │ ├── greenCup.tsx │ │ │ │ ├── greenCutlery.tsx │ │ │ │ ├── greenHandBag.tsx │ │ │ │ └── index.ts │ │ │ ├── all-about-cars/ │ │ │ │ ├── blueRings.tsx │ │ │ │ ├── index.ts │ │ │ │ └── multipleBlueRings.tsx │ │ │ ├── almodos-films/ │ │ │ │ ├── heptagram.tsx │ │ │ │ └── index.ts │ │ │ ├── an-artists-legacy/ │ │ │ │ ├── brushStroke.tsx │ │ │ │ └── index.ts │ │ │ ├── art-books-gift-guide/ │ │ │ │ ├── books.tsx │ │ │ │ ├── handHeldSign.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── manReading.tsx │ │ │ │ ├── openHands.tsx │ │ │ │ ├── photoFrame.tsx │ │ │ │ ├── womanReading.tsx │ │ │ │ └── womanReading02.tsx │ │ │ ├── baking-bread-guide/ │ │ │ │ ├── breadBun.tsx │ │ │ │ ├── dough.tsx │ │ │ │ ├── flourBag.tsx │ │ │ │ ├── flourBowl.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── jar.tsx │ │ │ │ ├── thermometer.tsx │ │ │ │ └── yeastPackage.tsx │ │ │ ├── beauty-quiz/ │ │ │ │ ├── greenBlob.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── pinkBlob.tsx │ │ │ │ └── tiltedGreenBlob.tsx │ │ │ ├── belly-fat-workout/ │ │ │ │ ├── blackSectionSeparator.tsx │ │ │ │ └── index.ts │ │ │ ├── buying-art-on-the-internet/ │ │ │ │ ├── blackInstagram.tsx │ │ │ │ └── index.ts │ │ │ ├── celebrity-life-story/ │ │ │ │ ├── blackStar.tsx │ │ │ │ ├── greenStar.tsx │ │ │ │ └── index.ts │ │ │ ├── diy-home-office/ │ │ │ │ ├── index.ts │ │ │ │ └── offWhiteArrow.tsx │ │ │ ├── doers-get-more-done/ │ │ │ │ ├── facebookIcon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── instagramIcon.tsx │ │ │ │ ├── orangeCross.tsx │ │ │ │ ├── orangeDot.tsx │ │ │ │ ├── plus.tsx │ │ │ │ ├── rightArrow.tsx │ │ │ │ ├── twitterIcon.tsx │ │ │ │ └── youTubeIcon.tsx │ │ │ ├── elegant-travel-itinerary/ │ │ │ │ ├── floralFrame.tsx │ │ │ │ ├── floralSeparator.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── stationClock.tsx │ │ │ │ ├── trainTracks.tsx │ │ │ │ └── yellowBridge.tsx │ │ │ ├── experience-thailand/ │ │ │ │ ├── facebookIcon.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── instagramIcon.tsx │ │ │ │ ├── mapFill.tsx │ │ │ │ ├── twitterIcon.tsx │ │ │ │ └── youTubeIcon.tsx │ │ │ ├── fashion-inspiration/ │ │ │ │ ├── curvedArrow.tsx │ │ │ │ ├── cutout.tsx │ │ │ │ └── index.ts │ │ │ ├── fashion-on-the-go/ │ │ │ │ ├── arrowDark.tsx │ │ │ │ ├── arrowLight.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── starburst.tsx │ │ │ │ ├── starburstSolid.tsx │ │ │ │ └── starburstWithArrow.tsx │ │ │ ├── fitness-apps-ranked/ │ │ │ │ ├── dumbbells.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── muscle.tsx │ │ │ │ ├── piechart.tsx │ │ │ │ ├── running.tsx │ │ │ │ ├── shoe.tsx │ │ │ │ └── swimmer.tsx │ │ │ ├── food-and-stuff/ │ │ │ │ ├── arrow.tsx │ │ │ │ ├── artichoke.tsx │ │ │ │ ├── carrot.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── persimmon.tsx │ │ │ │ ├── radish.tsx │ │ │ │ └── radishLarge.tsx │ │ │ ├── fresh-and-bright/ │ │ │ │ ├── cta.tsx │ │ │ │ ├── heart.tsx │ │ │ │ └── index.ts │ │ │ ├── hawaii-travel-packing-list/ │ │ │ │ ├── cartBag.tsx │ │ │ │ ├── greenLeaf.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── palmLeaf.tsx │ │ │ │ └── palmTree.tsx │ │ │ ├── honeymooning-in-italy/ │ │ │ │ ├── floralPattern.tsx │ │ │ │ └── index.ts │ │ │ ├── how-contact-tracing-works/ │ │ │ │ ├── index.ts │ │ │ │ ├── phoneInHand.tsx │ │ │ │ └── rings.tsx │ │ │ ├── how-video-calls-saved-the-day/ │ │ │ │ ├── chatBox.tsx │ │ │ │ ├── index.ts │ │ │ │ └── yellowBrowser.tsx │ │ │ ├── index.ts │ │ │ ├── indoor-garden-oasis/ │ │ │ │ ├── curvedScissor.tsx │ │ │ │ ├── floral.tsx │ │ │ │ ├── greenBag.tsx │ │ │ │ ├── greenBulb.tsx │ │ │ │ ├── greenCurvedLine.tsx │ │ │ │ ├── greenLightning.tsx │ │ │ │ ├── greenSun.tsx │ │ │ │ ├── greenSunLight.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── lengthComparison.tsx │ │ │ │ ├── lightYellowArrow.tsx │ │ │ │ ├── scissor.tsx │ │ │ │ └── waterDroplet.tsx │ │ │ ├── kitchen-stories/ │ │ │ │ ├── castIron.tsx │ │ │ │ ├── hotDish.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── oven.tsx │ │ │ │ └── potatoes.tsx │ │ │ ├── laptop-buying-guide/ │ │ │ │ ├── index.ts │ │ │ │ ├── laptopFull.tsx │ │ │ │ ├── laptopHalf.tsx │ │ │ │ ├── laptopOff.tsx │ │ │ │ └── laptopThreeFourth.tsx │ │ │ ├── los-angeles-city-guide/ │ │ │ │ ├── greenLocationPin.tsx │ │ │ │ └── index.ts │ │ │ ├── magazine-article/ │ │ │ │ ├── flightPath.tsx │ │ │ │ └── index.ts │ │ │ ├── modernist-travel-guide/ │ │ │ │ ├── index.ts │ │ │ │ └── linedCircle.tsx │ │ │ ├── new-york-party-round-up/ │ │ │ │ ├── index.ts │ │ │ │ ├── instagramIcon.tsx │ │ │ │ ├── partyCircleText.tsx │ │ │ │ ├── twitchIcon.tsx │ │ │ │ └── twitterIcon.tsx │ │ │ ├── no-days-off/ │ │ │ │ ├── cta.tsx │ │ │ │ └── index.ts │ │ │ ├── one-day-city-itinerary/ │ │ │ │ ├── creamSectionSeparator.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── offWhiteLocation.tsx │ │ │ │ ├── redSectionSeparator.tsx │ │ │ │ └── whiteLine.tsx │ │ │ ├── pizzas-in-nyc/ │ │ │ │ ├── arrowRight.tsx │ │ │ │ ├── cheese.tsx │ │ │ │ ├── chili.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── mediumBrushStroke.tsx │ │ │ │ ├── onion.tsx │ │ │ │ ├── thickBrushStroke.tsx │ │ │ │ ├── tiltedBrushStroke.tsx │ │ │ │ ├── tomatoes.tsx │ │ │ │ └── veggies.tsx │ │ │ ├── plant-based-dyes/ │ │ │ │ ├── bannerWithDots.tsx │ │ │ │ ├── greenLeaf.tsx │ │ │ │ ├── greenLeafShape.tsx │ │ │ │ ├── greenRoundBanner.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── offWhiteBanner.tsx │ │ │ │ ├── offWhiteSectionSeparator.tsx │ │ │ │ ├── plant.tsx │ │ │ │ ├── roundedBannerWithDots.tsx │ │ │ │ ├── squigglyLine.tsx │ │ │ │ ├── wavyLine.tsx │ │ │ │ ├── yellowDots.tsx │ │ │ │ ├── yellowLeaf.tsx │ │ │ │ └── yellowRoundBanner.tsx │ │ │ ├── rock-music-festival/ │ │ │ │ ├── flames.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── orangeLightning.tsx │ │ │ │ └── whiteLightning.tsx │ │ │ ├── sangria-artichoke/ │ │ │ │ ├── curvedArrow.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── openPill.tsx │ │ │ │ ├── pillBox.tsx │ │ │ │ ├── pills.tsx │ │ │ │ ├── scribbledArrow.tsx │ │ │ │ ├── scribbledUnderline.tsx │ │ │ │ ├── scribbledUnderline2.tsx │ │ │ │ └── scribbledUnderlineWhite.tsx │ │ │ ├── skin-care-at-home/ │ │ │ │ ├── combAndScissors.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── palmIcon.tsx │ │ │ │ ├── sleepMask.tsx │ │ │ │ ├── toiletries.tsx │ │ │ │ ├── towelTurban.tsx │ │ │ │ └── violetLotus.tsx │ │ │ ├── sleep/ │ │ │ │ ├── arrowDark.tsx │ │ │ │ ├── arrowLight.tsx │ │ │ │ ├── cta.tsx │ │ │ │ └── index.ts │ │ │ ├── street-style-on-the-go/ │ │ │ │ ├── index.ts │ │ │ │ └── tap.tsx │ │ │ ├── summer-adventure-guide/ │ │ │ │ ├── dashedTrail.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── plus.tsx │ │ │ │ ├── share.tsx │ │ │ │ └── terrainMap.tsx │ │ │ ├── summer-fashion-collection/ │ │ │ │ ├── index.ts │ │ │ │ ├── yellowHeart.tsx │ │ │ │ ├── yellowInstagram.tsx │ │ │ │ └── yellowStar.tsx │ │ │ ├── sustainability-tips/ │ │ │ │ ├── cloudBanner.tsx │ │ │ │ └── index.ts │ │ │ ├── technology-advice/ │ │ │ │ ├── grayFrameCorner.tsx │ │ │ │ ├── index.ts │ │ │ │ └── whiteFrameCorner.tsx │ │ │ ├── tips-for-throwing-an-outdoor-luau/ │ │ │ │ ├── greenFlower.tsx │ │ │ │ ├── greenLeafBanner.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── whiteDottedArrow.tsx │ │ │ │ ├── yellowFlower.tsx │ │ │ │ ├── yellowFlowerBanner.tsx │ │ │ │ └── yellowWavyLine.tsx │ │ │ ├── tv-show-recap/ │ │ │ │ ├── curvedDottedLine.tsx │ │ │ │ ├── discPieChart.tsx │ │ │ │ ├── dottedDiamond.tsx │ │ │ │ └── index.ts │ │ │ ├── types.ts │ │ │ ├── vintage-chairs-what-to-look-for/ │ │ │ │ ├── chair.tsx │ │ │ │ ├── chair02.tsx │ │ │ │ └── index.ts │ │ │ └── weekly-entertainment/ │ │ │ ├── entertainmentStar.tsx │ │ │ ├── index.ts │ │ │ └── musicNote.tsx │ │ └── tsconfig.json │ ├── stories-block/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── block/ │ │ │ │ ├── block-types/ │ │ │ │ │ ├── latest-stories/ │ │ │ │ │ │ └── edit.js │ │ │ │ │ ├── selected-stories/ │ │ │ │ │ │ ├── edit.js │ │ │ │ │ │ └── embedPlaceholder.js │ │ │ │ │ └── single-story/ │ │ │ │ │ ├── edit.css │ │ │ │ │ ├── edit.js │ │ │ │ │ ├── editInLoop.js │ │ │ │ │ ├── embedControls.js │ │ │ │ │ ├── embedControlsInLoop.js │ │ │ │ │ ├── embedLoading.js │ │ │ │ │ ├── embedPlaceholder.js │ │ │ │ │ ├── embedPreview.js │ │ │ │ │ ├── insertFromURLPopover.js │ │ │ │ │ ├── singleton.js │ │ │ │ │ ├── storyPlayer.js │ │ │ │ │ └── test/ │ │ │ │ │ ├── embedLoading.js │ │ │ │ │ ├── embedPlaceholder.js │ │ │ │ │ ├── embedPreview.js │ │ │ │ │ └── storyPlayer.js │ │ │ │ ├── block.js │ │ │ │ ├── components/ │ │ │ │ │ ├── authorSelection.js │ │ │ │ │ ├── autocomplete.js │ │ │ │ │ ├── blockTypeSwitcher.js │ │ │ │ │ ├── loaderContainer.js │ │ │ │ │ ├── storiesBlockConfigurationPanel.js │ │ │ │ │ ├── storiesBlockControls.js │ │ │ │ │ ├── storiesInspectorControls.js │ │ │ │ │ ├── storiesLoading.js │ │ │ │ │ ├── storiesPreview.js │ │ │ │ │ ├── storyCard.js │ │ │ │ │ ├── storyPicker/ │ │ │ │ │ │ ├── fetchSelectedStories.js │ │ │ │ │ │ ├── itemOverlay.js │ │ │ │ │ │ ├── selectStories.js │ │ │ │ │ │ ├── sortStories.js │ │ │ │ │ │ ├── storyPicker.js │ │ │ │ │ │ └── storyPreview.js │ │ │ │ │ ├── taxonomyItem.js │ │ │ │ │ └── test/ │ │ │ │ │ ├── blockTypeSwitcher.js │ │ │ │ │ ├── loaderContainer.js │ │ │ │ │ └── storyCard.js │ │ │ │ ├── constants.js │ │ │ │ ├── deprecated.js │ │ │ │ ├── edit.css │ │ │ │ ├── edit.js │ │ │ │ ├── icons.js │ │ │ │ ├── index.js │ │ │ │ ├── save.js │ │ │ │ ├── test/ │ │ │ │ │ ├── autocomplete.js │ │ │ │ │ ├── block.js │ │ │ │ │ ├── save.js │ │ │ │ │ └── storiesPlaceholder.js │ │ │ │ └── transforms.js │ │ │ ├── css/ │ │ │ │ ├── common.css │ │ │ │ ├── core-themes/ │ │ │ │ │ ├── twentyeleven.css │ │ │ │ │ ├── twentyfifteen.css │ │ │ │ │ ├── twentyfourteen.css │ │ │ │ │ ├── twentyseventeen.css │ │ │ │ │ ├── twentysixteen.css │ │ │ │ │ ├── twentyten.css │ │ │ │ │ ├── twentytwelve.css │ │ │ │ │ ├── twentytwenty.css │ │ │ │ │ └── twentytwentyone.css │ │ │ │ ├── embed.css │ │ │ │ ├── lightbox.css │ │ │ │ ├── singleton.css │ │ │ │ ├── style.css │ │ │ │ ├── variables.css │ │ │ │ └── views/ │ │ │ │ ├── carousel.css │ │ │ │ ├── circles.css │ │ │ │ ├── grid.css │ │ │ │ └── list.css │ │ │ ├── index.js │ │ │ ├── publicPath.js │ │ │ └── view.ts │ │ └── tsconfig.json │ ├── stories-carousel/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── @types/ │ │ │ │ └── global.d.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── story-editor/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ ├── apiProvider.tsx │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ └── apiProvider.js │ │ │ │ │ └── useAPI.ts │ │ │ │ ├── canvas/ │ │ │ │ │ ├── canvasProvider.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useAddPastedElements.ts │ │ │ │ │ ├── useCanvas.ts │ │ │ │ │ ├── useCanvasBoundingBox.ts │ │ │ │ │ ├── useCanvasCopyPaste.ts │ │ │ │ │ ├── useCanvasKeys.ts │ │ │ │ │ ├── useEditingElement.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── getLayerArrangementProps.ts │ │ │ │ │ └── test/ │ │ │ │ │ └── getLayerArrangementProps.js │ │ │ │ ├── config/ │ │ │ │ │ ├── configProvider.tsx │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useConfig.ts │ │ │ │ ├── currentUser/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── currentUserProvider.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useCurrentUser.ts │ │ │ │ ├── font/ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ └── useLoadFontFiles.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── fontProvider.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── actions/ │ │ │ │ │ │ └── useLoadFontFiles.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useFont.ts │ │ │ │ ├── helpCenter/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── provider.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── useHelpCenter/ │ │ │ │ │ │ ├── effects.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── test/ │ │ │ │ │ │ ├── effects.ts │ │ │ │ │ │ └── useHelpCenter.tsx │ │ │ │ │ └── useHelpCenterReducer.ts │ │ │ │ ├── highlights/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── provider.tsx │ │ │ │ │ ├── states.ts │ │ │ │ │ ├── styles.js │ │ │ │ │ └── useHighlights.ts │ │ │ │ ├── history/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── historyProvider.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── history.karma.js │ │ │ │ │ ├── reducer.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── reducer.js │ │ │ │ │ ├── useHistory.ts │ │ │ │ │ └── useHistoryReducer.ts │ │ │ │ ├── index.js │ │ │ │ ├── layout/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layoutProvider.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── useZoomSetting.js │ │ │ │ │ ├── useCarouselDrawer.ts │ │ │ │ │ ├── useLayout.ts │ │ │ │ │ └── useZoomSetting.ts │ │ │ │ ├── media/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── local/ │ │ │ │ │ │ ├── actions.js │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ ├── reducers/ │ │ │ │ │ │ │ ├── addAudioProcessing.js │ │ │ │ │ │ │ ├── addPosterProcessing.js │ │ │ │ │ │ │ ├── fetchMedia.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── prependMedia.js │ │ │ │ │ │ │ ├── removeAudioProcessing.js │ │ │ │ │ │ │ ├── removePosterProcessing.js │ │ │ │ │ │ │ ├── resetFilters.js │ │ │ │ │ │ │ ├── setMedia.js │ │ │ │ │ │ │ ├── setMediaType.js │ │ │ │ │ │ │ ├── setSearchTerm.js │ │ │ │ │ │ │ └── setupState.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ │ ├── useContextValueProvider.js │ │ │ │ │ │ │ └── useLocalMedia.js │ │ │ │ │ │ ├── typedefs.js │ │ │ │ │ │ ├── types.js │ │ │ │ │ │ ├── useContextValueProvider.js │ │ │ │ │ │ └── useLocalMedia.js │ │ │ │ │ ├── media3p/ │ │ │ │ │ │ ├── actions.js │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ ├── apiFetcher.js │ │ │ │ │ │ │ ├── context.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── media3pApiProvider.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── apiFetcher.js │ │ │ │ │ │ │ │ └── useMedia3pApi.js │ │ │ │ │ │ │ ├── typedefs.js │ │ │ │ │ │ │ └── useMedia3pApi.js │ │ │ │ │ │ ├── attribution.js │ │ │ │ │ │ ├── categories/ │ │ │ │ │ │ │ ├── actions.js │ │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── reducer.js │ │ │ │ │ │ │ ├── typedefs.js │ │ │ │ │ │ │ └── types.js │ │ │ │ │ │ ├── providerConfiguration.js │ │ │ │ │ │ ├── providerReducer.js │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── providerReducer.js │ │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ │ ├── useContextValueProvider.js │ │ │ │ │ │ │ ├── useFetchCategoriesEffect.js │ │ │ │ │ │ │ └── useFetchMediaEffect.js │ │ │ │ │ │ ├── typedefs.js │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── useContextValueProvider.js │ │ │ │ │ │ ├── useFetchCategoriesEffect.js │ │ │ │ │ │ ├── useFetchMediaEffect.js │ │ │ │ │ │ └── useProviderContextValueProvider.js │ │ │ │ │ ├── mediaProvider.js │ │ │ │ │ ├── pagination/ │ │ │ │ │ │ ├── actions.js │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── reducer.js │ │ │ │ │ │ └── types.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── useMediaReducer.js │ │ │ │ │ │ └── useUploadMedia.js │ │ │ │ │ ├── typedefs.js │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── uploadQueue/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── reducer.ts │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ │ └── useMediaUploadQueue.js │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── useMediaUploadQueue.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── useMedia.js │ │ │ │ │ ├── useMediaReducer.js │ │ │ │ │ ├── useUploadMedia.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── getPosterName.ts │ │ │ │ │ ├── getResourceFromLocalFile.ts │ │ │ │ │ ├── getResourceFromMedia3p.js │ │ │ │ │ ├── getResourceFromUrl.ts │ │ │ │ │ ├── heif/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useConvertHeif.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── getResourceFromMedia3p.js │ │ │ │ │ │ ├── useFFmpeg.js │ │ │ │ │ │ ├── useMediaInfo.tsx │ │ │ │ │ │ └── useProcessMedia.js │ │ │ │ │ ├── useDetectBaseColor.ts │ │ │ │ │ ├── useDetectBlurhash.js │ │ │ │ │ ├── useDetectVideoHasAudio.ts │ │ │ │ │ ├── useFFmpeg.ts │ │ │ │ │ ├── useMediaInfo.ts │ │ │ │ │ ├── useProcessMedia.ts │ │ │ │ │ ├── useUpdateElementDimensions.ts │ │ │ │ │ ├── useUploadVideoFrame.ts │ │ │ │ │ └── useVideoElementTranscoding.js │ │ │ │ ├── pageCanvas/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── getPageWithoutSelection.ts │ │ │ │ │ ├── getPixelDataFromCanvas.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── pageCanvasCacheValidator.ts │ │ │ │ │ ├── pageCanvasProvider.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── usePageCanvas.js │ │ │ │ │ ├── testUtils/ │ │ │ │ │ │ ├── createMockPage.js │ │ │ │ │ │ └── useStoryMock.js │ │ │ │ │ ├── useCalculateAccessibleTextColors.ts │ │ │ │ │ ├── usePageCanvas.ts │ │ │ │ │ ├── usePageCanvasMap.ts │ │ │ │ │ ├── usePageSnapshot.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── storyPageToCanvas.ts │ │ │ │ │ ├── storyPageToDataUrl.ts │ │ │ │ │ └── storyPageToNode.tsx │ │ │ │ ├── pageDataUrls/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── pageDataUrlsProvider.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── usePageDataUrls.js │ │ │ │ │ └── usePageDataUrls.ts │ │ │ │ ├── quickActions/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── mediaPicker.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── useQuickActions.js │ │ │ │ │ ├── useElementReset.js │ │ │ │ │ ├── useForegroundActions.js │ │ │ │ │ ├── useMediaActions.js │ │ │ │ │ ├── useQuickActions.js │ │ │ │ │ ├── useTextActions.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── getResetProperties.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── getResetProperties.js │ │ │ │ ├── rightClickMenu/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── testUtils.js │ │ │ │ │ │ │ ├── useCopyPasteActions.js │ │ │ │ │ │ │ ├── useElementActions.js │ │ │ │ │ │ │ ├── useLayerActions.js │ │ │ │ │ │ │ ├── usePageActions.js │ │ │ │ │ │ │ └── usePresetActions.js │ │ │ │ │ │ ├── useCopyPasteActions.js │ │ │ │ │ │ ├── useElementActions.js │ │ │ │ │ │ ├── useHeadingSelect.js │ │ │ │ │ │ ├── useLayerActions.js │ │ │ │ │ │ ├── usePageActions.js │ │ │ │ │ │ └── usePresetActions.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── items/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── layerHide.js │ │ │ │ │ │ ├── layerLock.js │ │ │ │ │ │ ├── layerName.js │ │ │ │ │ │ └── layerUngroup.js │ │ │ │ │ ├── menus/ │ │ │ │ │ │ ├── emptyStateMenu.js │ │ │ │ │ │ ├── foregroundMediaMenu.js │ │ │ │ │ │ ├── groupMenu.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── multipleElementsMenu.js │ │ │ │ │ │ ├── pageMenu.js │ │ │ │ │ │ ├── productMenu.js │ │ │ │ │ │ ├── shapeMenu.js │ │ │ │ │ │ ├── shared.js │ │ │ │ │ │ ├── stickerMenu.js │ │ │ │ │ │ └── textMenu.js │ │ │ │ │ ├── provider.js │ │ │ │ │ ├── reducer.js │ │ │ │ │ ├── useLayerSelect.js │ │ │ │ │ └── useRightClickMenu.js │ │ │ │ ├── story/ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── useAutoSave.js │ │ │ │ │ │ ├── useAutoSave.ts │ │ │ │ │ │ ├── useLocalAutoSave.ts │ │ │ │ │ │ └── useSaveStory.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── effects/ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── useHashState.js │ │ │ │ │ │ │ └── useLoadStory.js │ │ │ │ │ │ ├── useHashState.ts │ │ │ │ │ │ ├── useHistoryEntry.ts │ │ │ │ │ │ ├── useHistoryReplay.ts │ │ │ │ │ │ └── useLoadStory.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── storyProvider.tsx │ │ │ │ │ ├── storyTriggers/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── storyEvents/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── onInitialElementAddedRegister.ts │ │ │ │ │ │ │ ├── onPageAddedRegister.ts │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── onInitialElementAddedRegister.js │ │ │ │ │ │ │ │ └── onPageAddedRegister.js │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── storyTriggersProvider.tsx │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── useStoryTriggers.js │ │ │ │ │ │ └── useStoryTriggers.ts │ │ │ │ │ ├── useStory.ts │ │ │ │ │ ├── useStoryReducer/ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── reducer.ts │ │ │ │ │ │ ├── reducers/ │ │ │ │ │ │ │ ├── addAnimations.ts │ │ │ │ │ │ │ ├── addElements.ts │ │ │ │ │ │ │ ├── addElementsAcrossPages.ts │ │ │ │ │ │ │ ├── addGroup.ts │ │ │ │ │ │ │ ├── addPage.ts │ │ │ │ │ │ │ ├── arrangeElement.ts │ │ │ │ │ │ │ ├── arrangeGroup.ts │ │ │ │ │ │ │ ├── arrangePage.ts │ │ │ │ │ │ │ ├── combineElements.ts │ │ │ │ │ │ │ ├── copySelectedElement.ts │ │ │ │ │ │ │ ├── deleteElements.ts │ │ │ │ │ │ │ ├── deleteElementsByResourceId.ts │ │ │ │ │ │ │ ├── deleteGroup.ts │ │ │ │ │ │ │ ├── deletePage.ts │ │ │ │ │ │ │ ├── duplicateElementsById.ts │ │ │ │ │ │ │ ├── duplicateGroup.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── removeElementFromGroup.ts │ │ │ │ │ │ │ ├── restore.ts │ │ │ │ │ │ │ ├── selectElement.ts │ │ │ │ │ │ │ ├── setBackgroundElement.ts │ │ │ │ │ │ │ ├── setCurrentPage.ts │ │ │ │ │ │ │ ├── setSelectedElements.ts │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── utils.js │ │ │ │ │ │ │ ├── toggleElement.ts │ │ │ │ │ │ │ ├── toggleLayer.ts │ │ │ │ │ │ │ ├── unselectElement.ts │ │ │ │ │ │ │ ├── updateAnimationState.ts │ │ │ │ │ │ │ ├── updateElements.ts │ │ │ │ │ │ │ ├── updateElementsByFontFamily.ts │ │ │ │ │ │ │ ├── updateElementsByResourceId.ts │ │ │ │ │ │ │ ├── updateGroup.ts │ │ │ │ │ │ │ ├── updatePage.ts │ │ │ │ │ │ │ ├── updateStory.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ │ ├── addAnimations.js │ │ │ │ │ │ │ ├── addElement.js │ │ │ │ │ │ │ ├── addElementToSelection.js │ │ │ │ │ │ │ ├── addElements.js │ │ │ │ │ │ │ ├── addElementsAcrossPages.js │ │ │ │ │ │ │ ├── addGroup.js │ │ │ │ │ │ │ ├── addPage.js │ │ │ │ │ │ │ ├── addPageAt.js │ │ │ │ │ │ │ ├── arrangeElement.js │ │ │ │ │ │ │ ├── arrangeGroup.js │ │ │ │ │ │ │ ├── arrangePage.js │ │ │ │ │ │ │ ├── arrangeSelection.js │ │ │ │ │ │ │ ├── clearBackgroundElement.js │ │ │ │ │ │ │ ├── clearSelection.js │ │ │ │ │ │ │ ├── combineElements.js │ │ │ │ │ │ │ ├── copySelectedElement.js │ │ │ │ │ │ │ ├── deleteCurrentPage.js │ │ │ │ │ │ │ ├── deleteElementById.js │ │ │ │ │ │ │ ├── deleteElementsById.js │ │ │ │ │ │ │ ├── deleteElementsByResourceId.js │ │ │ │ │ │ │ ├── deleteGroupAndElementsById.js │ │ │ │ │ │ │ ├── deleteGroupById.js │ │ │ │ │ │ │ ├── deletePage.js │ │ │ │ │ │ │ ├── deleteSelectedElements.js │ │ │ │ │ │ │ ├── duplicateElementsById.js │ │ │ │ │ │ │ ├── duplicateGroupById.js │ │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ │ ├── removeElementFromGroup.js │ │ │ │ │ │ │ ├── removeElementFromSelection.js │ │ │ │ │ │ │ ├── restore.js │ │ │ │ │ │ │ ├── setBackgroundElement.js │ │ │ │ │ │ │ ├── setCurrentPage.js │ │ │ │ │ │ │ ├── setSelectedElementsById.js │ │ │ │ │ │ │ ├── toggleElementInSelection.js │ │ │ │ │ │ │ ├── toggleLayer.js │ │ │ │ │ │ │ ├── updateAnimationState.js │ │ │ │ │ │ │ ├── updateCurrentPageProperties.js │ │ │ │ │ │ │ ├── updateElementById.js │ │ │ │ │ │ │ ├── updateElementsByFontFamily.js │ │ │ │ │ │ │ ├── updateElementsById.js │ │ │ │ │ │ │ ├── updateElementsByResourceId.js │ │ │ │ │ │ │ ├── updateGroupById.js │ │ │ │ │ │ │ ├── updatePageProperties.js │ │ │ │ │ │ │ ├── updateSelectedElements.js │ │ │ │ │ │ │ └── updateStory.js │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── useStoryReducer.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── cleanElementFontProperties.ts │ │ │ │ │ ├── deleteNestedKeys.ts │ │ │ │ │ ├── getAllProducts.ts │ │ │ │ │ ├── getStoryFontsFromPages.ts │ │ │ │ │ ├── getStoryPropsToSave.ts │ │ │ │ │ ├── isEmptyStory.ts │ │ │ │ │ ├── pageContainsBlobUrl.ts │ │ │ │ │ └── test/ │ │ │ │ │ ├── cleanElementFontPropties.js │ │ │ │ │ ├── deleteNestedKeys.js │ │ │ │ │ ├── getAllProduct.js │ │ │ │ │ ├── getStoryFontsFromPages.js │ │ │ │ │ ├── getStoryPropsToSave.js │ │ │ │ │ ├── isEmptyStory.js │ │ │ │ │ └── pageContainsBlobUrl.js │ │ │ │ ├── taxonomy/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── taxonomyProvider.tsx │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── useTaxonomy.js │ │ │ │ │ └── useTaxonomy.ts │ │ │ │ ├── uploader/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── useUploader.js │ │ │ │ │ └── useUploader.ts │ │ │ │ └── userOnboarding/ │ │ │ │ ├── index.js │ │ │ │ ├── test/ │ │ │ │ │ └── useUserOnboarding.js │ │ │ │ └── useUserOnboarding.js │ │ │ ├── components/ │ │ │ │ ├── autoSaveCheck/ │ │ │ │ │ ├── autoSaveDialog.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── autoSaveDialog.js │ │ │ │ ├── autoSaveHandler/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── autoSaveHandler.js │ │ │ │ ├── canvas/ │ │ │ │ │ ├── canvas.js │ │ │ │ │ ├── canvasElementDropzone.js │ │ │ │ │ ├── canvasLayout.js │ │ │ │ │ ├── canvasUploadDropTarget.js │ │ │ │ │ ├── displayElement.d.ts │ │ │ │ │ ├── displayElement.js │ │ │ │ │ ├── displayLayer.js │ │ │ │ │ ├── editElement.js │ │ │ │ │ ├── editLayer.js │ │ │ │ │ ├── editLayerFocusManager/ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── context.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── keyBindings.js │ │ │ │ │ │ ├── provider.js │ │ │ │ │ │ ├── reduction.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── EditLayerFocusManager.js │ │ │ │ │ │ ├── useEditLayerFocusManager.js │ │ │ │ │ │ └── useFocusGroupRef.js │ │ │ │ │ ├── emptyStateLayer.js │ │ │ │ │ ├── extraPages.js │ │ │ │ │ ├── eyedropperLayer.js │ │ │ │ │ ├── frameElement.js │ │ │ │ │ ├── framesLayer.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── backgroundCopyPaste.karma.js │ │ │ │ │ │ ├── canvasKeyboardNavigation.karma.js │ │ │ │ │ │ ├── canvasKeys.karma.js │ │ │ │ │ │ ├── carousel.karma.js │ │ │ │ │ │ ├── cloneSelection.karma.js │ │ │ │ │ │ ├── elementKeyboardNavigation.js │ │ │ │ │ │ ├── fullbleedMediaAsBackground.karma.js │ │ │ │ │ │ ├── keys.karma.js │ │ │ │ │ │ ├── lasso.karma.js │ │ │ │ │ │ ├── multiSelectionMovable.karma.js │ │ │ │ │ │ ├── pageMenuActions.karma.js │ │ │ │ │ │ ├── pageSideMenuActions.karma.js │ │ │ │ │ │ ├── quickActions.karma.js │ │ │ │ │ │ ├── rightClickMenu.karma.js │ │ │ │ │ │ ├── rtl.karma.js │ │ │ │ │ │ ├── selection.karma.js │ │ │ │ │ │ └── snapping.karma.js │ │ │ │ │ ├── layout.js │ │ │ │ │ ├── mediaCaptions/ │ │ │ │ │ │ ├── cue.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── mediaCaptionsLayer.js │ │ │ │ │ │ ├── parseTimestamp.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── parseTimestamp.js │ │ │ │ │ │ └── trackRenderer.js │ │ │ │ │ ├── mediaRecordingLayer.js │ │ │ │ │ ├── multiSelectionMoveable/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── useDrag.js │ │ │ │ │ │ ├── useResize.js │ │ │ │ │ │ └── useRotate.js │ │ │ │ │ ├── navLayer.js │ │ │ │ │ ├── pageAttachment/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ └── pageAttachment.cuj.karma.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── pageAttachment.js │ │ │ │ │ ├── pageSideMenu.js │ │ │ │ │ ├── pagemenu/ │ │ │ │ │ │ ├── animationToggle.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── pageMenu.js │ │ │ │ │ │ └── pageMenuButton.js │ │ │ │ │ ├── pagenav/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── renderResourcePlaceholder.js │ │ │ │ │ ├── rightClickMenu.js │ │ │ │ │ ├── selection.js │ │ │ │ │ ├── selectionCanvas.js │ │ │ │ │ ├── shoppingPageAttachment/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── singleSelectionMoveable/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── useDrag.js │ │ │ │ │ │ ├── useResize.js │ │ │ │ │ │ └── useRotate.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── mediaDisplay.js.snap │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ ├── frame.js │ │ │ │ │ │ ├── frameElement.js │ │ │ │ │ │ ├── mediaDisplay.js │ │ │ │ │ │ ├── multiSelectionMoveable.js │ │ │ │ │ │ ├── singleSelectionMoveable.js │ │ │ │ │ │ ├── useCanvasKeys.js │ │ │ │ │ │ └── useInsertElement.js │ │ │ │ │ ├── useFocusCanvas.ts │ │ │ │ │ ├── useInsertElement.ts │ │ │ │ │ ├── useInsertTextSet.js │ │ │ │ │ ├── usePinchToZoom.js │ │ │ │ │ ├── useUploadWithPreview.d.ts │ │ │ │ │ ├── useUploadWithPreview.js │ │ │ │ │ ├── useWindowResizeHandler.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── getColorFromPixelData.js │ │ │ │ │ ├── getElementProperties.ts │ │ │ │ │ ├── normalizeRotationDegrees.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── getElementProperties.js │ │ │ │ │ ├── useElementOutOfCanvas.js │ │ │ │ │ ├── useFullbleedMediaAsBackground.js │ │ │ │ │ ├── useSnapping.js │ │ │ │ │ └── useUpdateSelectionRectangle.js │ │ │ │ ├── checklist/ │ │ │ │ │ ├── checklist.js │ │ │ │ │ ├── checklistContent/ │ │ │ │ │ │ ├── accessibilityChecks.js │ │ │ │ │ │ ├── designChecks.js │ │ │ │ │ │ ├── emptyContent.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── priorityChecks.js │ │ │ │ │ ├── checklistContext/ │ │ │ │ │ │ ├── context.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── provider.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── useChecklist.js │ │ │ │ │ │ └── useChecklist.js │ │ │ │ │ ├── checklistIcon.js │ │ │ │ │ ├── checkpointContext/ │ │ │ │ │ │ ├── checkpointContext.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── checks/ │ │ │ │ │ │ ├── elementLinkTappableRegionTooBig.js │ │ │ │ │ │ ├── elementLinkTappableRegionTooSmall.js │ │ │ │ │ │ ├── firstPageAnimation.js │ │ │ │ │ │ ├── imageElementMissingAlt.js │ │ │ │ │ │ ├── imageElementResolution.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── pageBackgroundLowTextContrast/ │ │ │ │ │ │ │ ├── check.js │ │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── pageTooLittleText.js │ │ │ │ │ │ ├── pageTooManyLinks.js │ │ │ │ │ │ ├── pageTooMuchText.js │ │ │ │ │ │ ├── publisherLogoMissing.js │ │ │ │ │ │ ├── publisherLogoSize.js │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── thumbnailWrapper.js │ │ │ │ │ │ │ ├── thumbnailWrapper.js │ │ │ │ │ │ │ └── videoChecklistCard.js │ │ │ │ │ │ ├── storyAmpValidationErrors.js │ │ │ │ │ │ ├── storyMissingExcerpt.js │ │ │ │ │ │ ├── storyMissingTitle.js │ │ │ │ │ │ ├── storyPagesCount.js │ │ │ │ │ │ ├── storyPosterAttached.js │ │ │ │ │ │ ├── storyPosterSize.js │ │ │ │ │ │ ├── storyTitleLength.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── elementLinkTappableRegionTooBig.js │ │ │ │ │ │ │ ├── elementLinkTappableRegionTooSmall.js │ │ │ │ │ │ │ ├── firstPageAnimation.js │ │ │ │ │ │ │ ├── imageElementMissingAlt.js │ │ │ │ │ │ │ ├── imageElementResolution.js │ │ │ │ │ │ │ ├── pageBackgroundLowTextContrast.js │ │ │ │ │ │ │ ├── pageTooLittleText.js │ │ │ │ │ │ │ ├── pageTooManyLinks.js │ │ │ │ │ │ │ ├── pageTooMuchText.js │ │ │ │ │ │ │ ├── publisherLogoMissing.js │ │ │ │ │ │ │ ├── publisherLogoSize.js │ │ │ │ │ │ │ ├── storyAmpValidationErrors.js │ │ │ │ │ │ │ ├── storyMissingExcerpt.js │ │ │ │ │ │ │ ├── storyMissingTitle.js │ │ │ │ │ │ │ ├── storyPagesCount.js │ │ │ │ │ │ │ ├── storyPosterAttached.js │ │ │ │ │ │ │ ├── storyPosterSize.js │ │ │ │ │ │ │ ├── storyTitleLength.js │ │ │ │ │ │ │ ├── textElementFontSizeTooSmall.js │ │ │ │ │ │ │ ├── videoElementLength.js │ │ │ │ │ │ │ ├── videoElementMissingCaptions.js │ │ │ │ │ │ │ ├── videoElementMissingDescription.js │ │ │ │ │ │ │ ├── videoElementMissingPoster.js │ │ │ │ │ │ │ ├── videoElementResolution.js │ │ │ │ │ │ │ └── videoOptimization.js │ │ │ │ │ │ ├── textElementFontSizeTooSmall.js │ │ │ │ │ │ ├── videoElementLength.js │ │ │ │ │ │ ├── videoElementMissingCaptions.js │ │ │ │ │ │ ├── videoElementMissingDescription.js │ │ │ │ │ │ ├── videoElementMissingPoster.js │ │ │ │ │ │ ├── videoElementResolution.js │ │ │ │ │ │ └── videoOptimization.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── countContext/ │ │ │ │ │ │ ├── checkCountContext.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── checkCountContext.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── checklist.karma.js │ │ │ │ │ │ ├── firstPageAnimation.karma.js │ │ │ │ │ │ └── prepublishSelect.karma.js │ │ │ │ │ ├── popupMountedContext.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styles.js │ │ │ │ │ ├── toggle/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── characterCountForPage.js │ │ │ │ │ ├── filterStoryElements.js │ │ │ │ │ ├── filterStoryPages.js │ │ │ │ │ ├── getVisibleThumbnails.js │ │ │ │ │ ├── hasNoFeaturedMedia.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── filterStoryElements.js │ │ │ │ │ │ └── filterStoryPages.js │ │ │ │ │ └── thumbnailPagePreview.js │ │ │ │ ├── checklistCard/ │ │ │ │ │ ├── checkboxCta.js │ │ │ │ │ ├── checklistCard.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── defaultCtaButton.js │ │ │ │ │ ├── defaultFooterText.js │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── getGridVariant.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── styles.js │ │ │ │ ├── colorPicker/ │ │ │ │ │ ├── addCustomColor.js │ │ │ │ │ ├── basicColorList.js │ │ │ │ │ ├── basicColorPicker.js │ │ │ │ │ ├── colorAdd.js │ │ │ │ │ ├── colorPicker.js │ │ │ │ │ ├── confirmationDialog.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── currentColorPicker.js │ │ │ │ │ ├── customColorPicker.js │ │ │ │ │ ├── editablePreview.js │ │ │ │ │ ├── gradientLine.js │ │ │ │ │ ├── gradientPicker.js │ │ │ │ │ ├── gradientStop.js │ │ │ │ │ ├── header.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── insertStop.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── colorPicker.karma.js │ │ │ │ │ │ └── eyedropper.karma.js │ │ │ │ │ ├── patternTypePicker.js │ │ │ │ │ ├── pointer.js │ │ │ │ │ ├── regenerateColor.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── colorPicker/ │ │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ │ ├── addStopByPointer.js │ │ │ │ │ │ │ ├── colorInteraction.js │ │ │ │ │ │ │ ├── footerInteraction.js │ │ │ │ │ │ │ ├── headerInteraction.js │ │ │ │ │ │ │ ├── manipulateGradient.js │ │ │ │ │ │ │ ├── manipulateStopByKeyboard.js │ │ │ │ │ │ │ ├── moveStopByPointer.js │ │ │ │ │ │ │ ├── onclose.js │ │ │ │ │ │ │ ├── onload.js │ │ │ │ │ │ │ ├── savedColors.js │ │ │ │ │ │ │ └── selectStop.js │ │ │ │ │ │ ├── insertStop.js │ │ │ │ │ │ ├── regenerateColor.js │ │ │ │ │ │ └── useColor/ │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ ├── addStopAt.js │ │ │ │ │ │ ├── load.js │ │ │ │ │ │ ├── moveCurrentStopBy.js │ │ │ │ │ │ ├── removeCurrentStop.js │ │ │ │ │ │ ├── reverseStops.js │ │ │ │ │ │ ├── rotateClockwise.js │ │ │ │ │ │ ├── selectStop.js │ │ │ │ │ │ ├── setToGradient.js │ │ │ │ │ │ ├── setToSolid.js │ │ │ │ │ │ └── updateCurrentColor.js │ │ │ │ │ ├── useColor.js │ │ │ │ │ ├── useDeleteColor.js │ │ │ │ │ ├── useKeyAddStop.js │ │ │ │ │ ├── useKeyDeleteStop.js │ │ │ │ │ ├── useKeyFocus.js │ │ │ │ │ ├── useKeyMoveStop.js │ │ │ │ │ ├── usePointerAddStop.js │ │ │ │ │ ├── usePointerMoveStop.js │ │ │ │ │ └── utils.js │ │ │ │ ├── devTools/ │ │ │ │ │ ├── devTools.js │ │ │ │ │ ├── dummyData.js │ │ │ │ │ └── index.js │ │ │ │ ├── dialog/ │ │ │ │ │ ├── dialog.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── directionAware/ │ │ │ │ │ └── index.js │ │ │ │ ├── dropTargets/ │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── dropTarget.karma.js │ │ │ │ │ │ └── order.karma.js │ │ │ │ │ ├── provider.js │ │ │ │ │ └── useDropTargets.js │ │ │ │ ├── elementLink/ │ │ │ │ │ ├── frame.js │ │ │ │ │ └── index.js │ │ │ │ ├── emptyContentMessage.js │ │ │ │ ├── errorBoundary/ │ │ │ │ │ ├── copyStoryDataToClipboard.js │ │ │ │ │ ├── errorActions.js │ │ │ │ │ └── index.js │ │ │ │ ├── eyedropper/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── useEyeDropperApi.js │ │ │ │ ├── floatingMenu/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── context/ │ │ │ │ │ │ ├── context.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── provider.js │ │ │ │ │ │ └── use.js │ │ │ │ │ ├── elements/ │ │ │ │ │ │ ├── borderRadius.js │ │ │ │ │ │ ├── borderWidthAndColor.js │ │ │ │ │ │ ├── dismiss.js │ │ │ │ │ │ ├── elementAlignment.js │ │ │ │ │ │ ├── flipHorizontal.js │ │ │ │ │ │ ├── flipVertical.js │ │ │ │ │ │ ├── fontFamily.js │ │ │ │ │ │ ├── fontSize.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ ├── border.karma.js │ │ │ │ │ │ │ ├── borderRadius.karma.js │ │ │ │ │ │ │ ├── flip.karma.js │ │ │ │ │ │ │ ├── loop.karma.js │ │ │ │ │ │ │ ├── more.karma.js │ │ │ │ │ │ │ ├── opacity.karma.js │ │ │ │ │ │ │ ├── shopping.karma.js │ │ │ │ │ │ │ ├── text.karma.js │ │ │ │ │ │ │ └── textAlign.karma.js │ │ │ │ │ │ ├── layerOpacity.js │ │ │ │ │ │ ├── loop.js │ │ │ │ │ │ ├── more.js │ │ │ │ │ │ ├── mute.js │ │ │ │ │ │ ├── settings.js │ │ │ │ │ │ ├── shapeColor.js │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── color.js │ │ │ │ │ │ │ ├── focusTrapButton.js │ │ │ │ │ │ │ ├── icon.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── input.js │ │ │ │ │ │ │ ├── separator.js │ │ │ │ │ │ │ ├── text.js │ │ │ │ │ │ │ ├── toggleButton.js │ │ │ │ │ │ │ ├── useFlip.ts │ │ │ │ │ │ │ ├── useProperties.ts │ │ │ │ │ │ │ └── useTextToggle.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── mute.js │ │ │ │ │ │ │ └── trim.js │ │ │ │ │ │ ├── textAlign.js │ │ │ │ │ │ ├── textColor.js │ │ │ │ │ │ ├── toggleBold.js │ │ │ │ │ │ ├── toggleItalics.js │ │ │ │ │ │ ├── toggleUnderline.js │ │ │ │ │ │ ├── trash.js │ │ │ │ │ │ └── trim.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── floatingMenu.karma.js │ │ │ │ │ │ ├── image.karma.js │ │ │ │ │ │ ├── shape.karma.js │ │ │ │ │ │ ├── sticker.karma.js │ │ │ │ │ │ ├── text.karma.js │ │ │ │ │ │ ├── utils.js │ │ │ │ │ │ └── video.karma.js │ │ │ │ │ ├── layer.js │ │ │ │ │ ├── menu.js │ │ │ │ │ ├── menus/ │ │ │ │ │ │ ├── audioSticker.js │ │ │ │ │ │ ├── image.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── multiple.js │ │ │ │ │ │ ├── product.js │ │ │ │ │ │ ├── selector.js │ │ │ │ │ │ ├── shape.js │ │ │ │ │ │ ├── sticker.js │ │ │ │ │ │ ├── text.js │ │ │ │ │ │ └── video.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── fontPicker/ │ │ │ │ │ └── index.js │ │ │ │ ├── footer/ │ │ │ │ │ ├── carousel/ │ │ │ │ │ │ ├── carouselContainer.js │ │ │ │ │ │ ├── carouselContext/ │ │ │ │ │ │ │ ├── carouselProvider.tsx │ │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ ├── useCarousel.ts │ │ │ │ │ │ │ ├── useCarouselKeys.ts │ │ │ │ │ │ │ ├── useCarouselScroll.ts │ │ │ │ │ │ │ └── useCarouselSizing.ts │ │ │ │ │ │ ├── carouselDrawer.js │ │ │ │ │ │ ├── carouselDrawerIcon.js │ │ │ │ │ │ ├── carouselLayout.js │ │ │ │ │ │ ├── carouselList.js │ │ │ │ │ │ ├── carouselPage.js │ │ │ │ │ │ ├── carouselScroll.js │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ ├── carouselDrawer.karma.js │ │ │ │ │ │ │ └── carouselNavigation.karma.js │ │ │ │ │ │ └── skeletonPage.tsx │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── footer.js │ │ │ │ │ ├── footerLayout.js │ │ │ │ │ ├── gridview/ │ │ │ │ │ │ ├── gridView.js │ │ │ │ │ │ ├── gridViewButton.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ └── gridView.karma.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── gridview.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── footerMenu.karma.js │ │ │ │ │ │ ├── popups.karma.js │ │ │ │ │ │ └── zoomSelector.karma.js │ │ │ │ │ ├── layers/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── layers.js │ │ │ │ │ ├── pagepreview/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── primaryMenu.js │ │ │ │ │ ├── secondaryMenu.js │ │ │ │ │ ├── toolbarToggle.js │ │ │ │ │ ├── useFooterHeight.js │ │ │ │ │ └── zoomSelector/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── zoomSelector.js │ │ │ │ ├── form/ │ │ │ │ │ ├── color/ │ │ │ │ │ │ ├── activeOpacity.js │ │ │ │ │ │ ├── applyOpacityChange.js │ │ │ │ │ │ ├── color.js │ │ │ │ │ │ ├── colorInput.js │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── getPreviewOpacity.js │ │ │ │ │ │ ├── getPreviewStyle.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── opacityInput.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ ├── applyOpacityChange.js │ │ │ │ │ │ ├── color.js │ │ │ │ │ │ ├── colorInput.js │ │ │ │ │ │ ├── getPreviewOpacity.js │ │ │ │ │ │ ├── getPreviewStyle.js │ │ │ │ │ │ └── opacityInput.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── dateTime/ │ │ │ │ │ │ ├── calendarWrapper.js │ │ │ │ │ │ ├── datePicker.js │ │ │ │ │ │ ├── dateTime.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── timePicker.js │ │ │ │ │ │ ├── timeZone.js │ │ │ │ │ │ └── utils.js │ │ │ │ │ ├── filterToggle/ │ │ │ │ │ │ ├── filterToggle.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── hierarchical/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── hierarchical.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── hierarchical.js │ │ │ │ │ │ │ └── utils.js │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── link.js │ │ │ │ │ ├── linkIcon.js │ │ │ │ │ ├── media.js │ │ │ │ │ ├── mediaUploadButton.js │ │ │ │ │ ├── radioGroup/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── required.js │ │ │ │ │ ├── row.js │ │ │ │ │ ├── select/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── select.js │ │ │ │ │ ├── shared/ │ │ │ │ │ │ └── useRadioNavigation.js │ │ │ │ │ ├── stackable/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stackableGroup.js │ │ │ │ │ │ └── stackableInput.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── link.js │ │ │ │ │ │ ├── radioGroup.js │ │ │ │ │ │ ├── switch.js │ │ │ │ │ │ └── tags.js │ │ │ │ │ ├── switch.js │ │ │ │ │ ├── tags/ │ │ │ │ │ │ ├── description.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── input.js │ │ │ │ │ │ ├── label.js │ │ │ │ │ │ ├── reducer.js │ │ │ │ │ │ ├── tag.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ ├── input.js │ │ │ │ │ │ └── reducer.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── radioGroup.js │ │ │ │ │ │ └── switch.js │ │ │ │ │ ├── textArea.js │ │ │ │ │ ├── useFormContext.js │ │ │ │ │ ├── useHotlink.js │ │ │ │ │ └── usePresubmitHandler.js │ │ │ │ ├── header/ │ │ │ │ │ ├── buttons/ │ │ │ │ │ │ ├── buttonWithChecklistWarning.js │ │ │ │ │ │ ├── historyButtons.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── preview.js │ │ │ │ │ │ ├── publish.js │ │ │ │ │ │ ├── switchToDraft.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── preview.js │ │ │ │ │ │ │ ├── publish.js │ │ │ │ │ │ │ ├── switchToDraft.js │ │ │ │ │ │ │ └── update.js │ │ │ │ │ │ └── update.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── previewErrorDialog.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── previewErrorDialog.js │ │ │ │ │ └── title.js │ │ │ │ ├── helpCenter/ │ │ │ │ │ ├── companion/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── helpCenter.karma.js │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── header.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── tips.js │ │ │ │ │ │ └── transitioner.js │ │ │ │ │ ├── navigator/ │ │ │ │ │ │ ├── bottomNavigation.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── utils.js │ │ │ │ │ ├── quickTip/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── transitioner.js │ │ │ │ │ ├── toggle/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── utils.js │ │ │ │ ├── hideOnError.tsx │ │ │ │ ├── hotlinkModal/ │ │ │ │ │ ├── hotlinkModal.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── hotlinkDialog.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── isValidUrlForHotlinking.js │ │ │ │ │ ├── useHotlinkModal.js │ │ │ │ │ └── utils.js │ │ │ │ ├── keyboardShortcutsMenu/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── headerShortcut.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── keyboardShortcuts.karma.js │ │ │ │ │ ├── keyboardShortcutList.js │ │ │ │ │ ├── keyboardShortcutsMenuContext/ │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── provider.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── useKeyboardShortcutsMenu.ts │ │ │ │ │ ├── landmarkShortcuts.js │ │ │ │ │ ├── regularShortcuts.js │ │ │ │ │ ├── shortcutLabel.js │ │ │ │ │ ├── shortcutMenu.js │ │ │ │ │ ├── shortcutMenuSection.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── layout/ │ │ │ │ │ └── index.js │ │ │ │ ├── library/ │ │ │ │ │ ├── common/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── searchInput.js │ │ │ │ │ │ ├── section.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── searchInput.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ ├── libraryTabs.karma.js │ │ │ │ │ │ ├── mediaTab.karma.js │ │ │ │ │ │ └── shapes/ │ │ │ │ │ │ └── shapes.karma.js │ │ │ │ │ ├── library.js │ │ │ │ │ ├── libraryLayout.js │ │ │ │ │ ├── libraryPanes.js │ │ │ │ │ ├── libraryProvider.js │ │ │ │ │ ├── libraryUploadDropTarget.js │ │ │ │ │ ├── paneIds.ts │ │ │ │ │ ├── panes/ │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ └── styles.js │ │ │ │ │ │ ├── media/ │ │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ │ ├── attribution.js │ │ │ │ │ │ │ │ ├── innerElement.js │ │ │ │ │ │ │ │ ├── insertionMenu.js │ │ │ │ │ │ │ │ ├── mediaElement.js │ │ │ │ │ │ │ │ ├── mediaGallery.js │ │ │ │ │ │ │ │ ├── paginatedMediaGallery.js │ │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ │ ├── mediaElement.js │ │ │ │ │ │ │ │ │ └── mediaGallery.js │ │ │ │ │ │ │ │ ├── styles.js │ │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ │ ├── accessibility.js │ │ │ │ │ │ │ │ └── paginatedMediaGallery.js │ │ │ │ │ │ │ ├── local/ │ │ │ │ │ │ │ │ ├── deleteDialog.js │ │ │ │ │ │ │ │ ├── dropDownMenu.js │ │ │ │ │ │ │ │ ├── hotlink/ │ │ │ │ │ │ │ │ │ ├── hotlink.js │ │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ │ └── useInsert.js │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ │ ├── hotlink.karma.js │ │ │ │ │ │ │ │ │ └── mediaFetching.karma.js │ │ │ │ │ │ │ │ ├── mediaEditDialog.js │ │ │ │ │ │ │ │ ├── mediaIcon.js │ │ │ │ │ │ │ │ ├── mediaPane.js │ │ │ │ │ │ │ │ ├── mediaRecording/ │ │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ │ └── mediaRecording.js │ │ │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ │ ├── deleteDialog.js │ │ │ │ │ │ │ │ │ ├── mediaEditDialog.js │ │ │ │ │ │ │ │ │ └── videoOptimizationDialog.js │ │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ │ └── videoOptimizationDialog.js │ │ │ │ │ │ │ │ ├── useOnMediaSelect.js │ │ │ │ │ │ │ │ └── videoOptimizationDialog.js │ │ │ │ │ │ │ └── media3p/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── mediaFetching.karma.js │ │ │ │ │ │ │ ├── media3pIcon.js │ │ │ │ │ │ │ ├── media3pPane.js │ │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ │ ├── providerPanel.js │ │ │ │ │ │ │ ├── providerTab.js │ │ │ │ │ │ │ ├── providerTabList.js │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ └── termsDialog.js │ │ │ │ │ │ │ ├── termsDialog.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ ├── accessibility.js │ │ │ │ │ │ │ └── media3pPane.js │ │ │ │ │ │ ├── pageTemplates/ │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ ├── defaultPageTemplate.js │ │ │ │ │ │ │ ├── defaultTemplates.js │ │ │ │ │ │ │ ├── deleteDialog.js │ │ │ │ │ │ │ ├── dropDownMenu.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ ├── defaultPageTemplates.karma.js │ │ │ │ │ │ │ │ └── savedPageTemplates.karma.js │ │ │ │ │ │ │ ├── nameDialog.js │ │ │ │ │ │ │ ├── pageTemplatesIcon.js │ │ │ │ │ │ │ ├── pageTemplatesPane.js │ │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ │ ├── savedPageTemplate.js │ │ │ │ │ │ │ ├── savedTemplates.js │ │ │ │ │ │ │ ├── templateList.js │ │ │ │ │ │ │ └── templateSave.js │ │ │ │ │ │ ├── shapes/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ │ ├── shapePreview.js │ │ │ │ │ │ │ ├── shapesIcon.js │ │ │ │ │ │ │ ├── shapesPane.js │ │ │ │ │ │ │ └── stickerPreview.js │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── chipGroup/ │ │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ ├── useExpandAnimation.js │ │ │ │ │ │ │ │ └── useHandleRowVisibility.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── insertionOverlay.js │ │ │ │ │ │ │ ├── libraryMoveable.js │ │ │ │ │ │ │ └── virtualizedPanelGrid/ │ │ │ │ │ │ │ ├── components.js │ │ │ │ │ │ │ ├── getVirtualizedItemIndex.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── getVirtualizedItemIndex.js │ │ │ │ │ │ │ │ └── useVirtualizedGridNavigation.js │ │ │ │ │ │ │ └── useVirtualizedGridNavigation.js │ │ │ │ │ │ ├── shopping/ │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── shopping.karma.js │ │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ │ ├── product.js │ │ │ │ │ │ │ ├── productButton.js │ │ │ │ │ │ │ ├── productDropdown.js │ │ │ │ │ │ │ ├── productImage.js │ │ │ │ │ │ │ ├── productList.js │ │ │ │ │ │ │ ├── productPrice.js │ │ │ │ │ │ │ ├── productSort.js │ │ │ │ │ │ │ ├── shoppingIcon.js │ │ │ │ │ │ │ ├── shoppingPane.js │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ └── useProductNavigation.js │ │ │ │ │ │ └── text/ │ │ │ │ │ │ ├── fontPreview.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ ├── savedStyles.karma.js │ │ │ │ │ │ │ ├── textPane.karma.js │ │ │ │ │ │ │ └── textSets.cuj.karma.js │ │ │ │ │ │ ├── paneId.js │ │ │ │ │ │ ├── stylePresets/ │ │ │ │ │ │ │ └── stylePresets.js │ │ │ │ │ │ ├── textIcon.js │ │ │ │ │ │ ├── textPane.js │ │ │ │ │ │ ├── textPresets.ts │ │ │ │ │ │ ├── textSets/ │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ └── textSet.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── textSet.js │ │ │ │ │ │ │ │ └── textSetsPane.js │ │ │ │ │ │ │ ├── textSet.js │ │ │ │ │ │ │ ├── textSetElements.js │ │ │ │ │ │ │ ├── textSets.js │ │ │ │ │ │ │ └── textSetsPane.js │ │ │ │ │ │ └── useInsertPreset.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── _utils/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── deleteDialog.js │ │ │ │ │ │ ├── mediaEditDialog.js │ │ │ │ │ │ ├── mediaElement.js │ │ │ │ │ │ ├── shapes/ │ │ │ │ │ │ │ ├── shapePreview.js │ │ │ │ │ │ │ └── stickerPreview.js │ │ │ │ │ │ ├── termsDialog.js │ │ │ │ │ │ └── text/ │ │ │ │ │ │ └── textPane.js │ │ │ │ │ └── useLibrary.js │ │ │ │ ├── localAutoSaveHandler/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── localAutoSave.js │ │ │ │ │ └── test/ │ │ │ │ │ └── localAutoSaveHandler.js │ │ │ │ ├── mediaRecording/ │ │ │ │ │ ├── audio.js │ │ │ │ │ ├── blur.js │ │ │ │ │ ├── components.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── countdown.js │ │ │ │ │ ├── durationIndicator.js │ │ │ │ │ ├── errorDialog.js │ │ │ │ │ ├── footer.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── mediaRecording.karma.js │ │ │ │ │ ├── mediaRecording.js │ │ │ │ │ ├── permissionsDialog.js │ │ │ │ │ ├── playPauseButton.js │ │ │ │ │ ├── playbackMedia.js │ │ │ │ │ ├── processingOverlay.js │ │ │ │ │ ├── progressBar.js │ │ │ │ │ ├── provider.js │ │ │ │ │ ├── settingsModal.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── countdown.js │ │ │ │ │ │ ├── durationIndicator.js │ │ │ │ │ │ ├── errorDialog.js │ │ │ │ │ │ ├── permissionsDialog.js │ │ │ │ │ │ ├── settingsModal.js │ │ │ │ │ │ └── videoMode.js │ │ │ │ │ ├── useMediaRecording.js │ │ │ │ │ ├── useTrim.js │ │ │ │ │ └── videoMode.js │ │ │ │ ├── panels/ │ │ │ │ │ ├── design/ │ │ │ │ │ │ ├── alignment/ │ │ │ │ │ │ │ ├── alignment.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── alignment.karma.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── alignment.js │ │ │ │ │ │ │ │ └── useAlignment.js │ │ │ │ │ │ │ └── useAlignment.js │ │ │ │ │ │ ├── animation/ │ │ │ │ │ │ │ ├── animation.js │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ ├── directionRadioInput.js │ │ │ │ │ │ │ ├── effectChooserDropdown/ │ │ │ │ │ │ │ │ ├── dropdownConstants.js │ │ │ │ │ │ │ │ ├── dropdownItem.js │ │ │ │ │ │ │ │ ├── effectChooserDropdown.js │ │ │ │ │ │ │ │ ├── effectChooserElements.js │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ ├── styles.js │ │ │ │ │ │ │ │ ├── types.js │ │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ │ ├── generateDynamicProps.js │ │ │ │ │ │ │ │ ├── getDisabledBackgroundEffects.js │ │ │ │ │ │ │ │ ├── hasDynamicProperty.js │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ └── updateDynamicProps.js │ │ │ │ │ │ │ ├── effectInput.js │ │ │ │ │ │ │ ├── effectPanel.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── animation.karma.js │ │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ │ ├── directionRadioInput.js │ │ │ │ │ │ │ │ └── effectChooser.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ ├── directionRadioInput.js │ │ │ │ │ │ │ └── effectInput.js │ │ │ │ │ │ ├── audioSticker/ │ │ │ │ │ │ │ ├── audioStickerStyle.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── stickerSize.js │ │ │ │ │ │ │ ├── stickerStyle.js │ │ │ │ │ │ │ ├── stickerType.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── stickerSize.js │ │ │ │ │ │ │ │ ├── stickerStyle.js │ │ │ │ │ │ │ │ └── stickerType.js │ │ │ │ │ │ │ └── utils.js │ │ │ │ │ │ ├── border/ │ │ │ │ │ │ │ ├── border.js │ │ │ │ │ │ │ ├── borderWidth.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── border.karma.js │ │ │ │ │ │ │ └── shared.js │ │ │ │ │ │ ├── captions/ │ │ │ │ │ │ │ ├── captions.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── captions.js │ │ │ │ │ │ ├── filter/ │ │ │ │ │ │ │ ├── convertOverlay.js │ │ │ │ │ │ │ ├── filter.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── filter.karma.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── convertOverlay.js │ │ │ │ │ │ ├── imageAccessibility/ │ │ │ │ │ │ │ ├── imageAccessibility.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── imageAccessibility.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── link/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── link.karma.js │ │ │ │ │ │ │ ├── link.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── link.js │ │ │ │ │ │ ├── pageAdvancement/ │ │ │ │ │ │ │ ├── customPageAdvancement.js │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── pageAttachment/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── outlink.js │ │ │ │ │ │ │ ├── pageAttachment.js │ │ │ │ │ │ │ ├── shared.js │ │ │ │ │ │ │ └── shoppingAttachment.js │ │ │ │ │ │ ├── pageBackground/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── pageBackground.karma.js │ │ │ │ │ │ │ ├── pageBackground.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── pageBackground.js │ │ │ │ │ │ ├── pageBackgroundAudio/ │ │ │ │ │ │ │ ├── backgroundAudio.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── pageBackgroundAudio.karma.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── backgroundAudio.js │ │ │ │ │ │ ├── product/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── product.js │ │ │ │ │ │ ├── shapeStyle/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── shapeStyle.js │ │ │ │ │ │ ├── sizePosition/ │ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ ├── borderRadius.karma.js │ │ │ │ │ │ │ │ └── sizePosition.karma.js │ │ │ │ │ │ │ ├── opacity.js │ │ │ │ │ │ │ ├── radius.js │ │ │ │ │ │ │ ├── sizePosition.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── sizePosition.js │ │ │ │ │ │ │ ├── usePresubmitHandlers.js │ │ │ │ │ │ │ └── utils.js │ │ │ │ │ │ ├── textAccessibility/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── textAccessibility.js │ │ │ │ │ │ │ └── textAccessibility.js │ │ │ │ │ │ ├── textStyle/ │ │ │ │ │ │ │ ├── backgroundColor.js │ │ │ │ │ │ │ ├── color.js │ │ │ │ │ │ │ ├── font.js │ │ │ │ │ │ │ ├── getClosestFontWeight.js │ │ │ │ │ │ │ ├── getFontWeights.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ ├── contrast.karma.js │ │ │ │ │ │ │ │ ├── stylePresets.karma.js │ │ │ │ │ │ │ │ └── textStyle.other.karma.js │ │ │ │ │ │ │ ├── padding.js │ │ │ │ │ │ │ ├── panelHeader.js │ │ │ │ │ │ │ ├── style.js │ │ │ │ │ │ │ ├── stylePresets.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ ├── fontPicker.js │ │ │ │ │ │ │ │ ├── fontsResponse.json │ │ │ │ │ │ │ │ ├── getClosestFontWeight.js │ │ │ │ │ │ │ │ ├── getFontWeights.js │ │ │ │ │ │ │ │ ├── stylePresetPanel.js │ │ │ │ │ │ │ │ ├── textBox.js │ │ │ │ │ │ │ │ └── textStyle.js │ │ │ │ │ │ │ ├── textStyle.js │ │ │ │ │ │ │ ├── useRichTextFormatting.js │ │ │ │ │ │ │ └── utils.js │ │ │ │ │ │ ├── videoAccessibility/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── videoPoster.karma.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── videoAccessibility.js │ │ │ │ │ │ │ └── videoAccessibility.js │ │ │ │ │ │ ├── videoOptions/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ │ └── videoOptions.karma.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── videoOptions.js │ │ │ │ │ │ │ └── videoOptions.js │ │ │ │ │ │ ├── videoSegment/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── videoSegment.js │ │ │ │ │ │ └── warning/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── warning.js │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── backgroundAudio/ │ │ │ │ │ │ │ ├── backgroundAudio.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── backgroundAudio.js │ │ │ │ │ │ ├── excerpt/ │ │ │ │ │ │ │ ├── excerpt.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── excerpt.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── pageAdvancement/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── pageAdvancement.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── pageAdvancement.js │ │ │ │ │ │ ├── slug/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── slug.js │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── slug.js │ │ │ │ │ │ └── taxonomies/ │ │ │ │ │ │ ├── FlatTermSelector.js │ │ │ │ │ │ ├── HierarchicalTermSelector.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ └── taxonomies.karma.js │ │ │ │ │ │ ├── shared.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── taxonomies.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── taxonomies.js │ │ │ │ │ ├── layer/ │ │ │ │ │ │ ├── constants.js │ │ │ │ │ │ ├── elementLayer.js │ │ │ │ │ │ ├── elementLayerActions.js │ │ │ │ │ │ ├── groupLayer.js │ │ │ │ │ │ ├── groupLayerActions.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── karma/ │ │ │ │ │ │ │ └── layer.karma.js │ │ │ │ │ │ ├── layer.js │ │ │ │ │ │ ├── layerAction.js │ │ │ │ │ │ ├── layerComponents.js │ │ │ │ │ │ ├── layerForm.js │ │ │ │ │ │ ├── layerIdContext.js │ │ │ │ │ │ ├── layerList.js │ │ │ │ │ │ ├── layerPanel.js │ │ │ │ │ │ ├── reorderableElementLayer.js │ │ │ │ │ │ ├── reorderableGroupLayer.js │ │ │ │ │ │ ├── reorderableLayer.js │ │ │ │ │ │ ├── shapeMaskWrapper.js │ │ │ │ │ │ ├── useGroupSelection.js │ │ │ │ │ │ ├── useLayerSelection.js │ │ │ │ │ │ └── useLayers.js │ │ │ │ │ ├── panel/ │ │ │ │ │ │ ├── context.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── panel.js │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── content.js │ │ │ │ │ │ │ ├── handle.js │ │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ │ └── handle.js │ │ │ │ │ │ │ └── title.js │ │ │ │ │ │ ├── simplePanel.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── simplePanel.js │ │ │ │ │ │ ├── useDragHandlers.js │ │ │ │ │ │ └── useKeyboardHandlers.js │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── flipControls.js │ │ │ │ │ │ ├── generalPageAdvancement.js │ │ │ │ │ │ ├── getCommonObjectValue.js │ │ │ │ │ │ ├── getCommonValue.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── linkRelations.js │ │ │ │ │ │ ├── media/ │ │ │ │ │ │ │ ├── audioPlayer.js │ │ │ │ │ │ │ ├── backgroundAudioPanelContent.js │ │ │ │ │ │ │ ├── captionsPanelContent.js │ │ │ │ │ │ │ ├── dropDownMenu.js │ │ │ │ │ │ │ ├── fileRow.js │ │ │ │ │ │ │ └── loopPanelContent.js │ │ │ │ │ │ ├── styles.js │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── _utils.js │ │ │ │ │ │ │ ├── getCommonObjectValue.js │ │ │ │ │ │ │ └── linkRelations.js │ │ │ │ │ │ ├── useCommonColorValue.js │ │ │ │ │ │ └── useCommonObjectValue.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── dropDownKeyEvents.js │ │ │ │ │ ├── metricsForTextPadding.js │ │ │ │ │ └── test/ │ │ │ │ │ └── metricsForTextPadding.js │ │ │ │ ├── previewPage/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── previewErrorBoundary.js │ │ │ │ │ ├── previewPage.js │ │ │ │ │ └── previewPageElements.js │ │ │ │ ├── publishModal/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── content/ │ │ │ │ │ │ ├── checklistButton.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── mainStoryInfo.js │ │ │ │ │ │ └── storyPreview.js │ │ │ │ │ ├── header/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── publishModal.karma.js │ │ │ │ │ ├── publishModal.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ ├── content.js │ │ │ │ │ ├── header.js │ │ │ │ │ ├── mainStoryInfo.js │ │ │ │ │ └── storyPreview.js │ │ │ │ ├── reorderable/ │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── reorderable.js │ │ │ │ │ ├── reorderableItem.js │ │ │ │ │ ├── reorderableScroller.js │ │ │ │ │ ├── reorderableSeparator.js │ │ │ │ │ ├── useReorderable.js │ │ │ │ │ ├── useReordering.js │ │ │ │ │ └── useScroll.js │ │ │ │ ├── secondaryPopup/ │ │ │ │ │ ├── components.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── navigationWrapper.js │ │ │ │ │ ├── topNavigation.js │ │ │ │ │ └── utils.js │ │ │ │ ├── shopping/ │ │ │ │ │ └── frame.js │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── context.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── karma/ │ │ │ │ │ │ └── sidebarTabs.karma.js │ │ │ │ │ ├── sidebar.js │ │ │ │ │ ├── sidebarContent.js │ │ │ │ │ ├── sidebarLayout.js │ │ │ │ │ ├── sidebarProvider.js │ │ │ │ │ ├── useSidebar.js │ │ │ │ │ └── utils.js │ │ │ │ ├── storyFontPicker/ │ │ │ │ │ └── index.js │ │ │ │ ├── style/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── context.js │ │ │ │ │ ├── designPanel.js │ │ │ │ │ ├── getDesignPanelsForSelection.js │ │ │ │ │ ├── icons/ │ │ │ │ │ │ ├── animationIcon.js │ │ │ │ │ │ ├── audioStickerSelectionIcon.js │ │ │ │ │ │ ├── imageSelectionIcon.js │ │ │ │ │ │ ├── linkIcon.js │ │ │ │ │ │ ├── multiSelectionIcon.js │ │ │ │ │ │ ├── productSelectionIcon.js │ │ │ │ │ │ ├── selectionIcon.js │ │ │ │ │ │ ├── shapeSelectionIcon.js │ │ │ │ │ │ ├── textSelectionIcon.js │ │ │ │ │ │ └── videoSelectionIcon.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── style.js │ │ │ │ │ ├── styleLayout.js │ │ │ │ │ ├── stylePanes.js │ │ │ │ │ ├── styleProvider.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── designPanel.js │ │ │ │ │ │ └── updateProperties.js │ │ │ │ │ ├── updateProperties.js │ │ │ │ │ ├── useDesignPanels.js │ │ │ │ │ └── useStyle.js │ │ │ │ ├── styleManager/ │ │ │ │ │ ├── components.js │ │ │ │ │ ├── confirmationDialog.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── header.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── confirmationDialog.js │ │ │ │ │ ├── styleGroup.js │ │ │ │ │ ├── styleItem.js │ │ │ │ │ ├── styleManager.js │ │ │ │ │ ├── useApplyStyle.js │ │ │ │ │ ├── useDeleteStyle.js │ │ │ │ │ └── useKeyboardNavigation.js │ │ │ │ ├── tablist/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styles.js │ │ │ │ │ └── tablistPanel.js │ │ │ │ ├── tabview/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── thumbnail/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── LayerThumbnail.js │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── overflowThumbnail.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ ├── demoThumbnails.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── styles.js │ │ │ │ │ └── thumbnail.js │ │ │ │ ├── toggleButton/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── stories/ │ │ │ │ │ └── index.js │ │ │ │ ├── tooltip/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── tooltip.js │ │ │ │ ├── transition/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── scheduledTransition.js │ │ │ │ ├── uploadDropTarget/ │ │ │ │ │ ├── context.js │ │ │ │ │ ├── dropTarget.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── message.js │ │ │ │ │ ├── overlay.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── dropTarget.js │ │ │ │ │ └── use.js │ │ │ │ ├── videoTrim/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── currentTime.js │ │ │ │ │ ├── generateVideoStrip.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── provider.js │ │ │ │ │ ├── recordingProvider.js │ │ │ │ │ ├── slider.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── useVideoNode.js │ │ │ │ │ │ ├── useVideoTrimMode.js │ │ │ │ │ │ └── videoTrimmer.js │ │ │ │ │ ├── trimmerComponents.js │ │ │ │ │ ├── useRailBackground.js │ │ │ │ │ ├── useVideoNode.js │ │ │ │ │ ├── useVideoTrim.js │ │ │ │ │ ├── useVideoTrimMode.js │ │ │ │ │ ├── videoTrimContext.js │ │ │ │ │ └── videoTrimmer.js │ │ │ │ └── workspace/ │ │ │ │ ├── index.js │ │ │ │ └── layout.js │ │ │ ├── constants/ │ │ │ │ ├── audioSticker.ts │ │ │ │ ├── fonts.ts │ │ │ │ ├── headings.ts │ │ │ │ ├── index.ts │ │ │ │ ├── media.ts │ │ │ │ ├── multipleValue.ts │ │ │ │ ├── performanceTrackingEvents.ts │ │ │ │ └── zIndex.ts │ │ │ ├── dataUtils/ │ │ │ │ └── formattedTemplatesArray.js │ │ │ ├── getDefaultConfig.js │ │ │ ├── icons/ │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── karma/ │ │ │ │ ├── copyAndPaste.cuj.karma.js │ │ │ │ ├── duplicate.cuj.karma.js │ │ │ │ ├── element-library/ │ │ │ │ │ ├── audio-sticker/ │ │ │ │ │ │ └── audioSticker.karma.js │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── edit.karma.js │ │ │ │ │ │ └── resourceLoading.karma.js │ │ │ │ │ ├── text/ │ │ │ │ │ │ ├── edit.karma.js │ │ │ │ │ │ └── frame.karma.js │ │ │ │ │ └── video/ │ │ │ │ │ ├── autoplay.karma.js │ │ │ │ │ └── elementMinSizeAndPlayback.karma.js │ │ │ │ ├── elementTransform.cuj.karma.js │ │ │ │ ├── fixture/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── checklist/ │ │ │ │ │ │ │ ├── accessibility/ │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ ├── design/ │ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── priority/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ ├── documentPane/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── header/ │ │ │ │ │ │ ├── buttons/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── containers/ │ │ │ │ │ │ ├── canvas.js │ │ │ │ │ │ ├── carousel.js │ │ │ │ │ │ ├── checklist.js │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ ├── color.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── radio.js │ │ │ │ │ │ │ ├── select.js │ │ │ │ │ │ │ ├── toggle.js │ │ │ │ │ │ │ └── toggleButton.js │ │ │ │ │ │ ├── container.js │ │ │ │ │ │ ├── designMenu.js │ │ │ │ │ │ ├── designPanel/ │ │ │ │ │ │ │ ├── abstractPanel.js │ │ │ │ │ │ │ ├── alignment.js │ │ │ │ │ │ │ ├── animationPanel.js │ │ │ │ │ │ │ ├── audioStickerSize.js │ │ │ │ │ │ │ ├── audioStickerStyle.js │ │ │ │ │ │ │ ├── audioStickerType.js │ │ │ │ │ │ │ ├── border.js │ │ │ │ │ │ │ ├── captions.js │ │ │ │ │ │ │ ├── colorPreset.js │ │ │ │ │ │ │ ├── filter.js │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── layers.js │ │ │ │ │ │ │ ├── link.js │ │ │ │ │ │ │ ├── pageBackground.js │ │ │ │ │ │ │ ├── pageBackgroundAudio.js │ │ │ │ │ │ │ ├── shapeStyle.js │ │ │ │ │ │ │ ├── sizePosition.js │ │ │ │ │ │ │ ├── textStyle.js │ │ │ │ │ │ │ ├── videoOptions.js │ │ │ │ │ │ │ └── videoPoster.js │ │ │ │ │ │ ├── documentPanel/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── taxonomies.js │ │ │ │ │ │ ├── editor.js │ │ │ │ │ │ ├── footer.js │ │ │ │ │ │ ├── gridView.js │ │ │ │ │ │ ├── header.js │ │ │ │ │ │ ├── helpCenter.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── keyboardShortcuts.js │ │ │ │ │ │ ├── library/ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── pageTemplates.js │ │ │ │ │ │ │ └── text.js │ │ │ │ │ │ ├── quickActionMenu.js │ │ │ │ │ │ └── sidebar.js │ │ │ │ │ ├── db/ │ │ │ │ │ │ ├── getMediaResponse.js │ │ │ │ │ │ ├── getProductsResponse.js │ │ │ │ │ │ ├── getTaxonomiesResponse.js │ │ │ │ │ │ ├── getTaxonomyTermResponse.js │ │ │ │ │ │ ├── singleSavedTemplate.js │ │ │ │ │ │ └── storyResponse.js │ │ │ │ │ ├── fixture.js │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── integrationLayerTesting/ │ │ │ │ │ ├── config.karma.js │ │ │ │ │ └── optionalCallbacks.karma.js │ │ │ │ ├── media.cuj.karma.js │ │ │ │ ├── richText/ │ │ │ │ │ ├── _utils.js │ │ │ │ │ ├── inlineSelection.karma.js │ │ │ │ │ ├── inlineStyleOverride.karma.js │ │ │ │ │ ├── multiSelection.karma.js │ │ │ │ │ └── singleSelection.karma.js │ │ │ │ └── text.cuj.karma.js │ │ │ ├── propTypes.js │ │ │ ├── storyEditor.js │ │ │ ├── theme.js │ │ │ ├── types/ │ │ │ │ ├── apiProvider.ts │ │ │ │ ├── canvasProvider.ts │ │ │ │ ├── configProvider.ts │ │ │ │ ├── currentUserProvider.ts │ │ │ │ ├── highlightsProvider.ts │ │ │ │ ├── historyProvider.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layoutProvider.ts │ │ │ │ ├── pageCanvas.ts │ │ │ │ ├── pageDataUrls.ts │ │ │ │ ├── story.ts │ │ │ │ ├── storyEditor.ts │ │ │ │ ├── storyProvider.ts │ │ │ │ ├── storyTriggers.ts │ │ │ │ └── taxonomyProvider.ts │ │ │ ├── typings/ │ │ │ │ ├── apcx-hues.d.ts │ │ │ │ ├── colorthief.d.ts │ │ │ │ ├── dom.d.ts │ │ │ │ ├── global.d.ts │ │ │ │ ├── libheif-js.d.ts │ │ │ │ └── svg.d.ts │ │ │ └── utils/ │ │ │ ├── __mocks__/ │ │ │ │ └── getBlurHashFromImage.js │ │ │ ├── cleanForSlug.ts │ │ │ ├── contrastUtils.ts │ │ │ ├── copyPaste.tsx │ │ │ ├── createError.ts │ │ │ ├── createThumbnailCanvasFromFullbleedCanvas.ts │ │ │ ├── dragEvent.ts │ │ │ ├── generateBlurhash.worker.js │ │ │ ├── generateGroupName.ts │ │ │ ├── getBlurHashFromImage.js │ │ │ ├── getCropParams.ts │ │ │ ├── getInUseFonts.ts │ │ │ ├── getInsertedElementSize.ts │ │ │ ├── getMediaBaseColor.ts │ │ │ ├── getSessionStorageKey.ts │ │ │ ├── getUniquePresets.ts │ │ │ ├── getUpdatedSizeAndPosition.js │ │ │ ├── idleCallback.ts │ │ │ ├── isDefaultPage.ts │ │ │ ├── isOffCanvas.ts │ │ │ ├── isTargetOutOfContainer.ts │ │ │ ├── keyboardOnlyOutline.js │ │ │ ├── nativeCopyPasteExpected.ts │ │ │ ├── noop.ts │ │ │ ├── objectPick.ts │ │ │ ├── objectWithout.ts │ │ │ ├── presetUtils.js │ │ │ ├── removeDupsFromArray.ts │ │ │ ├── storyUpdates.ts │ │ │ ├── test/ │ │ │ │ ├── cleanForSlug.js │ │ │ │ ├── contrastUtils.js │ │ │ │ ├── copyPaste.js │ │ │ │ ├── createError.js │ │ │ │ ├── generateGroupName.js │ │ │ │ ├── getCropParams.js │ │ │ │ ├── getInUseFonts.js │ │ │ │ ├── getInsertedElementSize.js │ │ │ │ ├── getUniquePresets.js │ │ │ │ ├── isDefaultPage.js │ │ │ │ ├── isOffCanvas.js │ │ │ │ ├── nativeCopyPasteExpected.js │ │ │ │ ├── objectPick.js │ │ │ │ ├── presetUtils.js │ │ │ │ ├── removeDupsFromArray.js │ │ │ │ ├── storyUpdates.js │ │ │ │ ├── textMeasurements.js │ │ │ │ ├── useElementsWithLinks.js │ │ │ │ ├── useGlobalClipboardHandlers.js │ │ │ │ ├── useHandlers.js │ │ │ │ ├── useIdleTaskQueue.js │ │ │ │ ├── useIsUploadingToStory.js │ │ │ │ ├── usePreventWindowUnload.js │ │ │ │ └── useRovingTabIndex.js │ │ │ ├── useAddPreset.js │ │ │ ├── useApplyTextAutoStyle.js │ │ │ ├── useCORSProxy.ts │ │ │ ├── useDoubleClick.ts │ │ │ ├── useElementPolygon.js │ │ │ ├── useElementsWithLinks.ts │ │ │ ├── useFocusTrapping.js │ │ │ ├── useGlobalClipboardHandlers.ts │ │ │ ├── useHandlers.ts │ │ │ ├── useIdleTaskQueue.ts │ │ │ ├── useIsUploadingToStory.js │ │ │ ├── usePerformanceTracking.ts │ │ │ ├── usePreventWindowUnload.ts │ │ │ ├── useRefreshPostEditURL.ts │ │ │ ├── useRovingTabIndex/ │ │ │ │ ├── flatNavigation.js │ │ │ │ ├── index.js │ │ │ │ └── nestedNavigation.js │ │ │ ├── useShapeMask.js │ │ │ └── useShapeMaskElements.js │ │ └── tsconfig.json │ ├── templates/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── cli.js │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── getMetaData.ts │ │ │ ├── getTemplates.ts │ │ │ ├── index.ts │ │ │ ├── raw/ │ │ │ │ ├── 12-hours-in-barcelona/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── a-day-in-the-life/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── ace-hotel-kyoto-review/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── album-releases/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── all-about-cars/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── almodos-films/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── an-artists-legacy/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── art-books-gift-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── baking-bread-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── beauty-quiz/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── belly-fat-workout/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── buying-art-on-the-internet/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── celebrity-life-story/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── celebrity-q-and-a/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── diy-home-office/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── doers-get-more-done/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── elegant-travel-itinerary/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── experience-thailand/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── fashion-inspiration/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── fashion-on-the-go/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── fitness-apps-ranked/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── food-and-stuff/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── fresh-and-bright/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── google-music-studio-tour/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── hawaii-travel-packing-list/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── honeymooning-in-italy/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── house-hunting/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── how-contact-tracing-works/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── how-video-calls-saved-the-day/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── indoor-garden-oasis/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── kitchen-makeover/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── kitchen-stories/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── laptop-buying-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── los-angeles-city-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── magazine-article/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── modernist-travel-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── new-york-party-round-up/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── no-days-off/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── one-day-city-itinerary/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── pizzas-in-nyc/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── plant-based-dyes/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── pride-month-watchlist/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── rock-music-festival/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── sangria-artichoke/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── self-care-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── series-best-of/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── simple-tech-tutorial/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── skin-care-at-home/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── sleep/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── sports-quiz/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── street-style-on-the-go/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── summer-adventure-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── summer-fashion-collection/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── sustainability-tips/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── technology-advice/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── tips-for-throwing-an-outdoor-luau/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── tv-show-recap/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── ultimate-comparison/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── vintage-chairs-buying-guide/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ ├── ways-to-eat-avocado/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metaData.ts │ │ │ │ │ ├── template.d.ts │ │ │ │ │ └── template.json │ │ │ │ └── weekly-entertainment/ │ │ │ │ ├── index.ts │ │ │ │ ├── metaData.ts │ │ │ │ ├── template.d.ts │ │ │ │ └── template.json │ │ │ ├── test/ │ │ │ │ ├── getTemplates.js │ │ │ │ └── raw.js │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── memoize.ts │ │ │ └── test/ │ │ │ └── memoize.js │ │ └── tsconfig.json │ ├── test-utils/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── firePointerEvent.ts │ │ │ ├── index.ts │ │ │ ├── queryByAriaLabel.ts │ │ │ ├── queryByAutoAdvanceAfter.ts │ │ │ ├── queryById.ts │ │ │ ├── renderWithTheme.tsx │ │ │ └── typings/ │ │ │ └── svg.d.ts │ │ └── tsconfig.json │ ├── text-sets/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── cli.js │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── loadTextSets.ts │ │ │ ├── raw/ │ │ │ │ ├── contact.json │ │ │ │ ├── cover.json │ │ │ │ ├── editorial.json │ │ │ │ ├── list.json │ │ │ │ ├── quote.json │ │ │ │ ├── section_header.json │ │ │ │ ├── step.json │ │ │ │ └── table.json │ │ │ ├── test/ │ │ │ │ └── raw.js │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── tinymce-button/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── components/ │ │ │ ├── Modal.js │ │ │ ├── controls/ │ │ │ │ ├── Toggle.js │ │ │ │ └── test/ │ │ │ │ └── Toggle.js │ │ │ └── test/ │ │ │ └── Modal.js │ │ ├── containers/ │ │ │ └── Modal.js │ │ ├── index.js │ │ ├── store/ │ │ │ ├── actions.js │ │ │ ├── default.js │ │ │ ├── index.js │ │ │ ├── reducers.js │ │ │ ├── selectors.js │ │ │ └── test/ │ │ │ ├── actions.js │ │ │ └── selectors.js │ │ └── utils/ │ │ ├── index.js │ │ └── test/ │ │ └── index.js │ ├── tracking/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── disableTracking.ts │ │ │ ├── enableTracking.ts │ │ │ ├── getTimeTracker.ts │ │ │ ├── index.ts │ │ │ ├── initializeErrorReporting.ts │ │ │ ├── initializeTracking.ts │ │ │ ├── isTrackingEnabled.ts │ │ │ ├── shared.ts │ │ │ ├── test/ │ │ │ │ ├── disableTracking.ts │ │ │ │ ├── enableTracking.ts │ │ │ │ ├── getTimeTracker.ts │ │ │ │ ├── gtag.ts │ │ │ │ ├── initializeTracking.ts │ │ │ │ ├── isTrackingEnabled.ts │ │ │ │ ├── trackError.ts │ │ │ │ ├── trackEvent.ts │ │ │ │ ├── trackScreenView.ts │ │ │ │ └── trackTiming.ts │ │ │ ├── track.ts │ │ │ ├── trackClick.ts │ │ │ ├── trackError.ts │ │ │ ├── trackEvent.ts │ │ │ ├── trackScreenView.ts │ │ │ └── trackTiming.ts │ │ └── tsconfig.json │ ├── transform/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── context.ts │ │ │ ├── index.ts │ │ │ ├── transformProvider.tsx │ │ │ ├── types.ts │ │ │ ├── useTransform.ts │ │ │ └── useTransformHandler.ts │ │ └── tsconfig.json │ ├── units/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── calcRotatedObjectPositionAndSize.ts │ │ │ ├── calcRotatedResizeOffset.ts │ │ │ ├── constants.ts │ │ │ ├── context.ts │ │ │ ├── dimensions.ts │ │ │ ├── getBoundRect.ts │ │ │ ├── getCorner.ts │ │ │ ├── getCorners.ts │ │ │ ├── index.ts │ │ │ ├── range.ts │ │ │ ├── test/ │ │ │ │ ├── getBoundRect.ts │ │ │ │ └── range.ts │ │ │ ├── types.ts │ │ │ ├── unitsProvider.tsx │ │ │ └── useUnits.ts │ │ └── tsconfig.json │ ├── url/ │ │ ├── .npmignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── addQueryArgs.ts │ │ │ ├── index.ts │ │ │ ├── safeDecodeUriComponent.ts │ │ │ ├── test/ │ │ │ │ ├── addQueryArgs.ts │ │ │ │ ├── safeDecodeUriComponent.js │ │ │ │ └── url.ts │ │ │ └── url.ts │ │ └── tsconfig.json │ ├── widget/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── index.js │ │ └── style.css │ ├── wp-dashboard/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── api/ │ │ │ ├── authors.js │ │ │ ├── constants/ │ │ │ │ └── index.js │ │ │ ├── fonts.js │ │ │ ├── hooks/ │ │ │ │ ├── useApiAlerts.js │ │ │ │ ├── useFontsApi.js │ │ │ │ ├── useMediaApi.js │ │ │ │ ├── usePagesApi.js │ │ │ │ ├── usePublisherLogosApi.js │ │ │ │ ├── useSettingsApi.js │ │ │ │ └── useUserApi.js │ │ │ ├── index.js │ │ │ ├── media.js │ │ │ ├── pages.js │ │ │ ├── publisherLogo.js │ │ │ ├── reducers/ │ │ │ │ ├── media.js │ │ │ │ ├── publisherLogos.js │ │ │ │ ├── settings.js │ │ │ │ └── test/ │ │ │ │ ├── media.js │ │ │ │ ├── publisherLogos.js │ │ │ │ └── settings.js │ │ │ ├── settings.js │ │ │ ├── shopping.js │ │ │ ├── story.js │ │ │ ├── taxonomies.js │ │ │ ├── test/ │ │ │ │ ├── editorSettingsApi.js │ │ │ │ └── user.js │ │ │ ├── user.js │ │ │ └── utils/ │ │ │ ├── index.js │ │ │ ├── reshapeStoryObject.js │ │ │ └── test/ │ │ │ └── reshapeStoryObject.js │ │ ├── components/ │ │ │ ├── editorSettings/ │ │ │ │ ├── adManagement/ │ │ │ │ │ ├── adNetwork/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── adNetwork.js │ │ │ │ │ ├── googleAdManager/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── googleAdManager.js │ │ │ │ │ ├── googleAdSense/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── googleAdSense.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── mgid/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── stories/ │ │ │ │ │ │ │ └── index.js │ │ │ │ │ │ └── test/ │ │ │ │ │ │ └── mgid.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── adManagement.js │ │ │ │ ├── archive/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── archive.js │ │ │ │ ├── components.js │ │ │ │ ├── context.js │ │ │ │ ├── customFonts/ │ │ │ │ │ ├── confirmationDialog.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── customFonts.js │ │ │ │ │ └── utils/ │ │ │ │ │ └── getFontDataFromUrl.js │ │ │ │ ├── dataRemoval/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── DataRemovalSettings.js │ │ │ │ ├── dataUtils/ │ │ │ │ │ ├── formattedCustomFonts.js │ │ │ │ │ └── formattedPublisherLogos.js │ │ │ │ ├── editorSettings.js │ │ │ │ ├── editorSettingsProvider.js │ │ │ │ ├── googleAnalytics/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── googleAnalytics.js │ │ │ │ ├── index.js │ │ │ │ ├── mediaOptimization/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── mediaOptimizationSettings.js │ │ │ │ ├── pageAdvancement/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── pageAdvancement.js │ │ │ │ ├── publisherLogo/ │ │ │ │ │ ├── gridItem.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── popoverLogoContextMenu.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── publisherLogo.js │ │ │ │ ├── shopping/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── shopify/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── shoppingProviderDropDown.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ ├── shopping.js │ │ │ │ │ └── shoppingProvider.js │ │ │ │ ├── telemetry/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── stories/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── telemetrySettings.js │ │ │ │ ├── test/ │ │ │ │ │ └── editorSettings.js │ │ │ │ ├── useEditorSettings.js │ │ │ │ ├── utils/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── test/ │ │ │ │ │ │ ├── validateAdManagerSlotIdFormat.js │ │ │ │ │ │ ├── validateAdSensePublisherIdFormat.js │ │ │ │ │ │ ├── validateAdSenseSlotIdFormat.js │ │ │ │ │ │ ├── validateGoogleAnalyticsIdFormat.js │ │ │ │ │ │ ├── validateMgidWidgetIdFormat.js │ │ │ │ │ │ └── validateShopifyHost.js │ │ │ │ │ ├── validateAdManagerSlotIdFormat.js │ │ │ │ │ ├── validateAdSensePublisherIdFormat.js │ │ │ │ │ ├── validateAdSenseSlotIdFormat.js │ │ │ │ │ ├── validateGoogleAnalyticsIdFormat.js │ │ │ │ │ ├── validateMgidWidgetIdFormat.js │ │ │ │ │ └── validateShopifyHost.js │ │ │ │ └── videoCache/ │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── index.js │ │ │ │ └── test/ │ │ │ │ └── videoCache.js │ │ │ ├── ga4Banner/ │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── layout/ │ │ │ │ └── index.js │ │ │ ├── telemetryBanner/ │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── telemetryBanner.js │ │ │ │ └── test/ │ │ │ │ └── telemetryBanner.js │ │ │ └── updateBanner/ │ │ │ └── index.js │ │ ├── constants/ │ │ │ ├── index.js │ │ │ ├── settings.js │ │ │ ├── textConstants.js │ │ │ └── wpAdmin.js │ │ ├── effects/ │ │ │ ├── index.js │ │ │ ├── useMediaOptimization.js │ │ │ ├── useSyncAdminMenu.js │ │ │ └── useTelemetryOptIn.js │ │ ├── index.js │ │ ├── publicPath.js │ │ ├── setLocaleData.js │ │ ├── style.css │ │ ├── testUtils/ │ │ │ ├── index.js │ │ │ ├── mockEditorProvider.js │ │ │ └── renderWithProviders.js │ │ └── theme.js │ ├── wp-story-editor/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ ├── api/ │ │ │ ├── authors.js │ │ │ ├── constants/ │ │ │ │ └── index.js │ │ │ ├── fonts.js │ │ │ ├── hotlinkInfo.js │ │ │ ├── index.js │ │ │ ├── media.js │ │ │ ├── metaboxes.js │ │ │ ├── metadata.js │ │ │ ├── pageTemplate.js │ │ │ ├── proxy.js │ │ │ ├── publisherLogos.js │ │ │ ├── shopping.js │ │ │ ├── statusCheck.js │ │ │ ├── story.js │ │ │ ├── storyLock.js │ │ │ ├── taxonomy.js │ │ │ ├── test/ │ │ │ │ ├── _utils.js │ │ │ │ ├── fonts.js │ │ │ │ ├── media.js │ │ │ │ ├── metaboxes.js │ │ │ │ ├── pageTemplates.js │ │ │ │ ├── story.js │ │ │ │ └── user.js │ │ │ ├── user.js │ │ │ └── utils/ │ │ │ ├── base64Encode.js │ │ │ ├── flattenFormData.js │ │ │ ├── getResourceFromAttachment.js │ │ │ ├── index.js │ │ │ ├── normalizeResourceSizes.js │ │ │ ├── test/ │ │ │ │ ├── base64Encode.js │ │ │ │ ├── flatternFormData.js │ │ │ │ └── normalizeResourceSizes.js │ │ │ └── transformStoryResponse.js │ │ ├── components/ │ │ │ ├── checklist/ │ │ │ │ ├── accessibility/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── videoOptimizationCheckbox/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── index.js │ │ │ │ ├── design/ │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ └── priority/ │ │ │ │ ├── index.js │ │ │ │ └── storyMissingPublisherName.js │ │ │ ├── corsCheck/ │ │ │ │ ├── corsCheck.js │ │ │ │ ├── corsCheckFailed.js │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── corsCheckFailed.js │ │ │ │ └── test/ │ │ │ │ └── corsCheck.js │ │ │ ├── crossOriginIsolation/ │ │ │ │ └── index.js │ │ │ ├── documentPane/ │ │ │ │ ├── index.js │ │ │ │ ├── publish/ │ │ │ │ │ ├── author.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── publish.js │ │ │ │ │ ├── publishTime.js │ │ │ │ │ └── test/ │ │ │ │ │ └── publish.js │ │ │ │ └── status/ │ │ │ │ ├── index.js │ │ │ │ ├── status.js │ │ │ │ └── test/ │ │ │ │ └── status.js │ │ │ ├── fontCheck/ │ │ │ │ ├── fontCheckDialog.js │ │ │ │ ├── index.js │ │ │ │ └── test/ │ │ │ │ └── fontCheck.js │ │ │ ├── header/ │ │ │ │ ├── buttons/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── test/ │ │ │ │ │ └── buttons.js │ │ │ │ └── index.js │ │ │ ├── helpCenter/ │ │ │ │ ├── footer/ │ │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── layout/ │ │ │ │ └── index.js │ │ │ ├── mediaUpload/ │ │ │ │ ├── index.js │ │ │ │ └── mediaPicker/ │ │ │ │ ├── WordPressImageCropper.js │ │ │ │ ├── index.js │ │ │ │ ├── test/ │ │ │ │ │ └── useMediaPicker.js │ │ │ │ ├── useMediaPicker.js │ │ │ │ └── utils/ │ │ │ │ ├── calculateImageSelectOptions.js │ │ │ │ ├── getResourceFromMediaPicker.js │ │ │ │ ├── index.js │ │ │ │ ├── mustBeCropped.js │ │ │ │ └── test/ │ │ │ │ ├── calculateImageSelectOptions.js │ │ │ │ ├── getResourceFromMediaPicker.js │ │ │ │ └── mustBeCropped.js │ │ │ ├── metaBoxes/ │ │ │ │ ├── context.js │ │ │ │ ├── index.js │ │ │ │ ├── menuItem/ │ │ │ │ │ └── index.js │ │ │ │ ├── metaBoxes.js │ │ │ │ ├── metaBoxesArea.js │ │ │ │ ├── metaBoxesProvider.js │ │ │ │ ├── test/ │ │ │ │ │ └── useSaveMetaBoxes.js │ │ │ │ ├── useMetaBoxes.js │ │ │ │ └── useSaveMetaBoxes.js │ │ │ ├── postLock/ │ │ │ │ ├── index.js │ │ │ │ ├── postLock.js │ │ │ │ ├── postLockDialog.js │ │ │ │ ├── postTakeOverDialog.js │ │ │ │ ├── shared.js │ │ │ │ ├── stories/ │ │ │ │ │ ├── postLockDialog.js │ │ │ │ │ └── postTakeOverDialog.js │ │ │ │ └── test/ │ │ │ │ └── postLock.js │ │ │ ├── postPublishDialog/ │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── postPublishDialog.js │ │ │ │ └── test/ │ │ │ │ └── postPublishDialog.js │ │ │ ├── postReviewDialog/ │ │ │ │ ├── index.js │ │ │ │ ├── stories/ │ │ │ │ │ └── postReviewDialog.js │ │ │ │ └── test/ │ │ │ │ └── postReviewDialog.js │ │ │ ├── revisionMessage/ │ │ │ │ ├── index.js │ │ │ │ ├── revisionMessage.js │ │ │ │ └── test/ │ │ │ │ └── revisionMessage.js │ │ │ └── statusCheck/ │ │ │ ├── index.js │ │ │ ├── statusCheck.js │ │ │ ├── statusCheckFailed.js │ │ │ ├── stories/ │ │ │ │ └── statusCheckFailed.js │ │ │ ├── test/ │ │ │ │ └── statusCheck.js │ │ │ └── utils.js │ │ ├── constants/ │ │ │ ├── index.js │ │ │ ├── status.js │ │ │ ├── tips.js │ │ │ └── wpAdmin.js │ │ ├── index.js │ │ ├── publicPath.js │ │ ├── setLocaleData.js │ │ ├── style.css │ │ └── theme.js │ └── wp-utils/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── bindToCallbacks.ts │ │ ├── index.ts │ │ ├── snakeToCamelCase.ts │ │ └── test/ │ │ └── snakeToCamelCase.ts │ └── tsconfig.json ├── patches/ │ ├── @types+draft-js+0.11.9.patch │ ├── @types+glider-js+1.7.12.patch │ ├── eslint-plugin-react-hooks+7.0.1.patch │ ├── html-to-image+1.10.8.patch │ ├── humbug-php-scoper-php85.diff │ ├── karma-parallel+0.3.1.dev.patch │ ├── react-moveable+0.56.0.patch │ └── thecodingmachine-safe-nullable.diff ├── percy.config.karma.yml ├── percy.config.yml ├── phpcs.xml.dist ├── phpmd.xml ├── phpstan.neon.dist ├── phpunit-integration-multisite.xml.dist ├── phpunit-integration.xml.dist ├── phpunit.xml.dist ├── readme.txt ├── rollup.config.js ├── scoper.inc.php ├── tests/ │ ├── js/ │ │ ├── imageMock.js │ │ ├── jest.config.js │ │ ├── jest.setup.js │ │ ├── setup-globals.js │ │ ├── setup-mocks.js │ │ ├── styleMock.js │ │ └── svgrMock.js │ ├── phpstan/ │ │ ├── bootstrap.php │ │ ├── src/ │ │ │ ├── ServiceContainerDynamicReturnTypeExtension.php │ │ │ └── ServicesDynamicReturnTypeExtension.php │ │ └── stubs/ │ │ ├── amp.php │ │ ├── wordpress-override.php │ │ ├── wordpress-seo.php │ │ ├── wp-cli.php │ │ └── wpdotcom.php │ └── phpunit/ │ ├── integration/ │ │ ├── bootstrap.php │ │ ├── data/ │ │ │ ├── amp.dev.html │ │ │ ├── characters.example.com.html │ │ │ ├── cross_origin_content.html │ │ │ ├── example.com.html │ │ │ ├── schema.json │ │ │ ├── shopify_response_access_denied.json │ │ │ ├── shopify_response_default.json │ │ │ ├── shopify_response_empty_search.json │ │ │ ├── shopify_response_internal_server_error.json │ │ │ ├── shopify_response_shop_inactive.json │ │ │ ├── shopify_response_throttled.json │ │ │ ├── shopify_response_unknown.json │ │ │ ├── stories_in_amp.html │ │ │ ├── story_post_content.html │ │ │ ├── story_post_content_filtered.json │ │ │ └── story_post_content_sanitized.html │ │ ├── includes/ │ │ │ ├── Capabilities_Setup.php │ │ │ ├── DependencyInjectedRestTestCase.php │ │ │ ├── DependencyInjectedTestCase.php │ │ │ ├── Fixture/ │ │ │ │ ├── DummyClass.php │ │ │ │ ├── DummyClassWithDependency.php │ │ │ │ ├── DummyClassWithNamedArguments.php │ │ │ │ ├── DummyInterface.php │ │ │ │ ├── DummyPostTypeWithCustomArchive.php │ │ │ │ ├── DummyPostTypeWithoutArchive.php │ │ │ │ ├── DummyService.php │ │ │ │ ├── DummyServiceBasedPlugin.php │ │ │ │ ├── DummyServiceWithDelay.php │ │ │ │ ├── DummyServiceWithRequirements.php │ │ │ │ └── DummyTaxonomy.php │ │ │ ├── Kses_Setup.php │ │ │ ├── Mock_Vendor_Setup.php │ │ │ ├── REST_Setup.php │ │ │ ├── RestTestCase.php │ │ │ ├── Shopping/ │ │ │ │ ├── Mock_Vendor.php │ │ │ │ ├── Mock_Vendor_Error.php │ │ │ │ └── Mock_Vendor_Invalid.php │ │ │ ├── TestCase.php │ │ │ └── Test_Renderer.php │ │ ├── stubs/ │ │ │ └── WP_Site.php │ │ └── tests/ │ │ ├── AMP/ │ │ │ ├── Canonical_Sanitizer.php │ │ │ ├── Optimization.php │ │ │ ├── Output_Buffer.php │ │ │ └── Sanitization.php │ │ ├── AMP_Story_Player_Assets.php │ │ ├── AdSense.php │ │ ├── Ad_Manager.php │ │ ├── Admin/ │ │ │ ├── Activation_Notice.php │ │ │ ├── Admin.php │ │ │ ├── Cross_Origin_Isolation.php │ │ │ ├── Customizer.php │ │ │ ├── Dashboard.php │ │ │ ├── Editor.php │ │ │ ├── Meta_Boxes.php │ │ │ ├── PluginActionLinks.php │ │ │ ├── PluginRowMeta.php │ │ │ ├── Site_Health.php │ │ │ └── TinyMCE.php │ │ ├── Analytics.php │ │ ├── Assets.php │ │ ├── Block/ │ │ │ └── Web_Stories_Block.php │ │ ├── Context.php │ │ ├── Database_Upgrader.php │ │ ├── Demo_Content.php │ │ ├── Discovery.php │ │ ├── Embed_Base.php │ │ ├── Experiments.php │ │ ├── Font_Post_Type.php │ │ ├── Infrastructure/ │ │ │ ├── InjectionChainTest.php │ │ │ ├── LazilyInstantiatedServiceTest.php │ │ │ ├── ServiceBasedPluginTest.php │ │ │ ├── SimpleInjectorTest.php │ │ │ └── SimpleServiceContainerTest.php │ │ ├── Integrations/ │ │ │ ├── AMP.php │ │ │ ├── Conditional_Featured_Image.php │ │ │ ├── Core_Themes_Support.php │ │ │ ├── Jetpack.php │ │ │ ├── New_Relic.php │ │ │ ├── NextGen_Gallery.php │ │ │ ├── ShortPixel.php │ │ │ └── Site_Kit.php │ │ ├── Locale.php │ │ ├── Media/ │ │ │ ├── Base_Color.php │ │ │ ├── Blurhash.php │ │ │ ├── Cropping.php │ │ │ ├── Image_Sizes.php │ │ │ ├── Media_Source_Taxonomy.php │ │ │ ├── SVG.php │ │ │ ├── Types.php │ │ │ └── Video/ │ │ │ ├── Captions.php │ │ │ ├── Is_Gif.php │ │ │ ├── Muting.php │ │ │ ├── Optimization.php │ │ │ ├── Poster.php │ │ │ └── Trimming.php │ │ ├── Mgid.php │ │ ├── Migrations/ │ │ │ ├── Add_Media_Source_Editor.php │ │ │ ├── Add_Media_Source_Gif_Conversion.php │ │ │ ├── Add_Media_Source_Page_Template.php │ │ │ ├── Add_Media_Source_Recording.php │ │ │ ├── Add_Media_Source_Source_Image.php │ │ │ ├── Add_Media_Source_Source_Video.php │ │ │ ├── Add_Media_Source_Video_Optimization.php │ │ │ ├── Add_Poster_Generation_Media_Source.php │ │ │ ├── Add_VideoPress_Poster_Generation_Media_Source.php │ │ │ ├── Remove_Broken_Text_Styles.php │ │ │ ├── Remove_Incorrect_Tracking_Id.php │ │ │ ├── Remove_Unneeded_Attachment_Meta.php │ │ │ ├── Replace_Conic_Style_Presets.php │ │ │ ├── Set_Legacy_Analytics_Usage_Flag.php │ │ │ ├── Unify_Color_Presets.php │ │ │ └── Update_Publisher_Logos.php │ │ ├── Model/ │ │ │ └── Story.php │ │ ├── Page_Template_Post_Type.php │ │ ├── Post_Type_Base.php │ │ ├── REST_API/ │ │ │ ├── Embed_Controller.php │ │ │ ├── Font_Controller.php │ │ │ ├── Hotlinking_Controller.php │ │ │ ├── Link_Controller.php │ │ │ ├── Page_Template_Controller.php │ │ │ ├── Products_Controller.php │ │ │ ├── Publisher_Logos_Controller.php │ │ │ ├── Status_Check_Controller.php │ │ │ ├── Stories_Autosaves_Controller.php │ │ │ ├── Stories_Controller.php │ │ │ ├── Stories_Lock_Controller.php │ │ │ ├── Stories_Media_Controller.php │ │ │ ├── Stories_Taxonomies_Controller.php │ │ │ ├── Stories_Terms_Controller.php │ │ │ └── Stories_Users_Controller.php │ │ ├── Remove_Transients.php │ │ ├── Renderer/ │ │ │ ├── Archives.php │ │ │ ├── Feed.php │ │ │ ├── Oembed.php │ │ │ ├── Single.php │ │ │ ├── Stories/ │ │ │ │ ├── Carousel_Renderer.php │ │ │ │ ├── Fields/ │ │ │ │ │ └── BaseField.php │ │ │ │ ├── Generic_Renderer.php │ │ │ │ └── Renderer.php │ │ │ └── Story/ │ │ │ ├── Embed.php │ │ │ ├── HTML.php │ │ │ └── Image.php │ │ ├── Settings.php │ │ ├── Shopping/ │ │ │ ├── Product.php │ │ │ ├── Product_Meta.php │ │ │ ├── Shopify_Query.php │ │ │ └── Shopping_Vendors.php │ │ ├── Shortcode/ │ │ │ ├── Embed_Shortcode.php │ │ │ └── Stories_Shortcode.php │ │ ├── Story_Archive.php │ │ ├── Story_Post_Type.php │ │ ├── Story_Query.php │ │ ├── Story_Revisions.php │ │ ├── Taxonomy/ │ │ │ └── Taxonomy_Base.php │ │ ├── Tracking.php │ │ ├── User/ │ │ │ ├── Capabilities.php │ │ │ └── Preferences.php │ │ ├── Web_Stories_Compatibility.php │ │ └── Widgets/ │ │ └── Stories.php │ ├── shared/ │ │ └── Private_Access.php │ └── unit/ │ ├── bootstrap.php │ ├── includes/ │ │ ├── MarkupComparison.php │ │ ├── ScriptHash.php │ │ └── TestCase.php │ └── tests/ │ ├── AMP/ │ │ ├── Canonical_Sanitizer.php │ │ ├── Meta_Sanitizer.php │ │ ├── Output_Buffer.php │ │ └── Story_Sanitizer.php │ ├── Decoder.php │ └── Shopping/ │ └── WooCommerce_Query.php ├── tsconfig.json ├── tsconfig.shared.json ├── uninstall.php ├── web-stories.php ├── webpack.config.cjs └── webpack.config.test.cjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .allstar/branch_protection.yaml ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. dismissStale: false ================================================ FILE: .browserslistrc ================================================ last 2 Chrome versions last 2 Firefox versions last 2 Safari versions last 2 iOS versions last 2 Edge versions last 2 Opera versions ================================================ FILE: .distignore ================================================ .allstar .git .github .husky .idea .rollup.cache .storybook .wordpress-org __mocks__ __static__ assets/testjs bin build docs includes/composer.json karma node_modules packages patches plugin-assets public sitemap-generator static tests third-party/composer.json /vendor web-stories-scraper .browserslistrc .DS_Store .distignore .editorconfig .eslintignore .eslintrc .git-blame-ignore-revs .gitattributes .gitignore .markdownlint.json .markdownlintignore .npmignore .npmpackagejsonlintrc.json .npmrc .nvmrc .oxlintrc.json .phpstorm.config.js .phpstorm.meta.php .phpunit.result.cache .prettierignore .prettierrc .stylelintignore .stylelintrc .test_artifacts babel.config.cjs bun.lockb codecov.yml composer.lock CONTRIBUTING.md jest-puppeteer.config.cjs jsconfig.json karma-story-editor.config.cjs karma-dashboard.config.cjs package.json package-lock.json percy.config.karma.yml percy.config.yml phpcs.xml phpcs.xml.dist phpmd.xml phpstan.neon phpstan.neon.dist phpunit.xml phpunit.xml.dist phpunit-integration.xml phpunit-integration.xml.dist phpunit-integration-multisite.xml phpunit-integration-multisite.xml.dist README.md rollup.config.js scoper.inc.php tsconfig.json tsconfig.shared.json webpack.config.cjs webpack.config.test.cjs ================================================ FILE: .editorconfig ================================================ # WordPress Coding Standards # https://make.wordpress.org/core/handbook/coding-standards/ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 2 [*.php] indent_style = tab indent_size = 4 [*.md] trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ **/node_modules/** **/vendor/** **/dist/** **/dist-module/** **/dist-types/** **/assets/js/*.js **/packages/migration/scripts/module.js bin/build/* build/* !.storybook ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "@babel/eslint-parser", "extends": [ "plugin:@wordpress/eslint-plugin/i18n", "eslint:recommended", "plugin:import/recommended", "plugin:@eslint-community/eslint-comments/recommended", "plugin:react/recommended", "plugin:react-hooks/recommended-latest", "plugin:prettier/recommended", "plugin:jsx-a11y/recommended", "plugin:jsdoc/recommended", "plugin:styled-components-a11y/recommended", "plugin:oxlint/recommended" ], "plugins": [ "@babel", "@wordpress", "header", "jsdoc", "jsx-a11y", "markdown", "react", "react-hooks", "styled-components-a11y" ], "parserOptions": { "ecmaVersion": "latest", "ecmaFeatures": { "jsx": true } }, "rules": { "array-callback-return": "error", "block-scoped-var": "error", "complexity": ["error", {"max": 20}], "consistent-return": "error", "curly": ["error", "all"], "default-case": "error", "eol-last": "error", "eqeqeq": "error", "guard-for-in": "error", "no-await-in-loop": "error", "no-constant-binary-expression": "error", "no-extra-bind": "error", "no-extra-label": "error", "no-floating-decimal": "error", "no-implicit-coercion": "error", "no-implicit-globals": "error", "no-implied-eval": "error", "no-loop-func": "error", "no-new": "error", "no-new-func": "error", "no-new-wrappers": "error", "no-multiple-empty-lines": "error", "no-trailing-spaces": "error", "header/header": [ "error", "block", [ "", { "pattern": " \\* Copyright \\d{4} Google LLC", "template": " * Copyright 2025 Google LLC" }, " *", " * Licensed under the Apache License, Version 2.0 (the \"License\");", " * you may not use this file except in compliance with the License.", " * You may obtain a copy of the License at", " *", " * https://www.apache.org/licenses/LICENSE-2.0", " *", " * Unless required by applicable law or agreed to in writing, software", " * distributed under the License is distributed on an \"AS IS\" BASIS,", " * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.", " * See the License for the specific language governing permissions and", " * limitations under the License.", " " ] ], "import/no-cycle": "error", "import/no-restricted-paths": [ "error", { "zones": [ { "target": "./packages/migration/src/migrations", "from": "./packages/story-editor/src", "except": [ "./migration/migrations" ] }, { "target": "./packages/story-editor/src", "from": "./packages/story-editor/src/utils/useWhyDidYouUpdate.js" } ] } ], "import/dynamic-import-chunkname": [ "error", { "webpackChunknameFormat": "[0-9a-zA-Z-_/.[\\]]+" } ], "import/no-useless-path-segments": ["error", { "noUselessIndex": true }], "import/no-relative-packages": "error", "import/no-internal-modules": [ "error", { "forbid": [ "@googleforcreators/**/*" ] } ], "jsx-a11y/label-has-for": "off", "jsx-a11y/media-has-caption": [ "error", { "audio": [ "Audio" ], "video": [ "Video", "FadedVideo", "CropVideo", "StyledVideo" ], "track": [ "Track" ] }], "no-restricted-properties": "error", "no-return-assign": "error", "no-return-await": "error", "no-sequences": "error", "no-shadow": ["error", { "ignoreOnInitialization": true } ], "no-template-curly-in-string": "error", "no-throw-literal": "error", "no-unmodified-loop-condition": "error", "no-unused-vars": [ "error", { "ignoreRestSiblings": true } ], "no-useless-call": "error", "jsx-a11y/anchor-has-content": "off", "no-useless-concat": "error", "no-console": "error", "no-duplicate-imports": "error", "no-var": "error", "prefer-arrow-callback": [ "error", { "allowNamedFunctions": true } ], "prefer-const": "error", "prefer-object-spread": "error", "prefer-promise-reject-errors": "error", "prefer-rest-params": "error", "prefer-spread": "error", "radix": ["error", "as-needed"], "require-await": "error", "rest-spread-spacing": ["error", "never"], "react/forbid-component-props": ["error", { "forbid": ["for"] }], "react/jsx-key": "error", "react/no-array-index-key": "error", "react/no-unknown-property": [ "error", { "ignore": [ "amp", "amp-boilerplate", "amp-custom", "animate-in", "animate-in-delay", "animate-in-duration", "custom-element" ] } ], "react/prop-types": "error", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": [ "error", { "enableDangerousAutofixThisMayCauseInfiniteLoops": false, "additionalHooks": [ { "test": "useSelect$", "callbackIndex": 0 }, { "test": "useBatchingCallback", "callbackIndex": 0 }, { "test": "useKeyEffectInternal", "callbackIndex": 3 }, { "test": "useKeyEffect", "callbackIndex": 2 }, { "test": "useKeyDownEffect", "callbackIndex": 2 }, { "test": "useKeyUpEffect", "callbackIndex": 2 }, { "test": "useGlobalKeyDownEffect", "callbackIndex": 1 }, { "test": "useGlobalKeyUpEffect", "callbackIndex": 1 }, { "test": "useIntersectionEffect", "callbackIndex": 2 }, { "test": "useResizeEffect", "callbackIndex": 1 } ] } ], "react/jsx-boolean-value": "error", "react/jsx-fragments": "error", "react/jsx-no-literals": "error", "react/jsx-no-useless-fragment": "error", "react/no-unused-prop-types": "error", "react/react-in-jsx-scope": "off", "react/self-closing-comp": "error", "react-hooks/config": "error", "react-hooks/error-boundaries": "error", "react-hooks/component-hook-factories": "error", "react-hooks/gating": "error", "react-hooks/globals": "error", "react-hooks/immutability": "error", "react-hooks/preserve-manual-memoization": "error", "react-hooks/purity": "error", "react-hooks/refs": "error", "react-hooks/set-state-in-effect": "error", "react-hooks/set-state-in-render": "error", "react-hooks/static-components": "error", "react-hooks/unsupported-syntax": "warn", "react-hooks/use-memo": "error", "react-hooks/incompatible-library": "warn", "import/no-extraneous-dependencies": "error", "import/no-unresolved": "error", "import/order": [ "error", { "groups": [ "builtin", ["external", "unknown"], "internal", "parent", "sibling", "index" ] } ], "jsdoc/check-indentation": "error", "jsdoc/check-syntax": "error", "jsdoc/check-tag-names": ["error", { "definedTags": [ "jest-environment" ] }], "jsdoc/reject-any-type": "off", "jsdoc/reject-function-type": "off", "jsdoc/require-jsdoc": ["off", { "publicOnly": true }], "jsdoc/require-returns": "error", "jsdoc/require-param-description": "error", "jsdoc/tag-lines": [ "error", "any", { "startLines": 1 } ], "jsdoc/valid-types": "error", "@eslint-community/eslint-comments/no-unused-disable": "error", "@eslint-community/eslint-comments/require-description": "error", "@wordpress/dependency-group": "error", "@wordpress/i18n-no-flanking-whitespace": "error", "@wordpress/no-unused-vars-before-return": ["error", { "excludePattern": "^use" }], "@wordpress/react-no-unsafe-timeout": "error", "@wordpress/i18n-text-domain": ["error", { "allowedTextDomain": "web-stories" }], "@wordpress/valid-sprintf": "error", "@babel/no-unused-expressions": [ "error", { "allowShortCircuit": true } ] }, "env": { "browser": true, "es2020": true }, "globals": { "__webpack_public_path__": "writable", "WEB_STORIES_CI": "readonly", "WEB_STORIES_DISABLE_ERROR_BOUNDARIES": "readonly", "WEB_STORIES_DISABLE_OPTIMIZED_RENDERING": "readonly", "WEB_STORIES_DISABLE_PREVENT": "readonly", "WEB_STORIES_DISABLE_QUICK_TIPS": "readonly", "WEB_STORIES_ENV": "readonly" }, "settings": { "import/resolver": { "@web-stories-wp/eslint-import-resolver": { "mapping": { "^@googleforcreators\\/(.*)\\/(.*)": "./packages/$1/src/$2", "^@googleforcreators\\/(.*)": "./packages/$1/src/", "^@web-stories-wp\\/(.*)": "./packages/$1/src/" }, "extensions": [ ".js", ".jsx", ".ts", ".tsx" ] } }, "jsdoc": { "mode": "typescript", "preferredTypes": { "object": "Object" }, "tagNamePreference": { "returns": "return", "yields": "yield" } }, "react": { "version": "detect" }, "linkComponents": [ "Link", {"name": "Plain", "linkAttribute": "href"}, {"name": "Primary", "linkAttribute": "href"}, "PrimaryLink", "SecondaryLink", "ExternalLink", "ScrimAnchor" ], "testing-library/custom-renders": [ "arrange", "renderWithTheme", "renderWithProviders", "renderPanel", "setup" ] }, "overrides": [ { "files": [ "**/*.ts", "**/*.tsx" ], "plugins": [ "@typescript-eslint" ], "extends": [ "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-type-checked" ], "parser": "@typescript-eslint/parser", "parserOptions": { "projectService": true, "warnOnUnsupportedTypeScriptVersion": false }, "rules": { "@typescript-eslint/no-shadow": ["error", { "ignoreOnInitialization": true } ], "@typescript-eslint/unbound-method": [ "error", { "ignoreStatic": true } ], "@typescript-eslint/no-unused-vars": [ "error", { "ignoreRestSiblings": true } ], "react/prop-types": "off", "getter-return": "off", "jsdoc/check-param-names": "off", "jsdoc/require-param": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns": "off", "jsdoc/require-returns-check": "off", "jsdoc/require-returns-type": "off", "jsdoc/no-types": "error", "no-duplicate-imports": "off", "no-unused-vars": "off", "no-shadow": "off", "no-restricted-imports": [ "error", { "paths": [ { "name": "prop-types", "message": "Use TypeScript instead." } ] } ], "import/no-unresolved": "off", "import/default": "off", "import/named": "off" } }, { "files": [ "typings/**/*.ts", "**/*.d.ts" ], "rules": { "no-undef": "off", "no-unused-vars": "off", "no-var": "off", "@wordpress/dependency-group": "off" } }, { "files": [ "__mocks__/**/*.js", "**/test/**/*.js", "**/test/**/*.ts", "**/testUtils/**/*.js", "**/testUtils/**/*.ts", "tests/js/**/*.js" ], "excludedFiles": [ "**/karma/**/*.js", "**/test/**/*.karma.js" ], "extends": [ "plugin:jest/all", "plugin:testing-library/react", "plugin:jest-dom/recommended", "plugin:jest-extended/all" ], "plugins": [ "jsx-a11y", "header", "testing-library", "jest-dom" ], "env": { "node": true }, "rules": { "@eslint-community/eslint-comments/require-description": "off", "react/prop-types": "off", "react-hooks/globals": "off", "react-hooks/immutability": "off", "jest/no-hooks": "off", "jest/no-untyped-mock-factory": "off", "jest/max-expects": "off", "jest/prefer-expect-assertions": "off", "jest/prefer-importing-jest-globals": "off", "jest/prefer-inline-snapshots": "off", "jest/prefer-lowercase-title": [ "error", { "ignore": ["describe"] } ], "jest/prefer-mock-promise-shorthand": "off", "jest/prefer-snapshot-hint": "off", "jest/unbound-method": "off", "jest/padding-around-all": "off", "jest/padding-around-after-each-blocks": "off", "jest/padding-around-after-all-blocks": "off", "jest/padding-around-before-all-blocks": "off", "jest/padding-around-before-each-blocks": "off", "jest/padding-around-describe-blocks": "off", "jest/padding-around-expect-groups": "off", "jest/padding-around-test-blocks": "off", "jest/prefer-ending-with-an-expect": "off", "jsdoc/require-jsdoc": "off", "testing-library/no-await-sync-events": "error", "testing-library/no-debugging-utils": "error", "testing-library/no-dom-import": "error" } }, { "files": [ "**/test/**/*.ts", "**/testUtils/**/*.ts" ], "rules": { "@typescript-eslint/unbound-method": "off", "jest/unbound-method": "error", "jest/no-untyped-mock-factory": "error" } }, { "files": [ "__mocks__/**/*.js" ], "rules": { "jest/require-hook": "off" } }, { "files": [ "**/karma/**/*.js", "packages/karma-*/**/*.js" ], "extends": [ "plugin:jasmine/recommended" ], "plugins": [ "jsx-a11y", "header", "testing-library", "jasmine" ], "env": { "jasmine": true, "node": true }, "rules": { "@eslint-community/eslint-comments/require-description": "off", "testing-library/no-await-sync-events": "error", "testing-library/no-await-sync-queries": "error", "testing-library/no-debugging-utils": "error", "testing-library/no-dom-import": "error", "jasmine/new-line-before-expect": "off", "jasmine/no-disabled-tests": "error", "jasmine/no-spec-dupes": ["error", "branch"], "jasmine/no-suite-dupes": ["error", "branch"], "jsdoc/require-jsdoc": "off", "no-restricted-imports": [ "error", { "paths": [ { "name": "@testing-library/react-hooks", "message": "Use karma fixtures instead." } ] } ] }, "globals": { "karmaPuppeteer": "readonly", "karmaSnapshot": "readonly" } }, { "files": [ "packages/karma-puppeteer-client/**/*.js" ], "rules": { "no-var": "off", "prefer-const": "off" } }, { "files": [ "packages/jest-amp/src/**/*.js", "packages/jest-puppeteer-amp/src/**/*.js" ], "env": { "node": true } }, { "files": [ "packages/jest-puppeteer-amp/src/**/*.js" ], "globals": { "browser": "readonly", "page": "readonly" } }, { "files": [ "packages/e2e-test-utils/**/*.js", "packages/e2e-tests/src/**/*.js" ], "extends": [ "plugin:jest/all" ], "env": { "node": true }, "rules": { "@eslint-community/eslint-comments/require-description": "off", "jest/max-expects": "off", "jest/no-hooks": "off", "jest/prefer-ending-with-an-expect": "off", "jest/prefer-expect-assertions": "off", "jest/prefer-importing-jest-globals": "off", "jest/prefer-inline-snapshots": "off", "jest/prefer-lowercase-title": [ "error", { "ignore": ["describe"] } ], "jest/require-hook": [ "error", { "allowedFunctionCalls": [ "minWPVersionRequired", "withDisabledToolbarOnFrontend", "withExperimentalFeatures", "withPlugin", "withRTL", "withUser" ] } ], "jest/prefer-snapshot-hint": "off", "jest/unbound-method": "off", "jest/padding-around-all": "off", "jest/padding-around-after-each-blocks": "off", "jest/padding-around-after-all-blocks": "off", "jest/padding-around-before-all-blocks": "off", "jest/padding-around-before-each-blocks": "off", "jest/padding-around-describe-blocks": "off", "jest/padding-around-expect-groups": "off", "jest/padding-around-test-blocks": "off" }, "globals": { "browser": "readonly", "page": "readonly", "wp": "readonly" } }, { "files": [ "packages/e2e-test-utils/**/*.js" ], "rules": { "@eslint-community/eslint-comments/require-description": "off", "jest/expect-expect": "off", "jest/max-expects": "off", "jest/no-export": "off", "jest/require-top-level-describe": "off" } }, { "files": ["packages/e2e-tests/src/specs/**/*.js"], "rules": { "jsdoc/require-jsdoc": "off" } }, { "files": [ "packages/e2e-tests/src/config/**/*.js", "**/testUtils/**/*.js" ], "rules": { "@eslint-community/eslint-comments/require-description": "off", "jest/require-hook": "off" } }, { "files": [ "packages/commander/**/*.ts", "packages/fonts/scripts/**/*.ts", "packages/migration/scripts/**/*.js", "packages/templates/scripts/**/*.js", "packages/text-sets/scripts/**/*.js" ], "rules": { "import/no-useless-path-segments": ["error", { "noUselessIndex": false }] } }, { "files": [ "__mocks__/**/*.js", "bin/**/*.js", "babel.config.cjs", "rollup.config.js", "jest-puppeteer.config.cjs", "karma-*.config.cjs", "webpack.*.cjs", ".storybook/*.cjs", ".storybook/*.js", "tests/js/*.js", "packages/e2e-tests/src/*.js", "packages/e2e-tests/src/config/*.js", "packages/dashboard/src/karma-tests.cjs", "packages/story-editor/src/karma-tests.cjs", "packages/eslint-import-resolver/**/*.cjs", "packages/jest-resolver/**/*.cjs", "packages/fonts/**/*.ts", "packages/commander/**/*.ts", "packages/migration/scripts/**/*.js", "packages/templates/scripts/**/*.js", "packages/text-sets/scripts/**/*.js" ], "extends": [ "plugin:n/recommended", "plugin:security/recommended-legacy" ], "plugins": [ "security" ], "env": { "node": true }, "rules": { "@eslint-community/eslint-comments/require-description": "off", "n/no-extraneous-import": "off", "n/no-missing-import": "off", "n/no-unpublished-import": "off", "n/no-unpublished-require": "off", "security/detect-non-literal-fs-filename": "off" } }, { "files": [ "packages/commander/src/index.ts" ], "rules": { "no-console": "off", "n/hashbang": "off" } }, { "files": [ "packages/migration/scripts/utils/updateTemplates.js" ], "rules": { "@eslint-community/eslint-comments/no-unused-disable": "off" } }, { "files": [ "*.md", "**/*.md" ], "processor": "markdown/markdown" }, { "files": [ "**/*.md/*.js" ], "parserOptions": { "ecmaFeatures": { "impliedStrict": true } }, "rules": { "import/no-unresolved": "off", "import/no-extraneous-dependencies": "off", "@wordpress/dependency-group": "off", "no-console": "off", "no-undef": "off", "no-unused-vars": "off", "header/header": "off", "prettier/prettier": "off", "@eslint-community/eslint-comments/require-description": "off", "react/prop-types" : "off" } }, { "files": [ "packages/dashboard/src/**/*.js", "packages/story-editor/src/**/*.js", "packages/**/*.js" ], "excludedFiles": [ "packages/activation-notice/**/*.tsx", "packages/i18n/**/*.js", "packages/stories-block/**/*.js", "packages/tinymce-button/**/*.js" ], "rules":{ "no-restricted-imports": [ "error", { "paths": [ { "name": "@wordpress/i18n", "message": "Use @googleforcreators/i18n instead." } ] } ] } }, { "files": [ "packages/activation-notice/**/*.tsx", "packages/stories-block/**/*.js", "packages/tinymce-button/**/*.js" ], "rules":{ "@wordpress/data-no-store-string-literals": "error", "no-restricted-imports": [ "error", { "paths": [ { "name": "@googleforcreators/i18n", "message": "Use @wordpress/i18n instead." }, { "name": "react", "message": "Please use React API through `@wordpress/element` instead." }, { "name": "react-dom", "message": "Please use React API through `@wordpress/element` instead." } ] } ] } }, { "files": [ "packages/**/*.js" ], "excludedFiles": [ ".storybook/preview.js", "__mocks__/**/*.js", "packages/activation-notice/**/*.tsx", "packages/react/**/*.js", "packages/dashboard/src/karma/fixture.js", "packages/story-editor/src/karma/fixture/fixture.js", "packages/story-block/**/*.js", "packages/tinymce-button/**/*.js" ], "rules":{ "no-restricted-imports": [ "error", { "paths": [ { "name": "react", "message": "Use @googleforcreators/react instead." }, { "name": "react-dom", "message": "Use @googleforcreators/react instead." } ] } ] } }, { "files": [ "packages/activation-notice/**/*.tsx" ], "rules":{ "no-restricted-imports": "off", "import/no-named-as-default": "off", "@typescript-eslint/no-restricted-imports": [ "error", { "paths": [ { "name": "react", "message": "Use @wordpress/element instead.", "allowTypeImports": true }, { "name": "react-dom", "message": "Use @wordpress/element instead.", "allowTypeImports": true } ] } ] } }, { "files": [ "packages/tinymce-button/src/**/*.js" ], "globals": { "tinymce": "readonly" } }, { "files": [ "**/stories/*.js", ".storybook/main.cjs" ], "rules": { "react/no-array-index-key" : "off", "react/prop-types": "off", "@eslint-community/eslint-comments/no-unused-disable": "off", "@eslint-community/eslint-comments/require-description": "off" } }, { "files": [ "__mocks__/**/*", ".storybook/stories/**/*" ], "rules": { "import/no-extraneous-dependencies" : "off" } }, { "files": [ "packages/story-editor/src/**/*.js", "packages/dashboard/src/**/*.js" ], "rules": { "no-restricted-imports": [ "error", { "patterns": [ { "group": [ "**/*.css" ], "message": "css import is not allowed in story-editor and dashboard" } ] } ] } }, { "files": [ "packages/date/src/third_party/**/*" ], "rules": { "header/header": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-unsafe-return": "off" } }, { "files": [ "packages/story-editor/src/components/canvas/mediaCaptions/cue.js" ], "rules": { "@eslint-community/eslint-comments/no-unused-disable": "off" } }, { "files": [ "packages/design-system/src/components/keyboard/keyboard.tsx", "packages/element-library/src/media/editCropMoveable.tsx", "packages/element-library/src/media/editPanMoveable.tsx", "packages/element-library/src/text/edit.tsx", "packages/element-library/src/video/playPauseButton.tsx", "packages/react/src/useBatchingCallback.ts", "packages/react/src/useResizeEffect.ts", "packages/story-editor/src/app/helpCenter/provider.tsx", "packages/story-editor/src/app/media/media3p/useContextValueProvider.js", "packages/story-editor/src/app/media/media3p/useFetchMediaEffect.js", "packages/story-editor/src/app/quickActions/useQuickActions.js", "packages/story-editor/src/components/canvas/mediaRecordingLayer.js", "packages/story-editor/src/components/canvas/multiSelectionMoveable/useDrag.js", "packages/story-editor/src/components/canvas/multiSelectionMoveable/useResize.js", "packages/story-editor/src/components/canvas/multiSelectionMoveable/useRotate.js", "packages/story-editor/src/components/canvas/singleSelectionMoveable/index.js", "packages/story-editor/src/components/canvas/singleSelectionMoveable/useDrag.js", "packages/story-editor/src/components/canvas/singleSelectionMoveable/useResize.js", "packages/story-editor/src/components/canvas/singleSelectionMoveable/useRotate.js", "packages/story-editor/src/components/canvas/utils/useUpdateSelectionRectangle.js", "packages/story-editor/src/components/colorPicker/editablePreview.js", "packages/story-editor/src/components/form/usePresubmitHandler.js", "packages/story-editor/src/components/library/panes/shopping/shoppingPane.js", "packages/story-editor/src/components/mediaRecording/audio.js", "packages/story-editor/src/components/mediaRecording/footer.js", "packages/story-editor/src/components/panels/shared/useCommonObjectValue.js", "packages/story-editor/src/components/videoTrim/useVideoNode.js", "packages/transform/src/useTransformHandler.ts", "packages/wp-story-editor/src/components/fontCheck/index.js" ], "rules": { "react-hooks/refs": "off" } } ] } ================================================ FILE: .git-blame-ignore-revs ================================================ bf0cb0583153e71fcceaee044550ad511b94d470 1e93c55d2c9e596404646a3bd1785f6249f9945c fe28ba3ced458b23fc53574bb25d6d3fbe6a4b3f e6594d05a23cbd026f51668adc652fda959febbb f459efbc4a2c9546a0ca2fed1301e4f222fa2340 72c8fa5e2419876f20507240314d0acbb825059d ================================================ FILE: .gitattributes ================================================ *.snap linguist-generated=true .github export-ignore .storybook export-ignore .wordpress-org export-ignore __mocks__ export-ignore __static__ export-ignore bin export-ignore karma export-ignore patches export-ignore tests export-ignore .* export-ignore *.config.cjs export-ignore *.config.test.cjs export-ignore codecov.yml export-ignore package.json export-ignore package-lock.json export-ignore percy.config.yml export-ignore *.xml.dist export-ignore packages/fonts/src/fonts.json linguist-generated=true includes/data/fonts/fonts.json linguist-generated=true tests/phpunit/integration/data/schema.json linguist-generated=true packages/e2e-tests/src/specs/editor/shopping/schema.json linguist-generated=true tsconfig.json linguist-language=jsonc ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: Create a report to help us improve. title: '' labels: 'Type: Bug' assignees: '' --- ## Bug Description ## Expected Behaviour ## Steps to Reproduce ## Screenshots ## Additional Context - Plugin Version: - WordPress Version: - Operating System: - Browser: ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ # todo: disable in future? blank_issues_enabled: true ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.md ================================================ --- name: Enhancement about: Suggest an idea for this project. title: '' labels: 'Type: Enhancement' assignees: '' --- ## Feature Description ## Alternatives Considered ## Additional Context ================================================ FILE: .github/ISSUE_TEMPLATE/epic.md ================================================ --- name: Epic about: A theme of work that contains several issues or sub-tasks. title: '' labels: 'Epic' assignees: '' --- ## Summary ## References ## Alternatives Considered ## Acceptance Criteria ### Does this epic have any performance impact? ### Does this epic have telemetry? ================================================ FILE: .github/ISSUE_TEMPLATE/task.md ================================================ --- name: Task about: Tasks which do not involve engineering. title: '' labels: 'Type: Task' assignees: '' --- ## Task Description ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Summary ## User-facing changes ## Testing Instructions - [ ] This is a non-user-facing change and requires no QA This PR can be tested by following these steps: 1. ## Reviews ### Does this PR have a security-related impact? ### Does this PR change what data or activity we track or use? ### Does this PR have a legal-related impact? ## Checklist - [ ] This PR addresses an existing issue and I have linked this PR to it - [ ] I have tested this code to the best of my abilities - [ ] I have verified accessibility to the best of my abilities ([docs](https://github.com/googleforcreators/web-stories-wp/blob/main/docs/accessibility-testing.md)) - [ ] I have verified i18n and l10n (translation, right-to-left layout) to the best of my abilities - [ ] This code is covered by automated tests (unit, integration, and/or e2e) to verify it works as intended ([docs](https://github.com/googleforcreators/web-stories-wp/tree/main/docs#testing)) - [ ] I have added documentation where necessary - [ ] I have added a matching `Type: XYZ` label to the PR --- Fixes # ================================================ FILE: .github/SUPPORT.md ================================================ Thank you for being an early adopter of Web Stories for WordPress! We're working around the clock to improve your experience and add editing capabilities. If you find any issues, please reach out by visiting the [support forum](https://wordpress.org/support/plugin/web-stories/) to ask any questions or file feature requests. [Visit support forums](https://wordpress.org/support/plugin/web-stories/) ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: '/' schedule: interval: quarterly open-pull-requests-limit: 10 labels: - Dependencies groups: github-actions: patterns: - '*' - package-ecosystem: npm directory: '/' schedule: interval: quarterly open-pull-requests-limit: 15 labels: - Dependencies - JavaScript groups: wp-packages: patterns: - '@wordpress/*' exclude-patterns: - '@wordpress/element' storybook: patterns: - 'storybook' - '@storybook/*' typescript-eslint: patterns: - '@typescript-eslint/*' babel: patterns: - 'babel-*' - '@babel/*' exclude-patterns: - 'babel-plugin-react-compiler' oxlint: patterns: - 'oxlint' - 'eslint-plugin-oxlint' react-compiler: patterns: - '*-react-compiler' - 'react-compiler-*' ignore: # styled-components is not yet compatible. - dependency-name: 'stylis-plugin-rtl' versions: - '>= 2' # Not all plugins are compatible yet - dependency-name: 'rollup' versions: - '>= 3' # We have not yet upgraded to React 18. - dependency-name: 'react' versions: - '>= 18' - dependency-name: 'react-dom' versions: - '>= 18' - dependency-name: '@testing-library/react' versions: - '>= 13' # ESM only - dependency-name: 'use-context-selector' versions: - '>= 2' - dependency-name: 'flagged' versions: - '>= 3' - dependency-name: 'mime' versions: - '>= 4' # Needs manual update - dependency-name: 'styled-components' versions: - '>= 6' # Needs manual update - dependency-name: 'eslint' versions: - '>= 9' - package-ecosystem: composer directory: '/' schedule: interval: quarterly open-pull-requests-limit: 10 labels: - Dependencies - PHP ================================================ FILE: .github/release.yml ================================================ changelog: exclude: authors: - dependabot - github-actions - googleforcreators-bot categories: - title: '🚀 Features' labels: - 'Type: Enhancement' - title: '🐛 Bug Fixes' labels: - 'Type: Bug' - title: '🧰 Maintenance' labels: - 'Type: Infrastructure' - 'Type: Code Quality' - title: ':memo: Documentation' labels: - 'Type: Documentation' - title: 'Other Changes' labels: - '*' ================================================ FILE: .github/workflows/build-and-deploy.yml ================================================ name: Build plugin on: push: # Don't run for irrelevant changes. paths-ignore: - 'docs/**' - '.storybook/**' - '.wordpress-org/**' - '__mocks__/**' - '__static__/**' - 'bin/**' - 'packages/e2e-test-utils/**' - 'packages/e2e-tests/**' - 'packages/karma-*/**' - 'tests/**' - '**.md' - '**.yml' - '**.neon.dist' - '**.xml.dist' - '.editorconfig' - '.eslint*' - '.markdownlint*' - '.phpstorm.meta.php' - '.prettier*' - '.stylelint*' - '.github/workflows/**' - '!.github/workflows/build-and-deploy.yml' branches: - main - release/* pull_request: types: - opened - reopened - synchronize - ready_for_review # Don't run for irrelevant changes. paths-ignore: - 'docs/**' - '.storybook/**' - '.wordpress-org/**' - '__mocks__/**' - '__static__/**' - 'bin/**' - 'packages/e2e-test-utils/**' - 'packages/e2e-tests/**' - 'packages/karma-*/**' - 'tests/**' - '**.md' - '**.yml' - '**.neon.dist' - '**.xml.dist' - '.editorconfig' - '.eslint*' - '.markdownlint*' - '.phpstorm.meta.php' - '.prettier*' - '.stylelint*' - '.github/workflows/**' - '!.github/workflows/build-and-deploy.yml' permissions: contents: read pull-requests: write # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: bundle-size: name: Bundle size check runs-on: ubuntu-latest timeout-minutes: 15 # The action cannot annotate the PR when run from a PR fork or authored by Dependabot. if: > github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.login != 'dependabot[bot]' steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: block allowed-endpoints: > cloudresourcemanager.googleapis.com:443 codeserver.dev.6b7f1eeb-705b-4201-864d-2007030c8372.drush.in:2222 dl.google.com:443 github.com:443 api.github.com:443 oauth2.googleapis.com:443 objects.githubusercontent.com:443 packagist.org:443 registry.npmjs.org:443 storage.googleapis.com:443 54.185.253.63:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Bundle size check uses: preactjs/compressed-size-action@66325aad6443cb7cf89c4bfcd414aea2367cda94 with: repo-token: ${{ secrets.GITHUB_TOKEN }} pattern: '{assets/js/*.js,assets/css/*.css}' build-script: 'build:js' minimum-change-threshold: 100 # Ignore chunk and module hashes in bundle filenames. strip-hash: '.*-(\w{20})|^(\d{2,5})\.js$' ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: CodeQL on: push: # Only run if JS files changed. paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' branches: - main - release/* pull_request: # Only run if JS files changed. paths: - '**.js' - '**.cjs' - '**.ts' # The branches below must be a subset of the branches above branches: - main permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Initialize CodeQL uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 with: languages: javascript - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 ================================================ FILE: .github/workflows/deploy-storybook.yml ================================================ name: Deploy to GH Pages on: push: # Don't run for irrelevant changes. paths-ignore: - 'docs/**' - '!.github/workflows/deploy-storybook.yml' - '.storybook/**' - '.wordpress-org/**' - '__mocks__/**' - '__static__/**' - 'bin/**' - 'tests/**' branches: - main permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: build-storybook: name: Build storybook runs-on: ubuntu-latest permissions: contents: write # for Git to git push timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Build storyook run: npm run storybook:build - name: Checkout gh-pages uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: gh-pages token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} path: gh-pages - name: Move storybook assets run: | rm -rf gh-pages/storybook/* mv build/storybook/* gh-pages/storybook/ - name: Commit updates run: | git add . git status git diff --staged --quiet && echo 'No changes to commit; exiting!' && exit 0 git commit -m "Deploy storybook for ${{ github.sha }}" git pull --rebase --no-edit --quiet git push origin gh-pages env: GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot working-directory: ./gh-pages ================================================ FILE: .github/workflows/lint-css-js-md.yml ================================================ name: Lint CSS/JS/MD on: push: # Only run if CSS/JS/MD-related files changed. paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**.css' - 'docs/**/*.md' - 'packages/**/*.md' - '.eslint*' - '.markdownlint*' - '.npmpackagejsonlintrc.json' - '.nvmrc' - '.prettier*' - '.stylelint*' - '**/package.json' - 'package-lock.json' branches: - main - release/* pull_request: # Only run if CSS/JS/MD-related files changed. paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**.css' - 'docs/**/*.md' - 'packages/**/*.md' - '.eslint*' - '.markdownlint*' - '.npmpackagejsonlintrc.json' - '.nvmrc' - '.prettier*' - '.stylelint*' - '**/package.json' - 'package-lock.json' # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true permissions: contents: read jobs: lint: name: Lint runs-on: ubuntu-latest permissions: checks: write # for ataylorme/eslint-annotate-action to create checks contents: read # for actions/checkout to fetch code pull-requests: read # for ataylorme/eslint-annotate-action to get changed PR files timeout-minutes: 20 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-sudo: true disable-file-monitoring: true egress-policy: block allowed-endpoints: > api.github.com:443 github.com:443 registry.npmjs.org:443 bun.sh:443 54.185.253.63:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Markdown Lint run: npm run lint:md - name: CSS Lint run: npm run lint:css - name: package.json Lint run: npm run lint:package-json # Do this first so that the types are available to ESLint, # particularly the more sophisticated `@typescript/eslint` rules. - name: Type checking run: npm run workflow:bundle-packages:types - name: JS Lint run: npm run lint:js env: NODE_OPTIONS: --max-old-space-size=4096 if: > github.event.pull_request.head.repo.fork == true || github.event.pull_request.user.login == 'dependabot[bot]' - name: JS Lint Report run: npm run lint:js:report continue-on-error: true env: NODE_OPTIONS: --max-old-space-size=4096 # Prevent generating the ESLint report if PR is from a fork or authored by Dependabot. if: > github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.login != 'dependabot[bot]' - name: Annotate JS Lint Results uses: ataylorme/eslint-annotate-action@3.0.0 with: repo-token: '${{ secrets.GITHUB_TOKEN }}' report-json: 'build/lint-js-report.json' # The action cannot annotate the PR when run from a PR fork or authored by Dependabot. if: > github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.login != 'dependabot[bot]' - name: JSON Schema validation run: npm run test:schema ================================================ FILE: .github/workflows/lint-i18n.yml ================================================ name: Lint I18N on: push: paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'web-stories.php' - 'includes/**.php' - '.github/workflows/lint-i18n.yml' branches: - main pull_request: paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'web-stories.php' - 'includes/**.php' - '.github/workflows/lint-i18n.yml' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest coverage: none tools: wp-cli - name: Install latest version of i18n-command run: wp package install wp-cli/i18n-command:@stable - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest coverage: none tools: composer - name: Install dependencies run: | npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Build plugin run: bun run build:js - name: Bundle regular version run: bun run workflow:build-plugin # Check if as many strings as expected were found. # Fail job if `wp i18n make-pot` returns any warnings. # Some false positive warnings are removed due to a bug in the string extraction. # That's why this step is unfortunately a bit more complex. # See https://github.com/wp-cli/i18n-command/issues/154 - name: Generate POT file run: | OUTPUT=$((wp i18n make-pot build/web-stories build/web-stories.pot) 2>&1 >/dev/null) HAS_ERROR=false EXPECTED_NUMBER_OF_STRINGS=1000 NUMBER_OF_FOUND_STRINGS=$(grep -o msgstr build/web-stories.pot | wc -l | xargs) if (( "$NUMBER_OF_FOUND_STRINGS" < "$EXPECTED_NUMBER_OF_STRINGS" )); then HAS_ERROR=true echo "String extraction found only $NUMBER_OF_FOUND_STRINGS translatable strings. Expected at least $EXPECTED_NUMBER_OF_STRINGS." fi IFS=$'\n' declare -a WARNINGS=($OUTPUT) unset IFS for WARNING in "${WARNINGS[@]}"; do # Filter false positives. if [[ $WARNING == *"translator comment"* ]] && [[ $WARNING != *"%s"* ]]; then continue fi HAS_ERROR=true echo $WARNING done if [[ "$HAS_ERROR" = true ]]; then exit 1 fi ================================================ FILE: .github/workflows/lint-php.yml ================================================ name: Lint PHP on: push: # Only run if PHP-related files changed. paths: - '**.php' - 'phpcs.xml.dist' - 'phpmd.xml' - 'phpstan.neon.dist' - 'composer.json' - 'composer.lock' - '.github/workflows/lint-php.yml' branches: - main - release/* pull_request: # Only run if PHP-related files changed. paths: - '**.php' - 'phpcs.xml.dist' - 'phpmd.xml' - 'phpstan.neon.dist' - 'composer.json' - 'composer.lock' - '.github/workflows/lint-php.yml' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: block allowed-endpoints: > api.github.com:443 github.com:443 objects.githubusercontent.com:443 packagist.org:443 repo.packagist.org:443 getcomposer.org:443 dl.cloudsmith.io:443 54.185.253.63:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest coverage: none tools: composer, cs2pr - name: Validate composer.json run: composer --no-interaction validate --no-check-all - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Detect coding standard violations (PHPCS) run: vendor/bin/phpcs -q --report=checkstyle --severity=1 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 | cs2pr --graceful-warnings - name: Static Analysis (PHPStan) run: composer phpstan - name: Static Analysis (PHPMD) run: composer phpmd - name: Normalize composer.json run: composer normalize --no-interaction --dry-run ================================================ FILE: .github/workflows/lint-plugin-check.yml ================================================ name: Plugin Check on: push: paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'web-stories.php' - 'includes/**.php' - '.github/workflows/lint-plugin-check.yml' branches: - main pull_request: paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'web-stories.php' - 'includes/**.php' - '.github/workflows/lint-plugin-check.yml' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: '8.0' coverage: none tools: composer - name: Install dependencies run: | npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Build plugin run: bun run build:js - name: Bundle plugin run: bun run workflow:build-plugin - name: Run plugin check uses: wordpress/plugin-check-action@v1 with: build-dir: './build/web-stories' exclude-directories: 'third-party' exclude-checks: | late_escaping plugin_readme plugin_review_phpcs plugin_updater ================================================ FILE: .github/workflows/npm-release.yml ================================================ # npm packages release automation name: npm Release on: workflow_dispatch: permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true env: PRODUCTION_REGISTRY_URL: https://wombat-dressing-room.appspot.com LOCAL_REGISTRY_URL: http://localhost:4873 GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot jobs: dry-run: name: Dry-run release runs-on: ubuntu-latest timeout-minutes: 30 # This step requires additional review # See https://docs.github.com/en/actions/reference/environments environment: Production steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Bundle packages run: bun run workflow:bundle-packages env: NODE_OPTIONS: '--max_old_space_size=4096' # Exact format here doesn't matter for the dry-run, it's gonna be done properly later on. - name: Version bumps id: version_bumps run: npm version --no-git-tag-version --workspaces "0.1.$(date -u +%Y%m%d%H%M)" # Set up a local npm registry with Verdaccio. - name: Set up local registry run: bun run local-registry:start # Using Verdaccio - name: Publish packages locally run: npm --registry=$LOCAL_REGISTRY_URL --workspaces publish # Undo the version bumps in Git. We only needed them for testing. - name: Clean up local changes run: git checkout . # Verifies that packages can be installed without issues. - name: Install published packages run: | PUBLIC_PACKAGES=$(jq -r 'select(.private == false) | .name' $(find packages -maxdepth 2 -name "package.json")) TMPDIR=${TMPDIR-/tmp} TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") TEST_DIR=${TEST_DIR-$TMPDIR/packages-test} mkdir $TEST_DIR cd $TEST_DIR npm init --yes npm --registry=$LOCAL_REGISTRY_URL install $PUBLIC_PACKAGES npm ls --depth 0 - name: Stop local registry run: bun run local-registry:stop release: name: Release runs-on: ubuntu-latest permissions: contents: write # for Git to git push timeout-minutes: 20 needs: [dry-run] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} # See go/npm-publish - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm registry-url: ${{ env.PRODUCTION_REGISTRY_URL }} scope: '@googleforcreators' - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Bundle packages run: bun run workflow:bundle-packages env: NODE_OPTIONS: '--max_old_space_size=4096' # For the time being, using incremental versions like 0.1.202111302140 # `npm version` updates all packages, even the ones we don't intend to publish. # To address this, we undo the version change for private packages. # We're doing the commit ourselves since we only need it later on and # since committing doesn't work properly when using workspaces. # See https://github.com/npm/cli/issues/4017 - name: Version bumps id: version_bumps run: | NEW_VERSION_RAW="0.1.$(date -u +%Y%m%d%H%M)"; NEW_VERSION="v$NEW_VERSION_RAW" npm version --no-git-tag-version --workspaces $NEW_VERSION_RAW # Undo changes to all the private packages. for package_file in ./packages/*/package.json; do if [[ $(cat $package_file | jq '.private') == true ]]; then git checkout --quiet $package_file fi done # Updates the lock file. npm install git add packages/*/package.json git add package-lock.json echo "Committing version bump" echo git commit -m "Bumping npm packages version to $NEW_VERSION" echo "Adding tags" echo # For every public package, this creates a tag in the form "-v1234". # Example: templates-v1234 for package_file in ./packages/*/package.json; do if [[ $(cat $package_file | jq '.private') == false ]]; then package_name=$(basename $(dirname $package_file)) echo "Adding tag: $package_name-$NEW_VERSION" git tag "$package_name-$NEW_VERSION" fi done echo "Commit details:" echo git status git log -n 1 echo "Added tags:" echo git tag --points-at HEAD echo "Changed files:" echo git show --pretty=%gd --stat git push origin main --tags # Do the actual publishing to npmjs.com via Wombat Dressing Room. - name: Publish packages to production run: npm --registry=$PRODUCTION_REGISTRY_URL --workspaces publish --workspaces env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/plugin-release.yml ================================================ # Plugin release automation # # Builds the plugin for release candidates and stable releases. # # Creates the release branch, the actual release on GitHub, and the correct tag. # # For new major releases, the action should be run from the `main` branch. # For patch releases, the action should be run from the corresponding release branch (e.g. `release/1.2.0`) name: Plugin Release on: workflow_dispatch: inputs: version: description: 'Plugin version (e.g. 1.2.3 or 7.2.0-rc.1)' required: true permissions: contents: read env: PLUGIN_VERSION: ${{ github.event.inputs.version }} TAG_NAME: 'v${{ github.event.inputs.version }}' IS_RC: ${{ contains(github.event.inputs.version, 'rc') }} IS_PATCH_RELEASE: ${{ startsWith(github.ref, 'refs/heads/release/') }} GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot GITHUB_REPO_ID: 235435637 jobs: # Perform some sanity checks at the beginning to avoid surprises. checks: name: Checks runs-on: ubuntu-latest timeout-minutes: 5 # This step requires additional review # See https://docs.github.com/en/actions/reference/environments environment: Production steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Verify semver compatibility run: | if [[ $PLUGIN_VERSION =~ $SEMVER_VERSION_REGEX ]]; then echo "Given plugin version string is a valid semver version" else echo "Given plugin version string is not a valid semver version" exit 1 fi env: SEMVER_VERSION_REGEX: ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?(\+[0-9A-Za-z-]+)?$ - name: Verify release does not exist yet run: | if git describe --abbrev=0 --tags --match "$TAG_NAME" &>/dev/null; then echo "The planned plugin version already exists!" exit 1 fi # - name: Ensure RC exists for stable release # if: ${{ ! contains(github.event.inputs.version, 'rc') }} # run: | # VERSION_WITHOUT_SUFFIX=${PLUGIN_VERSION/-rc.*/} # BRANCH=release/$VERSION_WITHOUT_SUFFIX # # if [[ -z $(git ls-remote origin $BRANCH) ]]; then # echo "No release branch exists for this planned stable release" # exit 1 # fi # # git checkout --track origin/$BRANCH # # if ! git describe --abbrev=0 --tags --match "$TAG_NAME-rc.*" &>/dev/null; then # echo "No RC exists for this planned stable release" # exit 1 # fi - name: Ensure readme.txt contains changelog run: | VERSION_WITHOUT_SUFFIX=${PLUGIN_VERSION/-rc.*/} CHANGELOG_REGEX="= $VERSION_WITHOUT_SUFFIX =" if ! grep -q -P "$CHANGELOG_REGEX" readme.txt; then echo "No changelog found in readme.txt" exit 1 fi # Get the current CDN assets version. # If the static assets on the CDN have changed since the last release, # bump the assets version accordingly in the GoogleForCreators/wp.stories.google repo. assets-version: name: Prepare static assets runs-on: ubuntu-latest timeout-minutes: 10 needs: [checks] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs # TODO: Define behavior for patch releases. # # A patch release is done from a specific release branch instead of `main` # # Patch releases must not necessarily copy assets from `main`. # Given the following assets versions: # main 1 2 3 4 <- next major release # ^ # | # current branch # # The patch release should probably get version 3.1 or similar, # since version 4 is already used by the next major release. # # Right now, this is needs to be done manually for patch releases, # otherwise the assets version is left unchanged here. # Grab current assets version from `web-stories.php` and pass on to next steps. # - name: Checkout # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # with: # ref: # - name: Get current assets version # id: base_assets_version # run: | # BASE_ASSETS_VERSION=main # if [[ $(cat web-stories.php) =~ $ASSETS_VERSION_REGEX ]]; then # BASE_ASSETS_VERSION=${BASH_REMATCH[1]} # fi # echo "BASE_ASSETS_VERSION=$BASE_ASSETS_VERSION" >> $GITHUB_OUTPUT # env: # ASSETS_VERSION_REGEX: "https://wp.stories.google/static/([^']+)" - name: Checkout wp.stories.google uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: repository: GoogleForCreators/wp.stories.google lfs: true # Needed so the below commits will trigger a website deployment. token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Authenticate uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: credentials_json: ${{ secrets.GCP_SA_KEY }} - name: Setup Cloud SDK uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db with: project_id: ${{ secrets.GCP_PROJECT_ID }} # For release candidates of new major releases: # # 1. Get highest assets version # 2. Compare with `main` # 3. If they differ: # 3.1 Set new_version = version+1 # 3.2 Copy `main` to new_version # 3.2 Push new directory # 4. Else, keep currently highest version - name: Prepare assets for RC if: ${{ contains(github.event.inputs.version, 'rc') && ! startsWith(github.ref, 'refs/heads/release/') }} run: | LATEST_ASSETS_VERSION=$(gcloud storage ls gs://web-stories-wp-cdn-assets/ | sed 's/gs:\/\/web-stories-wp-cdn-assets\///' | sed 's/\///' | sort -n | tail -1) NEW_ASSETS_VERSION=$LATEST_ASSETS_VERSION NUMBER_OF_NEW_ASSETS=$(ls main | wc -l) if [[ "0" -eq NUMBER_OF_NEW_ASSETS ]]; then echo "No new assets found. Not uploading anything." else echo "New assets found." NEW_ASSETS_VERSION=$((LATEST_ASSETS_VERSION+1)) echo "Copying existing assets over to new version." gcloud storage rsync -r gs://web-stories-wp-cdn-assets/$LATEST_ASSETS_VERSION gs://web-stories-wp-cdn-assets/$NEW_ASSETS_VERSION echo "Uploading new assets to new version." gcloud storage rsync main gs://web-stories-wp-cdn-assets/$NEW_ASSETS_VERSION --recursive --ignore-symlinks --exclude="(^|/)\." rm -rf main/* echo "Updating LATEST_ASSETS_VERSION Firebase env variable." echo "LATEST_ASSETS_VERSION=$NEW_ASSETS_VERSION" > ../../packages/functions/.env git add ../../packages/functions/.env git add . git status git commit -m "Preparing assets for plugin release $PLUGIN_VERSION" git pull --rebase git push origin main fi echo "Assets version for this release: $NEW_ASSETS_VERSION" mkdir -p assets_version echo $NEW_ASSETS_VERSION > assets_version/assets_version.txt echo "ASSETS_VERSION=${NEW_ASSETS_VERSION}" >> $GITHUB_ENV working-directory: public/static env: BASE_ASSETS_VERSION: main # Uploads an empty file just so we have something to download in the next step # Essentially a no-op. - name: Prepare assets for stable release if: ${{ ! contains(github.event.inputs.version, 'rc') || startsWith(github.ref, 'refs/heads/release/') }} run: | mkdir -p assets_version echo "" > assets_version/assets_version.txt - name: Upload assets version uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: assets-version path: public/static/assets_version - name: Write summary run: | echo "Preparing assets for release" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Assets version: $ASSETS_VERSION" >> $GITHUB_STEP_SUMMARY env: ASSETS_VERSION: ${{ env.ASSES_VERSION }} build: name: Build new version runs-on: ubuntu-latest timeout-minutes: 20 needs: [assets-version] outputs: release_branch: ${{ steps.release_branch.outputs.release_branch }} release_name: ${{ steps.release_branch.outputs.release_name }} steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 # 0 indicates all history for all branches and tags. token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Download assets version uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: assets-version continue-on-error: true - name: Retrieve assets version id: assets_version run: | echo "ASSETS_VERSION=$(cat assets_version.txt)" >> $GITHUB_ENV rm -rf assets_version.txt continue-on-error: true - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest coverage: none tools: composer - name: Install dependencies run: | npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Create release branch id: release_branch run: | VERSION_WITHOUT_SUFFIX=${PLUGIN_VERSION/-rc.*/} BRANCH=release/$VERSION_WITHOUT_SUFFIX # Patch releases are already on the correct branch. if $IS_PATCH_RELEASE; then BRANCH=${GITHUB_REF#refs/heads/} echo "release_branch=${BRANCH}" >> $GITHUB_OUTPUT exit 0 fi if [[ -z $(git ls-remote origin $BRANCH) ]]; then git checkout -b $BRANCH else git checkout --track origin/$BRANCH fi echo "release_branch=${BRANCH/-rc./ RC}" >> $GITHUB_OUTPUT RELEASE_NAME=${PLUGIN_VERSION/-rc./ RC} echo "release_name=${RELEASE_NAME}" >> $GITHUB_OUTPUT - name: Update assets version run: bun run workflow:assets-version $ASSETS_VERSION if: ${{ env.ASSETS_VERSION }} env: ASSETS_VERSION: ${{ env.ASSETS_VERSION }} - name: Commit assets version bump run: | git add web-stories.php git status git diff --staged --quiet && echo 'No changes to commit; exiting!' && exit 0 git commit -m "Update assets version to $ASSETS_VERSION" git push -u origin HEAD if: ${{ env.ASSETS_VERSION }} env: ASSETS_VERSION: ${{ env.ASSETS_VERSION }} - name: Update plugin version run: bun run workflow:version $PLUGIN_VERSION # Commit the plugin version bump if it was successful. # It's also possible that there were no changes, for example if the # workflow was run a second time and the commit has already happened, # but the process later failed. # This allows re-running the workflow again without aborting in this case. - name: Commit plugin version bump id: plugin_version_bump run: | git add web-stories.php git status if git diff --cached --exit-code > /dev/null; then git commit -m "Prepare release $PLUGIN_VERSION" git push -u origin HEAD echo "changes=yes" >> $GITHUB_ENV else echo 'No changes to commit; exiting!' echo "changes=no" >> $GITHUB_ENV fi # Only non-patch release version bumps should be cherry picked to main # This will cherry-pick the last commit from the release branch, as # we only want the plugin version bump, not the assets version bump. # # Cherry-picking is only done if there actually was # a version bump in the previous step. - name: Cherry-pick to main run: | git checkout main git cherry-pick $BRANCH git pull --rebase git push git checkout $BRANCH if: ${{ ! startsWith(github.ref, 'refs/heads/release/') && env.changes == 'yes' }} env: BRANCH: ${{ steps.release_branch.outputs.release_branch }} - name: Build plugin run: bun run build:js - name: Bundle regular version run: bun run workflow:build-plugin -- --zip web-stories.zip - name: Bundle development version run: | rm -rf assets/css/* assets/js/* npx webpack --env=development bun run workflow:build-plugin -- --zip web-stories-dev.zip - name: Prepare release artifacts run: | mkdir -p build/release-assets mv build/*.zip build/release-assets/ - name: Upload artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: release-assets path: build/release-assets create-release: name: Create Release runs-on: ubuntu-latest timeout-minutes: 5 needs: [build] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: release-assets path: build - name: Publish Release id: create_release uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe with: tag_name: ${{ env.TAG_NAME }} name: ${{ env.release_name }} target_commitish: ${{ steps.release_branch.outputs.release_branch || github.ref }} prerelease: ${{ env.IS_RC }} generate_release_notes: true files: | build/web-stories.zip build/web-stories-dev.zip fail_on_unmatched_files: true token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} # Post-release version bumps for non-patch releases. post-release: name: Post-release version bump needs: [create-release] runs-on: ubuntu-latest if: ${{ ! startsWith(github.ref, 'refs/heads/release/') && ! contains(github.event.inputs.version, 'rc') }} steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: main token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest # If we're releasing 1.6.0, bump version on main to 1.7.0-alpha.0. - name: Update plugin version run: npm run workflow:version -- --increment preminor --preid alpha - name: Commit changes run: | git add web-stories.php git status git diff --staged --quiet && echo 'No changes to commit; exiting!' && exit 1 git commit -m "Post-release version bump" git pull --rebase git push -u origin HEAD # Stable releases are automatically deployed to WordPress.org. # TODO: Consider also deploying other types of releases (RC, beta), but without bumping the stable tag. # This way we could offer users a way to beta test the plugin. # See http://plugins.svn.wordpress.org/buddypress/tags/ and https://wordpress.org/plugins/bp-beta-tester/ for inspiration. deploy: name: Deploy plugin to WordPress.org runs-on: ubuntu-latest timeout-minutes: 10 needs: [create-release] if: ${{ ! contains(github.event.inputs.version, 'rc') }} env: PLUGIN_REPO_URL: 'https://plugins.svn.wordpress.org/web-stories' STABLE_TAG_REGEX: 'Stable tag:\s*(.+)' SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: release-assets path: release-assets - name: Install svn run: | sudo apt update -y --allow-releaseinfo-change sudo apt install -y subversion - name: Check out trunk folder run: svn checkout $PLUGIN_REPO_URL svn --username "$SVN_USERNAME" - name: Get previous stable tag id: get_previous_stable_tag # Returns the whole matching line. run: echo stable_tag=$(grep -P "$STABLE_TAG_REGEX" ./svn/trunk/readme.txt) >> $GITHUB_ENV - name: Delete everything in trunk run: find . -maxdepth 1 -not -name ".svn" -not -name "." -not -name ".." -exec rm -rf {} + working-directory: ./svn/trunk - name: Unzip release asset into trunk run: | unzip release-assets/web-stories.zip mv web-stories/* svn/trunk env: PLUGIN_URL: ${{ github.event.release.assets[0].browser_download_url }} - name: Replace stable tag placeholder with pre-existing stable tag run: | sed -r -i "s/${STABLE_TAG_REGEX}/${STABLE_TAG}/g" ./readme.txt working-directory: ./svn/trunk env: STABLE_TAG: ${{ env.stable_tag }} # Note: Creating the tag trigger an email confirmation that needs to be confirmed by someone with commit access. - name: Commit changes to trunk run: | svn st | grep '^?' | awk '{print $2}' | xargs -r svn add svn st | grep '^!' | awk '{print $2}' | xargs -r svn rm svn commit -m "Committing version $PLUGIN_VERSION" \ --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" working-directory: ./svn # Copy trunk to the new tag directly on the server. # Not done in the same commit as the changes to trunk in order to reduce number of file operations # and to prevent potential timeouts. # See https://developer.wordpress.org/plugins/wordpress-org/how-to-use-subversion/#create-tags-from-trunk - name: Create the SVN tag run: | svn cp "$PLUGIN_REPO_URL/trunk" "$PLUGIN_REPO_URL/tags/$PLUGIN_VERSION" \ -m "Tagging version $PLUGIN_VERSION" \ --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" working-directory: ./svn # It's recommended to run this only after the tag was successfully created. # Otherwise, if there were any errors, we risk changing this to a tag that doesn't exist. # The actual release still needs to be confirmed via email. - name: Update stable tag working-directory: ./svn run: | sed -r -i "s/${STABLE_TAG_REGEX}/Stable tag: ${PLUGIN_VERSION}/g" ./trunk/readme.txt svn commit -m "Releasing version $PLUGIN_VERSION" \ --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" ================================================ FILE: .github/workflows/scorecards.yml ================================================ name: Scorecards supply-chain security on: # Only the default branch is supported. branch_protection_rule: schedule: - cron: '0 12 1 * *' push: branches: [main] permissions: contents: read jobs: analysis: name: Scorecards analysis runs-on: ubuntu-latest permissions: # Needed to upload the results to code-scanning dashboard. security-events: write actions: read contents: read # Needed to access OIDC token. id-token: write steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: 'Checkout code' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false - name: 'Run analysis' uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a with: results_file: results.sarif results_format: sarif repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} # Publish the results to enable scorecard badges. For more details, see # https://github.com/ossf/scorecard-action#publishing-results. # For private repositories, `publish_results` will automatically be set to `false`, # regardless of the value entered here. publish_results: true # Upload the results as artifacts (optional). - name: 'Upload artifact' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/tests-e2e.yml ================================================ name: E2E Tests on: push: # Don't run for irrelevant changes. paths-ignore: - 'docs/**' - '.storybook/**' - '.wordpress-org/**' - '__mocks__/**' - '__static__/**' - 'tests/**' - '**.md' - '**.yml' - '**.neon.dist' - '**.xml.dist' - 'readme.txt' - '.editorconfig' - '.eslint*' - '.markdownlint*' - '.phpstorm.meta.php' - '.prettier*' - '.stylelint*' - '.github/workflows/**' - '!.github/workflows/tests-e2e.yml' branches: - main - release/* pull_request: # Don't run for irrelevant changes. paths-ignore: - 'docs/**' - '.storybook/**' - '.wordpress-org/**' - '__mocks__/**' - '__static__/**' - 'tests/**' - '**.md' - '**.yml' - '**.neon.dist' - '**.xml.dist' - 'readme.txt' - '.editorconfig' - '.eslint*' - '.markdownlint*' - '.phpstorm.meta.php' - '.prettier*' - '.stylelint*' - '.github/workflows/**' - '!.github/workflows/tests-e2e.yml' types: - opened - reopened - synchronize - ready_for_review permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: build: name: Build plugin runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest coverage: none tools: composer - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Build plugin run: bun run build:js env: # TODO: remove eventually DISABLE_PREVENT: true DISABLE_QUICK_TIPS: true - name: Bundle plugin run: bun run workflow:build-plugin - name: Upload bundle uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: web-stories path: build/web-stories e2e: name: '${{ matrix.browser }} - WP ${{ matrix.wp }} (${{ matrix.shard }})' runs-on: ubuntu-latest timeout-minutes: 30 continue-on-error: ${{ matrix.experimental == true }} needs: [build] strategy: fail-fast: false matrix: # TODO: add back Firefox once support is more mature. browser: ['chrome'] wp: ['6.6'] snapshots: [false] experimental: [false] # We want to split up the tests into 2 parts running in parallel. shard: ['1/2', '2/2'] include: - browser: 'chrome' wp: 'latest' snapshots: true shard: '1/2' - browser: 'chrome' wp: 'latest' snapshots: true shard: '2/2' steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: audit allowed-endpoints: > github.com:443 objects.githubusercontent.com:443 packagist.org:443 registry.npmjs.org:443 storage.googleapis.com:443 docker.io:443 registry-1.docker.io:443 auth.docker.io:443 34.104.35.123:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Download bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: web-stories # See https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix - name: Install libgbm1 run: sudo apt-get install libgbm1 - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_PRODUCT: ${{ matrix.browser }} - name: Start Docker environment run: npm run env:start env: COMPOSE_INTERACTIVE_NO_CLI: true WP_VERSION: ${{ matrix.wp }} # See https://issues.chromium.org/issues/373753919 # and https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md - name: Disable AppArmor run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - name: Get Chromium executable path id: chromium_path run: | CHROMIUM=$(node -p "const puppeteer = require('puppeteer'); puppeteer.executablePath();") echo "chromium_path=${CHROMIUM}" >> $GITHUB_ENV if: ( matrix.snapshots ) && ( github.event.pull_request.draft == false ) - name: Download AMP validator JS id: amp_validator run: | cd $RUNNER_TEMP && curl -O -f -s -S https://cdn.ampproject.org/v0/validator_wasm.js AMP_VALIDATOR_FILE="${RUNNER_TEMP}/validator_wasm.js" echo "validator_file=$AMP_VALIDATOR_FILE" >> $GITHUB_ENV - name: Run E2E tests run: npm run test:e2e -- --shard=$SHARD env: WP_VERSION: ${{ matrix.wp }} SHARD: ${{ matrix.shard }} AMP_VALIDATOR_FILE: ${{ env.validator_file }} - name: Stop Docker environment run: npm run env:stop if: always() env: COMPOSE_INTERACTIVE_NO_CLI: true - name: Get artifact name if: always() run: | ARTIFACT_NAME=${ARTIFACT_NAME//\//-} echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV env: ARTIFACT_NAME: failures-artifacts-${{ matrix.wp }}-${{ matrix.shard }} - name: Upload artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f if: always() with: name: ${{ env.ARTIFACT_NAME }} path: build/e2e-artifacts if-no-files-found: ignore ================================================ FILE: .github/workflows/tests-karma-dashboard.yml ================================================ name: Dashboard Integration Tests on: push: # Only run if dashboard-related files changed. paths: - 'babel.config.cjs' - 'karma-dashboard.config.cjs' - 'webpack.config.cjs' - 'webpack.config.test.cjs' - 'packages/**/*.js' - 'packages/**/*.ts' - 'packages/**/*.tsx' - '**/package.json' - 'package-lock.json' - '__static__/**' branches: - main - release/* pull_request: # Only run if dashboard-related files changed. paths: - 'babel.config.cjs' - 'karma-dashboard.config.cjs' - 'webpack.config.cjs' - 'webpack.config.test.cjs' - 'packages/**/*.js' - 'packages/**/*.ts' - 'packages/**/*.tsx' - '**/package.json' - 'package-lock.json' - '__static__/**' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: karma: name: Karma runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: block allowed-endpoints: > api.github.com:443 codecov.io:443 fonts.googleapis.com:443 fonts.gstatic.com:443 github.com:443 registry.npmjs.org:443 storage.googleapis.com:443 uploader.codecov.io:443 www.gravatar.com:443 54.185.253.63:443 34.104.35.123:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm # TODO: Remove need for `npm install puppeteer`. - name: Install dependencies run: | npm ci npm install puppeteer env: PUPPETEER_PRODUCT: chrome # See https://issues.chromium.org/issues/373753919 # and https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md - name: Disable AppArmor run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns # FIXME: https://github.com/googleforcreators/web-stories-wp/issues/4364 - name: Increase max number of file watchers run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - name: Get Chromium executable path id: chromium_path run: | CHROMIUM=$(node -p "const puppeteer = require('puppeteer'); puppeteer.executablePath();") echo "chromium_path=${CHROMIUM}" >> $GITHUB_ENV - name: Run integration tests run: npm run test:karma:dashboard -- --headless --viewport=1600:1000 --coverage || npm run test:karma:dashboard:retry-failed -- --headless --viewport=1600:1000 env: DISABLE_ERROR_BOUNDARIES: true - name: Upload code coverage report uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: files: build/logs/karma-coverage/dashboard/lcov.info flags: karmatests token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/tests-karma-editor.yml ================================================ name: Editor Integration Tests on: push: # Only run if editor-related files changed. paths: - 'babel.config.cjs' - 'karma-story-editor.config.cjs' - 'webpack.config.cjs' - 'webpack.config.test.cjs' - 'packages/**/*.js' - 'packages/**/*.cjs' - 'packages/**/*.ts' - 'packages/**/*.tsx' - '**/package.json' - 'package-lock.json' - '__static__/**' - '.github/workflows/tests-karma-editor.yml' branches: - main - release/* pull_request: # Only run if editor-related files changed. paths: - 'babel.config.cjs' - 'karma-story-editor.config.cjs' - 'webpack.config.cjs' - 'webpack.config.test.cjs' - 'packages/**/*.js' - 'packages/**/*.cjs' - 'packages/**/*.ts' - 'packages/**/*.tsx' - '**/package.json' - 'package-lock.json' - '__static__/**' - '.github/workflows/tests-karma-editor.yml' types: - opened - reopened - synchronize - ready_for_review permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: karma: name: Karma (${{ matrix.shard }}) runs-on: ubuntu-latest timeout-minutes: 60 strategy: fail-fast: false matrix: # We want to split up the tests into 20 parts running in parallel. shard: [ '1/20', '2/20', '3/20', '4/20', '5/20', '6/20', '7/20', '8/20', '9/20', '10/20', '11/20', '12/20', '13/20', '14/20', '15/20', '16/20', '17/20', '18/20', '19/20', '20/20', ] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: block allowed-endpoints: > api.github.com:443 codecov.io:443 fonts.googleapis.com:443 fonts.gstatic.com:443 github.com:443 registry.npmjs.org:443 storage.googleapis.com:443 uploader.codecov.io:443 wp.stories.google:443 media3p.googleapis.com:443 stories.local:80 54.185.253.63:443 34.104.35.123:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm # TODO: Remove need for `npm install puppeteer`. - name: Install dependencies run: | npm ci npm install puppeteer env: PUPPETEER_PRODUCT: chrome # See https://issues.chromium.org/issues/373753919 # and https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md - name: Disable AppArmor run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns # FIXME: https://github.com/googleforcreators/web-stories-wp/issues/4364 - name: Increase max number of file watchers run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - name: Get Chromium executable path id: chromium_path run: | CHROMIUM=$(node -p "const puppeteer = require('puppeteer'); puppeteer.executablePath();") echo "chromium_path=${CHROMIUM}" >> $GITHUB_ENV - name: Run integration tests run: | npm run test:karma:story-editor -- --headless --viewport=1600:1000 --coverage --shard=$SHARD || \ npm run test:karma:story-editor:retry-failed -- --headless --viewport=1600:1000 --shard=$SHARD env: DISABLE_ERROR_BOUNDARIES: true SHARD: ${{ matrix.shard }} - name: Upload code coverage report uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: files: build/logs/karma-coverage/story-editor/lcov.info flags: karmatests token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/tests-unit-js.yml ================================================ name: JavaScript Unit Tests on: push: # Only run if JS-related files changed. paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'packages/templates/src/raw/**' - 'packages/text-sets/src/raw/**' - 'patches/**' - 'packages/fonts/src/fonts.json' branches: - main - release/* pull_request: # Only run if JS-related files changed. paths: - '**.js' - '**.cjs' - '**.ts' - '**.tsx' - '**/package.json' - 'package-lock.json' - 'packages/templates/src/raw/**' - 'packages/text-sets/src/raw/**' - 'patches/**' - 'packages/fonts/src/fonts.json' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: unit-js: name: Unit Tests (${{ matrix.shard }}) runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: # We want to split up the tests into 2 parts running in parallel. shard: ['1/2', '2/2'] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-sudo: true disable-file-monitoring: true egress-policy: audit allowed-endpoints: > cdn.ampproject.org:443 codecov.io:443 github.com:443 raw.githubusercontent.com:443 registry.npmjs.org:443 storage.googleapis.com:443 uploader.codecov.io:443 fonts.gstatic.com:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Setup Jest cache uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 with: path: .jest-cache key: ${{ runner.os }}-${{ env.NVMRC }}-jest # Rollup is needed for tests in `packages/migration/src` expecting `module.js` - name: Install dependencies run: | npm ci npx rollup --config packages/migration/rollup.config.js env: PUPPETEER_SKIP_DOWNLOAD: true - name: Download AMP validator JS id: amp_validator run: | cd $RUNNER_TEMP && curl -O -f -s -S https://cdn.ampproject.org/v0/validator_wasm.js AMP_VALIDATOR_FILE="${RUNNER_TEMP}/validator_wasm.js" echo "validator_file=$AMP_VALIDATOR_FILE" >> $GITHUB_ENV - name: Run JavaScript unit tests run: npm run test:js -- --runInBand --ci --cacheDirectory="$HOME/.jest-cache" --collectCoverage --shard=$SHARD env: SHARD: ${{ matrix.shard }} AMP_VALIDATOR_FILE: ${{ env.validator_file }} - name: Upload code coverage report uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: files: build/logs/lcov.info flags: unittests token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/tests-unit-php.yml ================================================ name: PHP Unit Tests on: push: # Only run if PHP-related files changed. paths: - '**.php' - 'phpunit.xml.dist' - 'phpunit-multisite.xml.dist' - 'composer.json' - 'composer.lock' - 'tests/phpunit/**' - 'includes/data/**' - '.github/workflows/tests-unit-php.yml' branches: - main - release/* pull_request: # Only run if PHP-related files changed. paths: - '**.php' - 'phpunit.xml.dist' - 'phpunit-multisite.xml.dist' - 'composer.json' - 'composer.lock' - 'tests/phpunit/**' - 'includes/data/**' - '.github/workflows/tests-unit-php.yml' permissions: contents: read # Cancels all previous workflow runs for pull requests that have not completed. concurrency: # The concurrency group contains the workflow name and the (target) branch name. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: unit-php: name: PHP ${{ matrix.php }} - WP ${{ matrix.wp }}${{ matrix.experimental && ' (experimental)' || '' }}${{ matrix.coverage && ' (with coverage)' || '' }}${{ matrix.random && ' (in random order)' || '' }} runs-on: ubuntu-latest timeout-minutes: 20 services: mysql: image: mariadb:lts env: MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true MARIADB_DATABASE: wordpress_test MARIADB_MYSQL_LOCALHOST_USER: 1 MARIADB_MYSQL_LOCALHOST_GRANTS: USAGE ports: - 3306 options: --health-cmd="healthcheck.sh --su-mysql --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 continue-on-error: ${{ matrix.experimental }} strategy: matrix: php: ['8.4', '8.3', '8.2', '8.1', '8.0'] wp: ['latest'] coverage: [false] experimental: [false] include: - php: '8.5' wp: 'latest' coverage: true experimental: false - php: '8.5' wp: 'latest' random: true experimental: true - php: '7.4' wp: '6.6' experimental: false - php: '8.5' wp: 'trunk' experimental: true - php: 'nightly' wp: 'trunk' experimental: true steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: disable-file-monitoring: true egress-policy: audit allowed-endpoints: > api.github.com:443 api.wordpress.org:80 cdn.ampproject.org:443 develop.svn.wordpress.org:443 example.com:443 github.com:443 objects.githubusercontent.com:443 packagist.org:443 raw.github.com:443 repo.packagist.org:443 wordpress.org:443 getcomposer.org:443 dl.cloudsmith.io:443 - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # PHP-Scoper only works on PHP 7.4+ and we need to prefix our dependencies to accurately test them. # So we temporarily switch PHP versions, do a full install and then remove the package. # Then switch back to the PHP version we want to test and delete the vendor directory. - name: Setup PHP 8.0 uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: latest tools: composer - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction' - name: Remove prefixed dependencies run: rm -rf vendor/* - name: Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f with: php-version: ${{ matrix.php }} extensions: mysql coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} tools: composer, cs2pr - name: Install PHP dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda with: composer-options: '--prefer-dist --no-progress --no-interaction --no-scripts' - name: Composer dump autoload run: composer dump-autoload --no-interaction - name: Shutdown default MySQL service run: sudo service mysql stop - name: Verify MariaDB connection run: | while ! mysqladmin ping -h"127.0.0.1" -P"${JOB_SERVICES_MYSQL_3307TH_PORTS}" --silent; do sleep 1 done env: JOB_SERVICES_MYSQL_3307TH_PORTS: ${{ job.services.mysql.ports[3306] }} - name: Install svn run: | sudo apt update -y --allow-releaseinfo-change sudo apt install -y subversion - name: Set up tests run: bash bin/install-wp-tests.sh wordpress_test root '' 127.0.0.1:${JOB_SERVICES_MYSQL_PORTS_3306} ${{ matrix.wp }} true env: JOB_SERVICES_MYSQL_PORTS_3306: ${{ job.services.mysql.ports['3306'] }} - name: Set up problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Run tests run: | npm run test:php:unit npm run test:php:integration:single npm run test:php:integration:multisite if: ${{ ! matrix.coverage && ! matrix.random }} - name: Run tests with coverage run: | npm run test:php:unit -- --coverage-clover build/logs/php-coverage.xml npm run test:php:integration:single -- --coverage-clover build/logs/php-coverage-integration.xml npm run test:php:integration:multisite -- --coverage-clover build/logs/php-coverage-multisite.xml if: ${{ matrix.coverage && ! matrix.random }} - name: Run tests in random order run: | npm run test:php:unit -- --order-by random npm run test:php:integration:single -- --order-by random npm run test:php:integration:multisite -- --order-by random if: ${{ matrix.random }} - name: Upload code coverage report uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: files: build/logs/*.xml token: ${{ secrets.CODECOV_TOKEN }} if: ${{ matrix.coverage }} ================================================ FILE: .github/workflows/update-browserslist.yml ================================================ name: Update browserslist db on: workflow_dispatch: schedule: - cron: '0 12 1 * *' permissions: contents: read env: GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot jobs: update-browserslist-db: name: Update browserslist db runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Update db run: npx update-browserslist-db@latest - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} commit-message: Update browserslist db title: Update browserslist db body: Updates `caniuse-lite` to use the latest browser’s versions and statistics. branch: update/browserslist-db labels: Dependencies ================================================ FILE: .github/workflows/update-google-fonts.yml ================================================ name: Update Google Fonts on: workflow_dispatch: schedule: - cron: '0 12 1 * *' permissions: contents: read env: GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot jobs: update-fonts: name: Update Google Fonts runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: latest - name: Update list of Google Fonts env: GOOGLE_FONTS_API_KEY: ${{ secrets.GOOGLE_FONTS_API_KEY }} run: npm run workflow:fonts - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} commit-message: Update list of Google Fonts title: Update list of Google Fonts body: Fetched the currently available fonts on Google Fonts to update them in the project. branch: update/google-fonts labels: Dependencies ================================================ FILE: .github/workflows/update-product-schema.yml ================================================ name: Update Product Schema on: workflow_dispatch: schedule: - cron: '0 12 1 * *' permissions: contents: read env: GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot jobs: update-fonts: name: Update Product Schema runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Update Product Schema run: | curl -O -f https://raw.githubusercontent.com/ampproject/amphtml/main/examples/amp-story/shopping/product.schema.json cp product.schema.json packages/e2e-tests/src/specs/editor/shopping/schema.json mv product.schema.json tests/phpunit/integration/data/schema.json - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} commit-message: Update Product Schema title: Update Product Schema body: | Fetched the current [product JSON schema](https://github.com/ampproject/amphtml/blob/main/examples/amp-story/shopping/product.schema.json) for `` to update it in the project. branch: update/product-schema labels: Dependencies ================================================ FILE: .github/workflows/update-templates.yml ================================================ name: Migrate Templates on: workflow_dispatch: schedule: - cron: '0 12 * * 1' env: GIT_AUTHOR_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_AUTHOR_NAME: googleforcreators-bot GIT_COMMITTER_EMAIL: 94923726+googleforcreators-bot@users.noreply.github.com GIT_COMMITTER_NAME: googleforcreators-bot permissions: contents: read jobs: update-template: name: Migrate templates and text sets runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version-file: '.nvmrc' cache: npm - name: Install dependencies run: npm ci env: PUPPETEER_SKIP_DOWNLOAD: true - name: Update story data (templates, text sets, FTUE) run: npm run workflow:migrate - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 with: token: ${{ secrets.GOOGLEFORCREATORS_BOT_TOKEN }} commit-message: Migrate templates and text sets to latest version title: Migrate templates and text sets body: Update templates and text sets after running through migration branch: update/templates labels: Dependencies ================================================ FILE: .gitignore ================================================ .rollup.cache .test_artifacts .idea/ .vscode/ .DS_Store node_modules /assets /packages/migration/scripts/module.js /packages/*/dist /packages/*/dist-module /packages/*/dist-types /build /bin/build /bin/local-env/data bin/local-env/docker-compose.override.yml /includes/vendor /includes/composer.json /plugin-assets /public /static /third-party /vendor phpcs.xml phpunit.xml .phpunit.result.cache /packages/*/package-lock.json *.tsbuildinfo ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh # TODO: Move to ~/.huskyrc instead. # See https://typicode.github.io/husky/#/?id=command-not-found export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" . "$(dirname "$0")/_/husky.sh" npx lint-staged --relative ================================================ FILE: .markdownlint.json ================================================ { "default": true, "MD003": { "style": "atx" }, "MD007": { "indent": 4 }, "MD013": { "line_length": 9999 }, "no-hard-tabs": false, "no-inline-html": false, "whitespace": false } ================================================ FILE: .markdownlintignore ================================================ .github __mocks__ assets bin build node_modules tests vendor ================================================ FILE: .npmpackagejsonlintrc.json ================================================ { "rules": { "bin-type": "error", "bundledDependencies-type": "error", "config-type": "error", "cpu-type": "error", "dependencies-type": "error", "description-type": "error", "devDependencies-type": "error", "directories-type": "error", "engines-type": "error", "files-type": "error", "homepage-type": "error", "keywords-type": "error", "license-type": "error", "main-type": "error", "man-type": "error", "name-format": "error", "name-type": "error", "no-restricted-dependencies": "off", "no-restricted-devDependencies": "off", "no-restricted-pre-release-dependencies": "off", "no-restricted-pre-release-devDependencies": "off", "optionalDependencies-type": "error", "os-type": "error", "peerDependencies-type": "error", "prefer-absolute-version-dependencies": "off", "prefer-absolute-version-devDependencies": "off", "prefer-alphabetical-bundledDependencies": "error", "prefer-alphabetical-dependencies": "error", "prefer-alphabetical-devDependencies": "error", "prefer-alphabetical-optionalDependencies": "error", "prefer-alphabetical-peerDependencies": "error", "prefer-caret-version-dependencies": "off", "prefer-caret-version-devDependencies": "off", "prefer-no-engineStrict": "off", "prefer-no-version-zero-dependencies": "off", "prefer-no-version-zero-devDependencies": "off", "prefer-property-order": [ "error", [ "name", "description", "private", "version", "author", "license", "keywords", "homepage", "repository", "bugs", "engines", "files", "type", "workspaces", "customExports", "exports", "main", "module", "types", "source", "publishConfig", "sideEffects", "dependencies", "devDependencies", "peerDependencies", "bin", "scripts" ] ], "prefer-tilde-version-dependencies": "off", "prefer-tilde-version-devDependencies": "off", "preferGlobal-type": "error", "private-type": "error", "repository-type": "error", "require-author": "error", "require-bin": "off", "require-bugs": "error", "require-bundledDependencies": "off", "require-config": "off", "require-contributors": "off", "require-cpu": "off", "require-dependencies": "off", "require-description": "error", "require-devDependencies": "off", "require-directories": "off", "require-engines": "off", "require-files": "off", "require-homepage": "error", "require-keywords": "error", "require-license": "error", "require-main": "off", "require-man": "off", "require-module": "off", "require-name": "error", "require-optionalDependencies": "off", "require-os": "off", "require-peerDependencies": "off", "require-preferGlobal": "off", "require-private": "off", "require-publishConfig": "off", "require-repository": "error", "require-repository-directory": "off", "require-scripts": "off", "require-version": "off", "scripts-type": "error", "valid-values-author": ["error", ["Google"]], "valid-values-license": ["error", ["Apache-2.0"]], "valid-values-private": "off", "version-format": "error", "version-type": "error" } } ================================================ FILE: .npmrc ================================================ save-exact = false lockfile-version=3 ================================================ FILE: .nvmrc ================================================ 24 ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "./node_modules/oxlint/configuration_schema.json", "categories": { "correctness": "error", "nursery": "error", "pedantic": "error", "perf": "off", "restriction": "error", "suspicious": "error" }, "plugins": [ "import", "jsdoc", "promise", "react-perf" ], "ignorePatterns": [ "@types" ], "settings": { "jsdoc": { "tagNamePreference": { "returns": "return", "yields": "yield" } } }, "rules": { "accessor-pairs": "off", "class-methods-use-this": "off", "complexity": "off", "default-case": "off", "max-classes-per-file": "off", "max-depth": "off", "max-lines": "off", "max-lines-per-function": "off", "no-alert": "off", "no-bitwise": "off", "no-case-declarations": "off", "no-else-return": "off", "no-empty-function": "off", "no-inline-comments": "off", "no-multiple-resolved": "off", "no-negated-condition": "off", "no-param-reassign": "off", "no-plusplus": "off", "no-promise-executor-return": "off", "no-shadow": "off", "no-undef": "off", "no-undefined": "off", "no-unused-expressions": "off", "no-unused-vars": "off", "no-useless-return": "off", "no-void": ["error", { "allowAsStatement": true }], "no-warning-comments": "off", "radix": "off", "require-await": "off", "sort-vars": "off", "symbol-description": "off", "import/export": "off", "import/extensions": "off", "import/max-dependencies": "off", "import/named": "off", "import/no-commonjs": "off", "import/no-default-export": "off", "import/no-named-as-default": "off", "import/no-named-as-default-member": "off", "import/no-relative-parent-imports": "off", "import/no-unassigned-import": "off", "import/unambiguous": "off", "jest/no-conditional-in-test": "off", "jsdoc/check-tag-names": "off", "jsdoc/require-param": "off", "jsdoc/require-param-description": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns": "off", "jsdoc/require-returns-type": "off", "promise/always-return": "off", "promise/catch-or-return": "off", "promise/no-callback-in-promise": "off" }, "overrides": [ { "files": [ "packages/commander/**/*.ts" ], "rules": { "no-console": "off" } }, { "files": [ "**/*.d.ts", "packages/karma-puppeteer-client/**/*.js", "packages/karma-puppeteer-launcher/**/*.js" ], "rules": { "no-var": "off" } } ] } ================================================ FILE: .phpstorm.config.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // eslint-disable-next-line no-undef -- Special global in IntelliJ. System.config({ paths: { // Published Packages '@googleforcreators/animation': './packages/animation/src', '@googleforcreators/dashboard': './packages/dashboard/src', '@googleforcreators/date': './packages/date/src', '@googleforcreators/design-system': './packages/design-system/src', '@googleforcreators/dom': './packages/dom/src', '@googleforcreators/element-library': './packages/element-library/src', '@googleforcreators/elements': './packages/elements/src', '@googleforcreators/fonts': './packages/fonts/src', '@googleforcreators/i18n': './packages/i18n/src', '@googleforcreators/masks': './packages/masks/src', '@googleforcreators/media': './packages/media/src', '@googleforcreators/migration': './packages/migration/src', '@googleforcreators/moveable': './packages/moveable/src', '@googleforcreators/output': './packages/output/src', '@googleforcreators/patterns': './packages/patterns/src', '@googleforcreators/react': './packages/react/src', '@googleforcreators/rich-text': './packages/rich-text/src', '@googleforcreators/stickers': './packages/stickers/src', '@googleforcreators/story-editor': './packages/story-editor/src', '@googleforcreators/templates': './packages/templates/src', '@googleforcreators/text-sets': './packages/text-sets/src', '@googleforcreators/tracking': './packages/tracking/src', '@googleforcreators/transform': './packages/transform/src', '@googleforcreators/units': './packages/units/src', '@googleforcreators/url': './packages/url/src', // Private Packages '@web-stories-wp/e2e-tests': './packages/e2e-tests/src', '@web-stories-wp/activation-notice': './packages/activation-notice/src', '@web-stories-wp/commander': './packages/commander/src', '@web-stories-wp/e2e-test-utils': './packages/e2e-test-utils/src', '@web-stories-wp/eslint-import-resolver': './packages/eslint-import-resolver/src', '@web-stories-wp/glider': './packages/glider/src', '@web-stories-wp/jest-amp': './packages/jest-amp/src', '@web-stories-wp/jest-puppeteer-amp': './packages/jest-puppeteer-amp/src', '@web-stories-wp/jest-resolver': './packages/jest-resolver/src', '@web-stories-wp/karma-failed-tests-reporter': './packages/karma-failed-tests-reporter/src', '@web-stories-wp/karma-puppeteer-client': './packages/karma-puppeteer-client/src', '@web-stories-wp/karma-puppeteer-launcher': './packages/karma-puppeteer-launcher/src', '@web-stories-wp/stories-block': './packages/stories-block/src', '@web-stories-wp/stories-carousel': './packages/stories-carousel/src', '@web-stories-wp/tinymce-button': './packages/tinymce-button/src', '@web-stories-wp/widget': './packages/widget/src', '@web-stories-wp/wp-dashboard': './packages/wp-dashboard/src', '@web-stories-wp/wp-story-editor': './packages/wp-story-editor/src', '@web-stories-wp/wp-utils': './packages/wp-utils/src', }, }); ================================================ FILE: .phpstorm.meta.php ================================================ \Google\Web_Stories\Admin\Activation_Notice::class, 'admin.google_fonts' => \Google\Web_Stories\Admin\Google_Fonts::class, 'amp_output_buffer' => \Google\Web_Stories\AMP\Output_Buffer::class, 'amp_story_player_assets' => \Google\Web_Stories\AMP_Story_Player_Assets::class, 'adsense' => \Google\Web_Stories\AdSense::class, 'ad_manager' => \Google\Web_Stories\Ad_Manager::class, 'mgid' => \Google\Web_Stories\Mgid::class, 'admin' => \Google\Web_Stories\Admin\Admin::class, 'admin.revisions' => \Google\Web_Stories\Admin\Revisions::class, 'analytics' => \Google\Web_Stories\Analytics::class, 'coi' => \Google\Web_Stories\Admin\Cross_Origin_Isolation::class, 'customizer' => \Google\Web_Stories\Admin\Customizer::class, 'dashboard' => \Google\Web_Stories\Admin\Dashboard::class, 'database_upgrader' => \Google\Web_Stories\Database_Upgrader::class, 'discovery' => \Google\Web_Stories\Discovery::class, 'editor' => \Google\Web_Stories\Admin\Editor::class, 'embed_shortcode' => \Google\Web_Stories\Shortcode\Embed_Shortcode::class, 'experiments' => \Google\Web_Stories\Experiments::class, 'integrations.amp' => \Google\Web_Stories\Integrations\AMP::class, 'integrations.ezoic' => \Google\Web_Stories\Integrations\Ezoic::class, 'integrations.jetpack' => \Google\Web_Stories\Integrations\Jetpack::class, 'integrations.newrelic' => \Google\Web_Stories\Integrations\New_Relic::class, 'integrations.nextgen_gallery' => \Google\Web_Stories\Integrations\NextGen_Gallery::class, 'integrations.cfi' => \Google\Web_Stories\Integrations\Conditional_Featured_Image::class, 'integrations.sitekit' => \Google\Web_Stories\Integrations\Site_Kit::class, 'integrations.themes_support' => \Google\Web_Stories\Integrations\Core_Themes_Support::class, 'integrations.shortpixel' => \Google\Web_Stories\Integrations\ShortPixel::class, 'kses' => \Google\Web_Stories\KSES::class, 'media.base_color' => \Google\Web_Stories\Media\Base_Color::class, 'media.blurhash' => \Google\Web_Stories\Media\Blurhash::class, 'media.image_sizes' => \Google\Web_Stories\Media\Image_Sizes::class, 'media.media_source' => \Google\Web_Stories\Media\Media_Source_Taxonomy::class, 'media.video.captions' => \Google\Web_Stories\Media\Video\Captions::class, 'media.cropping' => \Google\Web_Stories\Media\Cropping::class, 'media.video.muting' => \Google\Web_Stories\Media\Video\Muting::class, 'media.video.optimization' => \Google\Web_Stories\Media\Video\Optimization::class, 'media.video.poster' => \Google\Web_Stories\Media\Video\Poster::class, 'media.video.trimming' => \Google\Web_Stories\Media\Video\Trimming::class, 'font_post_type' => \Google\Web_Stories\Font_Post_Type::class, 'page_template_post_type' => \Google\Web_Stories\Page_Template_Post_Type::class, 'plugin_row_meta' => \Google\Web_Stories\Admin\PluginRowMeta::class, 'plugin_action_links' => \Google\Web_Stories\Admin\PluginActionLinks::class, 'product_meta' => \Google\Web_Stories\Shopping\Product_Meta::class, 'meta_boxes' => \Google\Web_Stories\Admin\Meta_Boxes::class, 'settings' => \Google\Web_Stories\Settings::class, 'site_health' => \Google\Web_Stories\Admin\Site_Health::class, 'story_archive' => \Google\Web_Stories\Story_Archive::class, 'story_post_type' => \Google\Web_Stories\Story_Post_Type::class, 'story_revisions' => \Google\Web_Stories\Story_Revisions::class, 'story_shortcode' => \Google\Web_Stories\Shortcode\Stories_Shortcode::class, 'svg' => \Google\Web_Stories\Media\SVG::class, 'tracking' => \Google\Web_Stories\Tracking::class, 'tinymce' => \Google\Web_Stories\Admin\TinyMCE::class, 'register.widget' => \Google\Web_Stories\Register_Widget::class, 'renderer.archives' => \Google\Web_Stories\Renderer\Archives::class, 'renderer.single' => \Google\Web_Stories\Renderer\Single::class, 'renderer.oembed' => \Google\Web_Stories\Renderer\Oembed::class, 'renderer.feed' => \Google\Web_Stories\Renderer\Feed::class, 'user.capabilities' => \Google\Web_Stories\User\Capabilities::class, 'rest.embed_controller' => \Google\Web_Stories\REST_API\Embed_Controller::class, 'rest.link_controller' => \Google\Web_Stories\REST_API\Link_Controller::class, 'rest.hotlinking_controller' => \Google\Web_Stories\REST_API\Hotlinking_Controller::class, 'rest.publisher_logos' => \Google\Web_Stories\REST_API\Publisher_Logos_Controller::class, 'rest.status_check_controller' => \Google\Web_Stories\REST_API\Status_Check_Controller::class, 'rest.stories_autosave' => \Google\Web_Stories\REST_API\Stories_Autosaves_Controller::class, 'rest.stories_lock' => \Google\Web_Stories\REST_API\Stories_Lock_Controller::class, 'rest.media' => \Google\Web_Stories\REST_API\Stories_Media_Controller::class, 'rest.settings' => \Google\Web_Stories\REST_API\Stories_Settings_Controller::class, 'rest.users' => \Google\Web_Stories\REST_API\Stories_Users_Controller::class, 'rest.taxonomies' => \Google\Web_Stories\REST_API\Stories_Taxonomies_Controller::class, 'rest.template_autosave' => \Google\Web_Stories\REST_API\Template_Autosaves_Controller::class, 'rest.template_lock' => \Google\Web_Stories\REST_API\Template_Lock_Controller::class, 'taxonomy.category' => \Google\Web_Stories\Taxonomy\Category_Taxonomy::class, 'taxonomy.tag' => \Google\Web_Stories\Taxonomy\Tag_Taxonomy::class, 'user_preferences' => \Google\Web_Stories\User\Preferences::class, 'remove_transients' => \Google\Web_Stories\Remove_Transients::class, 'web_stories_block' => \Google\Web_Stories\Block\Web_Stories_Block::class, 'injector' => \Google\Web_Stories\Infrastructure\Injector::class, ] ) ); // For the injector, the return type should be the same as what the provided FQCN represents. override( \Google\Web_Stories\Infrastructure\Injector::make(), map( [ '' => '@' ] ) ); } ================================================ FILE: .prettierignore ================================================ /.idea /bin/local-env/uploads.ini /bin/local-env/data /build /docs /node_modules /vendor /assets/css /assets/js /assets/images packages/karma-puppeteer-client/src/client.js .browserslistrc .distignore .editorconfig .eslintignore .eslintrc .gitignore .npmrc .nvmrc .prettierignore .prettierrc .stylelintignore .stylelintrc .DS_Store *.php *.txt *.sh *.svg LICENSE composer.json composer.lock package.json package-lock.json ================================================ FILE: .prettierrc ================================================ { "plugins": ["@prettier/plugin-xml"], "printWidth": 80, "singleQuote": true, "trailingComma": "es5", "bracketSpacing": true, "arrowParens": "always", "overrides": [ { "files": "*.md", "options": { "parser": "markdown" } }, { "files": [".eslintrc", ".prettierrc", ".stylelintrc", "*.json"], "options": { "parser": "json" } }, { "files": ["*.yml", "*.neon.dist"], "options": { "parser": "yaml" } }, { "files": ["*.xml", "*.xml.dist"], "options": { "parser": "xml" } } ] } ================================================ FILE: .storybook/main.cjs ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ const { readFileSync } = require('fs'); const webpack = require('webpack'); const CircularDependencyPlugin = require('circular-dependency-plugin'); // eslint-disable-next-line n/no-missing-require const { loadCsf } = require('storybook/internal/csf-tools'); /** @type { import('@storybook/react-webpack5').StorybookConfig } */ module.exports = { stories: [ './stories/playground/*/(index|preview).js', '../packages/dashboard/src/**/stories/index.js', '../packages/story-editor/src/**/stories/index.js', '../packages/wp-dashboard/src/**/stories/index.js', '../packages/activation-notice/src/**/stories/index.js', '../packages/design-system/src/**/stories/index.js', '../packages/animation/src/**/stories/*.js', ], experimental_indexers: (indexers) => { const createIndex = (fileName, opts) => { const code = readFileSync(fileName, { encoding: 'utf-8', }); return loadCsf(code, { ...opts, fileName, }).parse().indexInputs; }; return [ { test: /stories\/.*\.js$/, createIndex, }, ...(indexers || []), ]; }, addons: [ '@storybook/addon-a11y/register', '@storybook/addon-webpack5-compiler-babel', ], framework: { name: '@storybook/react-webpack5', options: {}, }, core: { disableTelemetry: true, }, docs: { disabled: true, }, //eslint-disable-next-line require-await -- Negligible. webpackFinal: async (webpackConfig) => { // webpack < 5 used to include polyfills for node.js core modules by default. // Prevent ModuleNotFoundError for this dependency. webpackConfig.resolve = { ...webpackConfig.resolve, // Fixes resolving packages in the monorepo so we use the "src" folder, not "dist". // This should be sync'd with the config in `webpack.config.cjs`. exportsFields: ['customExports', 'exports'], // To make loading mediainfo.js work. fallback: { fs: false, path: false, url: false, module: false, assert: false, perf_hooks: false, crypto: false, worker_threads: false, }, }; // Avoid having to provide full file extension for imports. // See https://webpack.js.org/configuration/module/#resolvefullyspecified webpackConfig.module.rules = webpackConfig.module.rules.map((rule) => ({ ...rule, resolve: { ...rule.resolve, fullySpecified: false, }, })); // These should be sync'd with the config in `webpack.config.cjs`. webpackConfig.plugins.push( new webpack.DefinePlugin({ WEB_STORIES_CI: JSON.stringify(process.env.CI), WEB_STORIES_ENV: JSON.stringify(process.env.NODE_ENV), WEB_STORIES_DISABLE_ERROR_BOUNDARIES: JSON.stringify( process.env.DISABLE_ERROR_BOUNDARIES ), WEB_STORIES_DISABLE_OPTIMIZED_RENDERING: JSON.stringify( process.env.DISABLE_OPTIMIZED_RENDERING ), WEB_STORIES_DISABLE_PREVENT: JSON.stringify( process.env.DISABLE_PREVENT ), WEB_STORIES_DISABLE_QUICK_TIPS: JSON.stringify( process.env.DISABLE_QUICK_TIPS ), }) ); // These should be sync'd with the config in `webpack.config.cjs`. webpackConfig.plugins.push( new webpack.DefinePlugin({ WEB_STORIES_CI: JSON.stringify(process.env.CI), WEB_STORIES_ENV: JSON.stringify(process.env.NODE_ENV), WEB_STORIES_DISABLE_ERROR_BOUNDARIES: JSON.stringify( process.env.DISABLE_ERROR_BOUNDARIES ), WEB_STORIES_DISABLE_OPTIMIZED_RENDERING: JSON.stringify( process.env.DISABLE_OPTIMIZED_RENDERING ), WEB_STORIES_DISABLE_PREVENT: JSON.stringify( process.env.DISABLE_PREVENT ), WEB_STORIES_DISABLE_QUICK_TIPS: JSON.stringify( process.env.DISABLE_QUICK_TIPS ), }) ); webpackConfig.plugins.push( new CircularDependencyPlugin({ // exclude detection of files based on a RegExp exclude: /a\.js|node_modules/, // add errors to webpack instead of warnings failOnError: true, // allow import cycles that include an asynchronous import, // e.g. via import(/* webpackMode: "weak" */ 'file.js') allowAsyncCycles: false, // set the current working directory for displaying module paths cwd: process.cwd(), }) ); // Ensure SVGR is the only loader used for files with .svg extension. const assetRule = webpackConfig.module.rules.find(({ test }) => { if (!test) { return false; } if (Array.isArray(test)) { return test.every((t) => t.test('.svg')); } return test.test('.svg'); }); assetRule.exclude = /\.svg/; webpackConfig.module.rules.unshift( { test: /\.svg$/, // Use asset SVG and SVGR together. // Not using resourceQuery because it doesn't work well with Rollup. // https://react-svgr.com/docs/webpack/#use-svgr-and-asset-svg-in-the-same-project oneOf: [ { type: 'asset/inline', include: [/inline-icons\/.*\.svg$/], }, { issuer: /\.[jt]sx?$/, include: [/\/icons\/.*\.svg$/], use: [ { loader: '@svgr/webpack', options: { titleProp: true, svgo: true, memo: true, svgoConfig: { plugins: [ { name: 'preset-default', params: { overrides: { removeViewBox: false, convertColors: { currentColor: /^(?!url|none)/i, }, }, }, }, 'removeDimensions', ], }, }, }, ], }, { issuer: /\.[jt]sx?$/, include: [/images\/.*\.svg$/], use: [ { loader: '@svgr/webpack', options: { titleProp: true, svgo: true, memo: true, svgoConfig: { plugins: [ { name: 'preset-default', params: { overrides: { removeViewBox: false, convertColors: { // See https://github.com/googleforcreators/web-stories-wp/pull/6361 currentColor: false, }, }, }, }, 'removeDimensions', ], }, }, }, ], }, ], }, { test: /\.(png|jpe?g|gif|webp)$/i, type: 'asset/resource', generator: { outputPath: 'images/', }, } ); return webpackConfig; }, features: { actions: true, backgrounds: true, controls: false, viewport: true, toolbars: true, }, }; ================================================ FILE: .storybook/manager-head.html ================================================ ================================================ FILE: .storybook/preview-head.html ================================================ ================================================ FILE: .storybook/preview.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { ThemeProvider } from 'styled-components'; // eslint-disable-next-line import/no-unresolved import { INITIAL_VIEWPORTS } from 'storybook/viewport'; import { theme as designSystemTheme, lightMode, ThemeGlobals, ModalGlobalStyle, } from '@googleforcreators/design-system'; import { CropMoveableGlobalStyle } from '@googleforcreators/moveable'; /** * Internal dependencies */ // Disable reason: // Importing from the dashboard and story editor roots break fast refresh in storybook. // Prevented by importing the necessary providers and configs directly. /* eslint-disable import/no-relative-packages */ import { GlobalStyle as DashboardGlobalStyle } from '../packages/dashboard/src/theme'; import DashboardKeyboardOnlyOutline from '../packages/dashboard/src/utils/keyboardOnlyOutline'; import DashboardConfigProvider from '../packages/dashboard/src/app/config/configProvider'; import ApiProvider from '../packages/dashboard/src/app/api/apiProvider'; import EditorConfigProvider from '../packages/story-editor/src/app/config/configProvider'; /* eslint-enable import/no-relative-packages */ // @todo: Find better way to mock these. const wp = {}; window.wp = window.wp || wp; window.wp.media = { controller: { Library: { prototype: { defaults: {}, }, }, }, }; const { ipad, ipad10p, ipad12p } = INITIAL_VIEWPORTS; /** @type { import('@storybook/react-webpack5').Preview } */ const preview = { parameters: { a11y: { element: '#root', config: {}, options: {}, manual: true, }, viewport: { viewports: { ipad, ipad10p, ipad12p, }, }, backgrounds: { default: 'Light', values: [ { name: 'Light', value: '#fff', default: true }, { name: 'Dark', value: 'rgba(0, 0, 0, 0.9)', default: true }, ], }, }, decorators: [ (Story, context) => { const { id } = context; // TODO(#10380): Replacement add-on for RTL feature const isRTL = false; const isDesignSystemStorybook = id.startsWith('designsystem'); const isDashboardStorybook = id.startsWith('dashboard'); if (isDashboardStorybook) { return ( Promise.resolve({ id: 1 }), }, editStoryURL: 'editStory', isRTL, styleConstants: { topOffset: 0, }, }} > {Story()} ); } if (isDesignSystemStorybook) { // override darkMode colors const dsTheme = { ...designSystemTheme, colors: lightMode }; return ( {Story()} ); } return ( {Story()} ); }, ], }; export default preview; ================================================ FILE: .storybook/stories/playground/dashboard/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useRef, useEffect } from 'react'; import { toId } from '@storybook/csf'; import { Dashboard, InterfaceSkeleton } from '@googleforcreators/dashboard'; /** * Internal dependencies */ import { GlobalStyle } from './theme'; export default { title: 'Playground/Dashboard', }; const linkHrefTo = (title, name) => { const url = new URL(window.parent.location); url.searchParams.set('path', '/story/' + toId(title, name)); return decodeURIComponent(url.href); }; const fetchStories = () => { const response = { stories: { 1: { id: 1, status: 'publish', title: 'Example story', created: '2021-11-04T10:12:47', createdGmt: '2021-11-04T10:12:47Z', author: { name: 'Author', id: 1, }, featuredMediaUrl: 'https://wp.stories.google/static/main/images/templates/food-and-stuff/page1_bg.jpg', }, 2: { id: 2, status: 'publish', title: 'Example story 2', created: '2021-12-04T10:12:47', createdGmt: '2021-12-04T10:12:47Z', author: { name: 'Author', id: 1, }, featuredMediaUrl: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page8_figure.jpg', }, }, fetchedStoryIds: [1, 2], totalPages: 1, totalStoriesByStatus: { all: 2, publish: 2, }, }; return Promise.resolve(response); }; // mock filter api calls const getTaxonomies = () => Promise.resolve([ { restBase: '', restPath: '', labels: { allItems: 'All Categories', notFound: 'No categories found', searchItems: 'Search Categories', }, }, ]); const getTaxonomyTerms = () => Promise.resolve([{ name: 'Food', id: 1 }]); const getAuthors = () => Promise.resolve([{ name: 'Author', id: 1 }]); /** * Clears url hash ( Required only for storybook ) * Dashboard uses # for checking route path and story-editor uses #page, * when returning from story-editor to dashboard in storybook, currentPath read from history package gets manipulated, * which breaks the current path, so this custom hook is used to clear the hash before dashboard app is mounted. */ const useClearHash = () => { const isHashCleaned = useRef(false); useEffect(() => { if (!isHashCleaned.current) { window.location.hash = '/'; isHashCleaned.current = true; } }, []); }; export const _default = { render: function Render() { useClearHash(); const config = { newStoryURL: linkHrefTo('Playground/Stories Editor', 'default'), apiCallbacks: { fetchStories, getTaxonomies, getTaxonomyTerms, getAuthors, }, }; return ( ); }, }; ================================================ FILE: .storybook/stories/playground/dashboard/theme.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createGlobalStyle } from 'styled-components'; import { themeHelpers } from '@googleforcreators/design-system'; export const GlobalStyle = createGlobalStyle` body.web-story_page_stories-dashboard #wpbody { ${themeHelpers.scrollbarCSS}; } `; ================================================ FILE: .storybook/stories/playground/story-editor/api/fonts.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export async function getFonts(params) { let { default: fonts } = await import( /* webpackChunkName: "chunk-fonts" */ '@googleforcreators/fonts/fonts.json' // eslint-disable-line import/no-internal-modules -- This is fine here. ); fonts = fonts.map((font) => ({ id: font.family, name: font.family, value: font.family, ...font, })); if (params.include) { const include = params.include.split(','); fonts = fonts.filter(({ family }) => include.includes(family)); } if (params.search) { fonts = fonts.filter(({ family }) => family.toLowerCase().includes(params.search) ); } return fonts; } ================================================ FILE: .storybook/stories/playground/story-editor/api/index.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Not using "export * from" because of https://github.com/storybookjs/storybook/issues/17587 export { getFonts } from './fonts'; export { getMedia } from './media'; export { saveStoryById } from './story'; ================================================ FILE: .storybook/stories/playground/story-editor/api/media.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import { getDummyMedia } from '../getDummyMedia'; export const getMedia = (params) => { const dummyMedia = getDummyMedia(); const mediaResponse = { data: dummyMedia, headers: { totalItems: dummyMedia.length, totalPages: 1, }, }; if (params.searchTerm) { mediaResponse.data = dummyMedia.filter((media) => { return media.alt.toLowerCase().includes(params.searchTerm.toLowerCase()); }); mediaResponse.headers.totalItems = mediaResponse.data.length; } return Promise.resolve(mediaResponse); }; ================================================ FILE: .storybook/stories/playground/story-editor/api/story.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { DATA_VERSION } from '@googleforcreators/migration'; /** * Internal dependencies */ import { LOCAL_STORAGE_CONTENT_KEY, LOCAL_STORAGE_PREVIEW_MARKUP_KEY, } from '../constants'; export const saveStoryById = ({ pages, globalStoryStyles, autoAdvance, defaultPageDuration, currentStoryStyles, backgroundAudio, content, title, excerpt, }) => { const storySaveData = { title: { raw: title, }, excerpt: { raw: excerpt, }, storyData: { version: DATA_VERSION, pages, autoAdvance, defaultPageDuration, currentStoryStyles, backgroundAudio, }, author: { id: 1, name: '', }, stylePresets: globalStoryStyles, permalinkTemplate: 'https://example.org/web-stories/%pagename%/', }; window.localStorage.setItem( LOCAL_STORAGE_CONTENT_KEY, JSON.stringify(storySaveData) ); window.localStorage.setItem(LOCAL_STORAGE_PREVIEW_MARKUP_KEY, content); return Promise.resolve({}); }; ================================================ FILE: .storybook/stories/playground/story-editor/constants.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export const LOCAL_STORAGE_CONTENT_KEY = 'web_stories_json_content'; export const LOCAL_STORAGE_PREVIEW_MARKUP_KEY = 'web_stories_preview_markup'; ================================================ FILE: .storybook/stories/playground/story-editor/getDummyMedia.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createResource } from '@googleforcreators/media'; function createDummyImageData({ mimeType = 'image/jpeg', src, width, height, id, title, }) { return createResource({ mimeType, creationDate: '2021-10-29T11:43:38', src, width, height, id, alt: title, sizes: { medium: { file: `${title}-300x84.jpeg`, width: 300, height: 84, mimeType, sourceUrl: src, }, large: { file: `${title}-1024x288.jpeg`, width: 1024, height: 288, mimeType, sourceUrl: src, }, thumbnail: { file: `${title}-150x150.jpeg`, width: 150, height: 150, mimeType, sourceUrl: src, }, medium_large: { file: `${title}-768x216.jpeg`, width: 768, height: 216, mimeType, sourceUrl: src, }, '1536x1536': { file: `${title}-1536x432.jpeg`, width: 1536, height: 432, mimeType, sourceUrl: src, }, '2048x2048': { file: `${title}-2048x576.jpeg`, width: 2048, height: 576, mimeType, sourceUrl: src, }, 'post-thumbnail': { file: `${title}-1568x441.jpeg`, width: 1568, height: 441, mimeType, sourceUrl: src, }, 'web-stories-poster-portrait': { file: `${title}-640x853.jpeg`, width: 640, height: 853, mimeType, sourceUrl: src, }, 'web-stories-publisher-logo': { file: `${title}-96x96.jpeg`, width: 96, height: 96, mimeType, sourceUrl: src, }, 'web-stories-thumbnail': { file: `${title}-150x42.jpeg`, width: 150, height: 42, mimeType, sourceUrl: src, }, full: { file: title, width, height, mimeType, sourceUrl: src, }, }, }); } function createDummyVideoData({ mimeType = 'video/mp4', src, mediaDetails, id, title, poster, }) { return createResource({ mimeType, creationDate: '2021-10-29T11:56:33', src, width: mediaDetails.width, height: mediaDetails.height, poster: poster.src, posterId: 57, id, length: mediaDetails.length, bitsPerSample: mediaDetails.bitsPerSample, alt: title, }); } function getDummyMedia() { return [ createDummyImageData({ mimeType: 'image/png', src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page1_bg-alt.png', width: 412, height: 732, id: 1, title: 'Fresh and Bright 1', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page3_figure.jpg', width: 720, height: 844, id: 2, title: 'Fresh and Bright 2', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page4_bg.png', width: 750, height: 1334, id: 3, title: 'Fresh and Bright 3', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page5_figure.jpg', width: 750, height: 1079, id: 4, title: 'Fresh and Bright 4', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page7_product1.jpg', width: 720, height: 900, id: 5, title: 'Fresh and Bright 5', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page8_figure.jpg', width: 408, height: 544, id: 6, title: 'Fresh and Bright 6', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page9_story1.jpg', width: 720, height: 405, id: 7, title: 'Fresh and Bright 7', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page9_story2.jpg', width: 480, height: 720, id: 8, title: 'Fresh and Bright 8', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/fresh-and-bright/page9_story3.jpg', width: 480, height: 720, id: 9, title: 'Fresh and Bright 9', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/food-and-stuff/page1_bg.jpg', width: 1080, height: 1620, id: 10, title: 'Fresh and Bright 10', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/food-and-stuff/page3_image1.jpg', width: 1080, height: 720, id: 11, title: 'Food and Stuff 1', }), createDummyImageData({ src: 'https://wp.stories.google/static/main/images/templates/food-and-stuff/page5_bg.jpg', width: 731, height: 1300, id: 12, title: 'Food and Stuff 2', }), createDummyVideoData({ src: 'https://stream.mux.com/OGTmBYTMkV7Ez601cRNpH6BP10102fQu9C00/high.mp4?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InQ5UHZucm9ZY0hQNjhYSmlRQnRHTEVVSkVSSXJ0UXhKIn0.eyJleHAiOjE2MjYxNzgxMTIsImF1ZCI6InYiLCJzdWIiOiJPR1RtQllUTWtWN0V6NjAxY1JOcEg2QlAxMDEwMmZRdTlDMDAifQ.Jvddeah-XyF0AMmfYND-fFaIfgeDYW_cAuIVIXOrk2so_YalyMMAQ11YXHA1h11yKpLM-xa8xiNXazPqA0Suq1tfxHjJjEqiQOzSlPYd4visiPdTjUgT50FkwYdJrN0IldfXoS19yi3GyAd9McVFoSGCZ6qp8m_hgJ39y8FJJbOrvGtrzEvElpz1M8M1Dat3PF-BSLvFcTvCOlec9dipajxHG_2Xg-EE_vOqww6z81kC09evj5gu_A-Vz58Q-ebd08R47ybNejhE3rzMr1dCKgUikjkkQokPPQyrwVEy8zeZ68elax-ZRvDokZ2mTVPGvKIye6m_CQ-WVlG5XRjw7A', mediaDetails: { filesize: 1867215, mimeType: 'video/mp4', length: 12, lengthFormatted: '0:12', width: 720, height: 406, fileformat: 'mp4', dataformat: 'quicktime', audio: { dataformat: 'mp4', codec: 'ISO/IEC 14496-3 AAC', sampleRate: 48000, channels: 2, bitsPerSample: 16, lossless: false, channelmode: 'stereo', }, createdTimestamp: -2082844800, sizes: {}, }, id: 13, title: 'Story', poster: { src: 'https://image.mux.com/OGTmBYTMkV7Ez601cRNpH6BP10102fQu9C00/thumbnail.jpg?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InQ5UHZucm9ZY0hQNjhYSmlRQnRHTEVVSkVSSXJ0UXhKIn0.eyJ0aW1lIjowLCJleHAiOjMxNzE3MDYwNjg4MSwiYXVkIjoidCIsInN1YiI6Ik9HVG1CWVRNa1Y3RXo2MDFjUk5wSDZCUDEwMTAyZlF1OUMwMCJ9.WxHU2cpFdNqqiWF08KEv1g7barfbE_Nw4JDvVrtrQc9mVKiSZ6pEHpYl14NZeaBOf4Ep9MiomaegCVrD-UANhURlxWuHWFx5h7Msg74-q_ojjuZbLZtFPUdA0NA3_GTq5y0LDAeXijM7oENn_IkuDSY0fbRhRUVh-hYqCzw_OqMx7B1IxoOkkZUCHgkm9VnPdAMQRpmaanCHB8SHVRI_vIJo577DxLd88KBFP3UWK1XTvN5NYr0oPtwc82XMYUW4HrxZZTmxzLSZckxzwB7T5E6zAtXpo-xwd52yB1OUhoihlTqiOPWbntpvt9H4lFVRISLwsT39d1Tw80HF_X02eQ', width: 1920, height: 1080, generated: false, }, }), createDummyVideoData({ src: 'https://storage.coverr.co/videos/PDolZIrwdONTFJd005VOT4qvFsyxVfB01N/preview?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjEyNjFDMkM5QUYxNEY5NkJDRTc2IiwiaWF0IjoxNjI2ODM0NDQzfQ._6LJb2Ad843im0xV4-2I2vVpDNoPiy8nPc3-3IkSSuw', mediaDetails: { filesize: 1510877, mimeType: 'video/quicktime', length: 18, lengthFormatted: '0:18', width: 640, height: 360, fileformat: 'mp4', dataformat: 'quicktime', createdTimestamp: -2082844800, sizes: {}, }, id: 14, title: 'Low', poster: { src: 'https://storage.coverr.co/p/PDolZIrwdONTFJd005VOT4qvFsyxVfB01N', width: 2048, height: 1152, generated: false, }, }), createDummyVideoData({ src: 'https://storage.coverr.co/videos/qMc3OVOA8a6Q9j01T2L3pGfF029UA00OvZJ/preview?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjEyNjFDMkM5QUYxNEY5NkJDRTc2IiwiaWF0IjoxNjI2ODM3NDkwfQ.rHiHdTyik8sDuP8viRbC4ph-qjHHJ81I7kdawP2-1eQ', mediaDetails: { filesize: 456538, mimeType: 'video/mp4', length: 15, lengthFormatted: '0:15', width: 360, height: 640, fileformat: 'mp4', dataformat: 'quicktime', createdTimestamp: -2082844800, sizes: {}, audio: { bitsPerSample: 16, channelmode: 'stereo', channels: 2, codec: 'ISO/IEC 14496-3 AAC', dataformat: 'mp4', lossless: false, sampleRate: 48000, }, }, id: 15, title: 'Mountain Video', poster: { src: 'https://storage.coverr.co/p/qMc3OVOA8a6Q9j01T2L3pGfF029UA00OvZJ', width: 1080, height: 1920, generated: false, }, }), createDummyVideoData({ src: 'https://storage.coverr.co/videos/XHtOLYGTnHiXVxofSdO8oTcA02vxasheL/preview?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjEyNjFDMkM5QUYxNEY5NkJDRTc2IiwiaWF0IjoxNjI2ODM3NDkwfQ.rHiHdTyik8sDuP8viRbC4ph-qjHHJ81I7kdawP2-1eQ', mediaDetails: { filesize: 2536304, mimeType: 'video/mp4', length: 27, lengthFormatted: '0:27', width: 640, height: 360, fileformat: 'mp4', dataformat: 'quicktime', createdTimestamp: -2082844800, sizes: {}, audio: { bitsPerSample: 16, channelmode: 'stereo', channels: 2, codec: 'ISO/IEC 14496-3 AAC', dataformat: 'mp4', lossless: false, sampleRate: 48000, }, }, id: 16, title: 'Beautiful Cloud Video', poster: { src: 'https://storage.coverr.co/p/XHtOLYGTnHiXVxofSdO8oTcA02vxasheL', width: 1920, height: 1080, generated: false, }, }), createDummyVideoData({ src: 'https://storage.coverr.co/videos/X4pOA7IG76p95Gl3IcU3oX26S2sbGsc2/preview?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBJZCI6IjEyNjFDMkM5QUYxNEY5NkJDRTc2IiwiaWF0IjoxNjI2ODM4Mjc3fQ.lIznWsYEFy3abrDbou1mKJivj6GzQnHO6gJ8jdisPW0', mediaDetails: { filesize: 1029136, mimeType: 'video/mp4', length: 10, lengthFormatted: '0:10', width: 640, height: 360, fileformat: 'mp4', dataformat: 'quicktime', createdTimestamp: -2082844800, sizes: {}, audio: { bitsPerSample: 16, channelmode: 'stereo', channels: 2, codec: 'ISO/IEC 14496-3 AAC', dataformat: 'mp4', lossless: false, sampleRate: 48000, }, }, id: 17, title: 'Beautiful Fire Video', poster: { src: 'https://storage.coverr.co/p/X4pOA7IG76p95Gl3IcU3oX26S2sbGsc2', width: 1920, height: 1080, generated: false, }, }), ]; } export { getDummyMedia }; ================================================ FILE: .storybook/stories/playground/story-editor/header/buttons/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { useStory } from '@googleforcreators/story-editor'; import { CircularProgress } from '@googleforcreators/design-system'; /** * Internal dependencies */ import { SaveButton } from './saveButton'; import { PreviewButton } from './preview'; const ButtonList = styled.nav` display: flex; justify-content: flex-end; padding: 1em; height: 100%; `; const List = styled.div` display: flex; align-items: center; `; const Space = styled.div` width: 8px; `; const Spinner = styled.div` position: absolute; top: 0; `; const IconWithSpinner = styled.div` position: relative; `; function Loading() { return ( ); } function Buttons() { const { isSaving } = useStory( ({ state: { meta: { isSaving }, }, }) => ({ isSaving }) ); return ( {isSaving && } ); } export { Buttons }; ================================================ FILE: .storybook/stories/playground/story-editor/header/buttons/preview.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { hrefTo } from '@storybook/addon-links'; import { Tooltip, useStory } from '@googleforcreators/story-editor'; import { Button, ButtonSize, ButtonType, ButtonVariant, Icons, } from '@googleforcreators/design-system'; function PreviewButton() { const { isSaving, saveStory } = useStory( ({ state: { meta: { isSaving }, }, actions: { saveStory }, }) => ({ isSaving, saveStory, }) ); const openPreviewLink = async () => { await saveStory(); const previewLink = await hrefTo('Playground/preview', 'default'); // Start a about:blank popup with waiting message until we complete // the saving operation. That way we will not bust the popup timeout. try { const popup = window.open('about:blank', 'story-preview'); if (popup) { popup.document.write(''); popup.document.write(''); popup.document.write('Generating the preview…'); popup.document.write(''); popup.document.write(''); popup.document.write('Please wait. Generating the preview…'); // Output "waiting" message. // Force redirect to the preview URL after 5 seconds. The saving tab // might get frozen by the browser. popup.document.write( `` ); } } catch { // Not interested in the error. } }; return ( ); } export { PreviewButton }; ================================================ FILE: .storybook/stories/playground/story-editor/header/buttons/saveButton.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { Button, ButtonSize, ButtonType, ButtonVariant, useSnackbar, } from '@googleforcreators/design-system'; import { useStory } from '@googleforcreators/story-editor'; function SaveButton() { const { isSaving, saveStory } = useStory( ({ state: { meta: { isSaving }, }, actions: { saveStory }, }) => ({ isSaving, saveStory, }) ); const { showSnackbar } = useSnackbar(); const handleSaveButton = () => { saveStory().then(() => { showSnackbar({ message: 'Story Saved', }); }); }; // @todo Make the app state persistent. return ( ); } export { SaveButton }; ================================================ FILE: .storybook/stories/playground/story-editor/header/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { HeaderTitle } from '@googleforcreators/story-editor'; /** * Internal dependencies */ import { Buttons } from './buttons'; const Background = styled.header.attrs({ role: 'group', 'aria-label': 'Story canvas header', })` display: flex; align-items: center; justify-content: space-between; background-color: ${({ theme }) => theme.colors.bg.primary}; `; const Head = styled.div` flex: 1 1 auto; padding: 1em; `; const ButtonCell = styled.div` grid-area: buttons; `; function HeaderLayout() { return ( ); } export { HeaderLayout }; ================================================ FILE: .storybook/stories/playground/story-editor/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { StoryEditor, InterfaceSkeleton, } from '@googleforcreators/story-editor'; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; /** * Internal dependencies */ import { getMedia, saveStoryById, getFonts } from './api'; import { HeaderLayout } from './header'; import { LOCAL_STORAGE_CONTENT_KEY } from './constants'; export default { title: 'Playground/Stories Editor', }; export const _default = { render: function Render() { const content = window.localStorage.getItem(LOCAL_STORAGE_CONTENT_KEY); const story = content ? JSON.parse(content) : {}; const apiCallbacks = { saveStoryById, getMedia, getFonts }; elementTypes.forEach(registerElementType); return (
} />
); }, }; ================================================ FILE: .storybook/stories/playground/story-editor/preview.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useEffect } from 'react'; /** * Internal dependencies */ import { LOCAL_STORAGE_PREVIEW_MARKUP_KEY } from './constants'; export default { title: 'Playground/preview', }; // This is a hidden story component ( hidden via manager-head.html ), used for previewing the story-editor. Please do not remove. function Preview() { useEffect(() => { const content = window.localStorage.getItem( LOCAL_STORAGE_PREVIEW_MARKUP_KEY ); if (content) { document.open(); document.write(content); document.close(); } }, []); return null; } export const _default = Preview; ================================================ FILE: .stylelintignore ================================================ .github .storybook .wordpress-org __mocks__ __static__ assets bin blocks build docs includes node_modules patches public tests third-party vendor /*.js /*.cjs ================================================ FILE: .stylelintrc ================================================ { "extends": [ "stylelint-config-recommended" ], "plugins": ["stylelint-prettier"], "customSyntax": "postcss-syntax", "rules": { "font-family-name-quotes": null, "font-family-no-missing-generic-family-keyword": null, "font-weight-notation": null, "function-name-case": null, "prettier/prettier": true, "selector-class-pattern": null, "selector-type-no-unknown": [ true, { "ignore": ["custom-elements"], "ignoreTypes": ["overlay", "container"] } ], "property-no-vendor-prefix": [ true, { "ignoreProperties": ["clip-path"] } ], "unit-no-unknown": [ true, { "ignoreUnits": ["/^`/", "/`$/"] } ], "value-keyword-case": null }, "overrides": [ { "files": ["*.js", ".ts", ".tsx"], "customSyntax": "postcss-styled-syntax", "rules": { "annotation-no-unknown": null, "block-no-empty": null, "function-no-unknown": null, "media-query-no-invalid": null, "no-empty-source": null } } ] } ================================================ FILE: .wordpress-org/README.md ================================================ # WordPress.org assets These assets are used in the WordPress.org plugin directory. They will need to be synced with the plugin SVN repository whenever they change. ================================================ FILE: .wordpress-org/blueprints/blueprint.json ================================================ { "$schema": "https://playground.wordpress.net/blueprint-schema.json", "landingPage": "/wp-admin/plugins.php", "preferredVersions": { "php": "8.0", "wp": "latest" }, "phpExtensionBundles": ["kitchen-sink"], "features": { "networking": true }, "steps": [ { "step": "installPlugin", "pluginZipFile": { "resource": "url", "url": "https://downloads.wordpress.org/plugin/web-stories.latest-stable.zip" }, "options": { "activate": true } }, { "step": "login", "username": "admin", "password": "password" } ] } ================================================ FILE: CONTRIBUTING.md ================================================ # How to Contribute We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. ## Getting Started Please check out our dedicated [Getting Started](./docs/getting-started.md) guide. ## Contributor License Agreement Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project. Head over to to see your current agreements on file or to sign a new one. You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again. ## Code reviews All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. ## Community Guidelines This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Web Stories for WordPress Visual storytelling for WordPress. [![Latest Release)](https://img.shields.io/github/v/release/googleforcreators/web-stories-wp?include_prereleases)](https://github.com/googleforcreators/web-stories-wp/releases) [![Commit activity](https://img.shields.io/github/commit-activity/m/googleforcreators/web-stories-wp)](https://github.com/googleforcreators/web-stories-wp/pulse/monthly) [![Code Coverage](https://codecov.io/gh/googleforcreators/web-stories-wp/branch/main/graph/badge.svg)](https://codecov.io/gh/googleforcreators/web-stories-wp) [![License](https://img.shields.io/github/license/googleforcreators/web-stories-wp)](https://github.com/googleforcreators/web-stories-wp/blob/main/LICENSE) [![Storybook](https://raw.githubusercontent.com/storybooks/brand/master/badge/badge-storybook.svg)](https://googleforcreators.github.io/web-stories-wp/storybook/)
Build Status [![Build](https://img.shields.io/github/actions/workflow/status/googleforcreators/web-stories-wp/build-and-deploy.yml?branch=main&label=Build)](https://github.com/googleforcreators/web-stories-wp/actions?query=branch%3Amain) [![Integration Tests](https://img.shields.io/github/actions/workflow/status/googleforcreators/web-stories-wp/tests-karma-editor.yml?branch=main&label=integration%20tests)](https://github.com/googleforcreators/web-stories-wp/actions?query=branch%3Amain) [![E2E Tests](https://img.shields.io/github/actions/workflow/status/googleforcreators/web-stories-wp/tests-e2e.yml?branch=main&label=e2e%20tests)](https://github.com/googleforcreators/web-stories-wp/actions?query=branch%3Amain) [![JS Tests](https://img.shields.io/github/actions/workflow/status/googleforcreators/web-stories-wp/tests-unit-js.yml?branch=main&label=js%20tests)](https://github.com/googleforcreators/web-stories-wp/actions?query=branch%3Amain) [![PHP Tests](https://img.shields.io/github/actions/workflow/status/googleforcreators/web-stories-wp/tests-unit-php.yml?branch=main&label=php%20tests)](https://github.com/googleforcreators/web-stories-wp/actions?query=branch%3Amain)
[Web Stories](https://amp.dev/about/stories/) are a free, open-web, visual storytelling format for the web, enabling you to easily create visual narratives with engaging animations and tappable interactions, and immerse your readers in great and fast-loading full-screen experiences. With [Web Stories for WordPress](https://wp.stories.google/), we're bringing first-class Web Stories support to WordPress. Use Web Stories for WordPress by installing it directly from the WordPress admin dashboard or manually downloading the plugin from the [WordPress.org plugin directory](https://wordpress.org/plugins/web-stories/). ## Support If you find any issues, please reach out by visiting the [support forum](https://wordpress.org/support/plugin/web-stories/) to ask any questions or file feature requests. ## Contributing We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. Please check out our [Contributing documentation](./CONTRIBUTING.md) and the [Getting Started](./docs/getting-started.md) guide. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We support the latest stable version of the plugin with security updates. ## Reporting a Vulnerability To report a vulnerability in this repository, please contact the Google Security Team at [g.co/vulnz](https://g.co/vulnz). ================================================ FILE: __mocks__/colorthief.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export default function () {} ================================================ FILE: __mocks__/node:fs.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // see https://jestjs.io/docs/en/manual-mockshttps://jestjs.io/docs/en/manual-mocks /** * External dependencies */ //eslint-disable-next-line no-undef -- TODO: Figure out why this is needed. const path = require('path'); const fs = jest.createMockFromModule('fs'); /* eslint-disable security/detect-object-injection -- TODO: Figure out why this is needed. */ // This is a custom function that our tests can use during setup to specify // what the files on the "mock" filesystem should look like when any of the // `fs` APIs are used. let mockFiles = Object.create(null); function __setMockFiles(newMockFiles) { mockFiles = Object.create(null); for (const [file, content] of Object.entries(newMockFiles)) { const dir = path.dirname(file); const basename = path.basename(file); if (!mockFiles[dir]) { mockFiles[dir] = []; } mockFiles[dir][basename] = content; } } function readdirSync(directoryPath) { return mockFiles[directoryPath] ? Object.keys(mockFiles[directoryPath]) : []; } function existsSync(dirOrFile) { const dirExists = Object.prototype.hasOwnProperty.call(mockFiles, dirOrFile); const dirname = path.dirname(dirOrFile); const fileExists = Object.prototype.hasOwnProperty.call(mockFiles, dirname) && Object.prototype.hasOwnProperty.call( mockFiles[dirname], path.basename(dirOrFile) ); return dirExists || fileExists; } function readFileSync(file) { const dir = path.dirname(file); return mockFiles[dir][path.basename(file)]; } function writeFileSync(file, content) { const dir = path.dirname(file); if (!mockFiles[dir]) { mockFiles[dir] = []; } mockFiles[dir][path.basename(file)] = content; } function lstatSync(dirOrFile) { return { isDirectory: () => Object.prototype.hasOwnProperty.call(mockFiles, dirOrFile), }; } function rmdirSync(dirname) { if (Object.prototype.hasOwnProperty.call(mockFiles, dirname)) { delete mockFiles[dirname]; } } function unlinkSync(file) { const dir = path.dirname(file); delete mockFiles[dir][path.basename(file)]; } fs.__setMockFiles = __setMockFiles; fs.readdirSync = readdirSync; fs.readFileSync = readFileSync; fs.writeFileSync = writeFileSync; fs.existsSync = existsSync; fs.lstatSync = lstatSync; fs.rmdirSync = rmdirSync; fs.unlinkSync = unlinkSync; // eslint-disable-next-line no-undef -- TODO: Figure out why this is needed. module.exports = fs; /* eslint-enable security/detect-object-injection */ ================================================ FILE: __mocks__/react-moveable.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { forwardRef } from 'react'; // eslint-disable-next-line no-unused-vars export const MockMoveable = jest.fn((props, ref) =>
); export default forwardRef(MockMoveable); ================================================ FILE: __static__/README.md ================================================ # Static files These are static files that can be used for testing. ================================================ FILE: babel.config.cjs ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ module.exports = function (api) { const isTest = api.env('test'); const isProduction = api.env('production'); const targets = isTest ? { node: 'current', } : undefined; return { presets: [ [ '@babel/preset-env', { shippedProposals: true, targets, useBuiltIns: 'usage', corejs: require('core-js/package.json').version, // Remove some unnecessary polyfills, similar to how Gutenberg is handling that. // See https://github.com/WordPress/gutenberg/blob/e95970d888c309274e24324d593c77c536c9f1d8/packages/babel-preset-default/polyfill-exclusions.js. exclude: [ 'es.array.push', /^es(next)?\.set\./, /^es(next)?\.iterator\./, ], }, ], [ '@babel/preset-react', { // Not fully released yet, see https://github.com/facebook/react/pull/18299#issuecomment-603738136. //runtime: 'automatic', development: !isProduction, }, ], '@babel/preset-typescript', ], plugins: [ ['babel-plugin-react-compiler', { target: '17' }], '@wordpress/babel-plugin-import-jsx-pragma', [ 'babel-plugin-styled-components', { meaninglessFileNames: ['index', 'styles', 'components'], }, ], ], sourceMaps: true, env: { production: { plugins: ['transform-react-remove-prop-types'], }, }, }; }; ================================================ FILE: bin/deploy-to-test-environment.sh ================================================ #!/usr/bin/env bash set -e # # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Custom deployment script for Pantheon environment. # Adapted from original version at https://github.com/ampproject/amp-wp/pull/1042. echo "Initializing deployment to Web Stories test environment" PANTHEON_SITE="wordpress-amp" PANTHEON_BRANCH="stories-new" PANTHEON_UUID="6b7f1eeb-705b-4201-864d-2007030c8372" cd "$(dirname "$0")/.." project_dir="$(pwd)" repo_dir="$HOME/deployment-targets/$PANTHEON_SITE" echo "Setting up SSH configuration" # Dynamic hosts through Pantheon mean constantly checking interactively # that we mean to connect to an unknown host. We ignore those here. echo "StrictHostKeyChecking no" > ~/.ssh/config if ! grep -q "codeserver.dev.$PANTHEON_UUID.drush.in" ~/.ssh/known_hosts; then ssh-keyscan -p 2222 codeserver.dev.$PANTHEON_UUID.drush.in >> ~/.ssh/known_hosts fi if ! grep -q "codeserver.dev.$PANTHEON_UUID.drush.in" ~/.ssh/config; then echo "" >> ~/.ssh/config echo "Host $PANTHEON_SITE" >> ~/.ssh/config echo " Hostname codeserver.dev.$PANTHEON_UUID.drush.in" >> ~/.ssh/config echo " User codeserver.dev.$PANTHEON_UUID" >> ~/.ssh/config echo " Port 2222" >> ~/.ssh/config echo " KbdInteractiveAuthentication no" >> ~/.ssh/config fi echo "Fetching remote repository" git config --global user.name "Google for Creators Bot" git config --global user.email "94923726+googleforcreators-bot@users.noreply.github.com" if [ ! -e "$repo_dir/.git" ]; then git clone -v ssh://codeserver.dev.$PANTHEON_UUID@codeserver.dev.$PANTHEON_UUID.drush.in:2222/~/repository.git "$repo_dir" fi cd "$repo_dir" git fetch if git rev-parse --verify --quiet "$PANTHEON_BRANCH" > /dev/null; then git checkout "$PANTHEON_BRANCH" else git checkout -b "$PANTHEON_BRANCH" fi if git rev-parse --verify --quiet "origin/$PANTHEON_BRANCH" > /dev/null; then git reset --hard "origin/$PANTHEON_BRANCH" fi cd "$project_dir" echo "Moving files to repository" rsync -avz --delete ./build/web-stories/ "$repo_dir/wp-content/plugins/web-stories/" git --no-pager log -1 --format="Build Web Stories plugin at %h: %s" > /tmp/commit-message.txt echo "Committing changes" # Commit and deploy. cd "$repo_dir" git add -A "wp-content/plugins/web-stories/" git commit -F /tmp/commit-message.txt echo "Pushing new build to remote repository" git push origin $PANTHEON_BRANCH echo "View site at http://$PANTHEON_BRANCH-$PANTHEON_SITE.pantheonsite.io/" echo "Access Pantheon dashboard at https://dashboard.pantheon.io/sites/$PANTHEON_UUID#$PANTHEON_BRANCH" ================================================ FILE: bin/install-wp-tests.sh ================================================ #!/usr/bin/env bash # # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # See https://raw.githubusercontent.com/wp-cli/scaffold-command/master/templates/install-wp-tests.sh if [ $# -lt 3 ]; then echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" exit 1 fi DB_NAME=$1 DB_USER=$2 DB_PASS=$3 DB_HOST=${4-localhost} WP_VERSION=${5-latest} SKIP_DB_CREATE=${6-false} TMPDIR=${TMPDIR-/tmp} TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} WP_TESTS_FILE="$WP_TESTS_DIR"/includes/functions.php WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} WP_CORE_FILE="$WP_CORE_DIR"/wp-settings.php download() { if [ `which curl` ]; then #curl -s "$1" > "$2"; # WordPress.org seems to block requests without proper user agent. curl -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (K HTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36" -s "$1" > "$2"; elif [ `which wget` ]; then wget -nv -O "$2" "$1" fi } if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then WP_BRANCH=${WP_VERSION%\-*} WP_TESTS_TAG="branches/$WP_BRANCH" elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then WP_TESTS_TAG="branches/$WP_VERSION" elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x WP_TESTS_TAG="tags/${WP_VERSION%??}" else WP_TESTS_TAG="tags/$WP_VERSION" fi elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then WP_TESTS_TAG="trunk" else # http serves a single offer, whereas https serves multiple. we only want one download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') if [[ -z "$LATEST_VERSION" ]]; then echo "Latest WordPress version could not be found" exit 1 fi WP_TESTS_TAG="tags/$LATEST_VERSION" fi set -ex install_wp() { if [ -f $WP_CORE_FILE ]; then return; fi rm -rf $WP_CORE_DIR mkdir -p $WP_CORE_DIR if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then mkdir -p $TMPDIR/wordpress-trunk svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR else if [ $WP_VERSION == 'latest' ]; then local ARCHIVE_NAME='latest' elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then # https serves multiple offers, whereas http serves single. download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x LATEST_VERSION=${WP_VERSION%??} else # otherwise, scan the releases and get the most up to date minor version of the major release local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) fi if [[ -z "$LATEST_VERSION" ]]; then local ARCHIVE_NAME="wordpress-$WP_VERSION" else local ARCHIVE_NAME="wordpress-$LATEST_VERSION" fi else local ARCHIVE_NAME="wordpress-$WP_VERSION" fi download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR fi download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php } install_test_suite() { # portable in-place argument for both GNU sed and Mac OSX sed if [[ $(uname -s) == 'Darwin' ]]; then local ioption='-i.bak' else local ioption='-i' fi # set up testing suite if it doesn't yet exist or only partially exists if [ ! -f $WP_TESTS_FILE ]; then # set up testing suite rm -rf $WP_TESTS_DIR mkdir -p $WP_TESTS_DIR svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data fi if [ ! -f wp-tests-config.php ]; then download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php # remove all forward slashes in the end WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php fi } install_db() { if [ ${SKIP_DB_CREATE} = "true" ]; then return 0 fi # parse DB_HOST for port or socket references local PARTS=(${DB_HOST//\:/ }) local DB_HOSTNAME=${PARTS[0]}; local DB_SOCK_OR_PORT=${PARTS[1]}; local EXTRA="" if ! [ -z $DB_HOSTNAME ] ; then if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" elif ! [ -z $DB_SOCK_OR_PORT ] ; then EXTRA=" --socket=$DB_SOCK_OR_PORT" elif ! [ -z $DB_HOSTNAME ] ; then EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" fi fi # create database mariadb-admin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA || \ mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA } install_wp install_test_suite install_db ================================================ FILE: bin/local-env/docker-compose.yml ================================================ services: wordpress: image: wordpress:php${PHP_VERSION:-8.2} ports: - '127.0.0.1:8899:80' environment: WORDPRESS_DB_HOST: mysql WORDPRESS_DB_USER: root WORDPRESS_DB_PASSWORD: example WORDPRESS_DB_NAME: wordpress WP_ENVIRONMENT_TYPE: local ABSPATH: /usr/src/wordpress/ WORDPRESS_DEBUG: 1 WORDPRESS_CONFIG_EXTRA: | define( 'SCRIPT_DEBUG', true ); define( 'FS_METHOD', 'direct' ); define( 'WEBSTORIES_DEV_MODE', true ); volumes: - wordpress_data:/var/www/html - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini - ../../:/var/www/html/wp-content/plugins/web-stories - ../../packages/e2e-tests/src/plugins:/var/www/html/wp-content/plugins/web-stories-test-plugins - ../../packages/e2e-tests/src/assets:/var/www/html/wp-content/e2e-assets depends_on: - mysql cli: image: wordpress:cli-php${PHP_VERSION:-8.2} environment: WORDPRESS_DB_HOST: mysql WORDPRESS_DB_USER: root WORDPRESS_DB_PASSWORD: example WORDPRESS_DB_NAME: wordpress volumes: - wordpress_data:/var/www/html - ../../:/var/www/html/wp-content/plugins/web-stories - ../../packages/e2e-tests/src/plugins:/var/www/html/wp-content/plugins/web-stories-test-plugins - ../../packages/e2e-tests/src/assets:/var/www/html/wp-content/e2e-assets depends_on: - mysql - wordpress command: tail -f /dev/null mysql: image: mariadb:lts command: --max-allowed-packet=16777216 environment: MYSQL_ROOT_PASSWORD: example MYSQL_DATABASE: wordpress_test volumes: - ./data/mysql:/var/lib/mysql volumes: wordpress_data: ================================================ FILE: bin/local-env/includes.sh ================================================ #!/usr/bin/env bash # Common variables. DOCKER_COMPOSE_FILE_OPTIONS="-f $(dirname "$0")/docker-compose.yml" if [ -f "$(dirname "$0")/docker-compose.override.yml" ]; then DOCKER_COMPOSE_FILE_OPTIONS="$DOCKER_COMPOSE_FILE_OPTIONS -f $(dirname "$0")/docker-compose.override.yml" fi # These are the containers and values for the development site. CLI='cli' CONTAINER='wordpress' DATABASE='mysql' SITE_TITLE='Web Stories Dev' ## # Ask a Yes/No question, and way for a reply. # # This is a general-purpose function to ask Yes/No questions in Bash, either with or without a default # answer. It keeps repeating the question until it gets a valid answer. # # @param {string} prompt The question to ask the user. # @param {string} [default] Optional. "Y" or "N", for the default option to use if none is entered. # @param {int} [timeout] Optional. The number of seconds to wait before using the default option. # # @returns {bool} true if the user replies Yes, false if the user replies No. ## ask() { # Source: https://djm.me/ask local timeout endtime timediff prompt default reply while true; do timeout="${3:-}" if [ "${2:-}" = "Y" ]; then prompt="Y/n" default=Y elif [ "${2:-}" = "N" ]; then prompt="y/N" default=N else prompt="y/n" default= timeout= fi if [ -z "$timeout" ]; then # Ask the question (not using "read -p" as it uses stderr not stdout) echo -en "$1 [$prompt] " # Read the answer (use /dev/tty in case stdin is redirected from somewhere else) read reply /dev/null 2>&1 } ## # Docker Compose helper # # Calls docker compose with common options. ## dc() { docker compose $DOCKER_COMPOSE_FILE_OPTIONS "$@" } ## # WP CLI # # Executes a WP CLI request in the CLI container. ## wp() { dc exec -T -u 33:33 $CLI wp "$@" } ## # MySQL CLI. # # Executes the given MySQL client command in the database container. ## mysql() { dc exec -T -e MYSQL_PWD=example $DATABASE mariadb "$@" } ## # WordPress Container helper. # # Executes the given command in the wordpress container. ## container() { dc exec -T $CONTAINER "$@" } ================================================ FILE: bin/local-env/install-wordpress.sh ================================================ #!/usr/bin/env bash # Exit if any command fails. set -e # Common variables. WP_DEBUG=${WP_DEBUG-true} SCRIPT_DEBUG=${SCRIPT_DEBUG-true} WEBSTORIES_DEV_MODE=${WEBSTORIES_DEV_MODE-true} MEDIA_TRASH=${MEDIA_TRASH-false} WP_VERSION=${WP_VERSION-"latest"} # Include useful functions . "$(dirname "$0")/includes.sh" # Make sure Docker containers are running dc up -d >/dev/null 2>&1 # Get the host port for the WordPress container. HOST_PORT=$(dc port $CONTAINER 80 | awk -F : '{printf $2}') # Wait until the WordPress site is responding to requests. echo -en $(status_message "Attempting to connect to WordPress...") until $(curl -L http://localhost:$HOST_PORT -so - 2>&1 | grep -q "WordPress"); do echo -n '.' sleep 5 done echo '' # Wait until the database container is ready. echo -en $(status_message "Waiting for database connection...") until $(container bash -c "echo -n > /dev/tcp/mysql/3306" >/dev/null 2>&1); do echo -n '.' sleep 5 done echo '' # Create the database if it doesn't exist. echo -e $(status_message "Creating the database (if it does not exist)...") mysql -e 'CREATE DATABASE IF NOT EXISTS wordpress;' # If this is the test site, we reset the database so no posts/comments/etc. # dirty up the tests. if [ "$1" == '--reset-site' ]; then echo -e $(status_message "Resetting test database...") wp db reset --yes --quiet fi if [ ! -z "$WP_VERSION" ] && [ "$WP_VERSION" != "latest" ]; then # Potentially downgrade (or upgrade) WordPress echo -e $(status_message "Downloading WordPress version $WP_VERSION...") wp core download --version=${WP_VERSION} --skip-content --force --quiet fi # Install WordPress. echo -e $(status_message "Installing WordPress...") wp core install --title="$SITE_TITLE" --admin_user=admin --admin_password=password --admin_email=test@test.com --skip-email --url=http://localhost:$HOST_PORT --quiet # Potentially update WordPress if [ "$WP_VERSION" == "latest" ]; then echo -e $(status_message "Updating WordPress to the latest major") wp core update --force --quiet else echo -e $(status_message "Updating WordPress to the latest minor") wp core update --minor --force --quiet fi # Create additional users. echo -e $(status_message "Creating additional users...") if [[ $(wp user get editor --field=login 2>&1) != "editor" ]]; then wp user create editor editor@example.com --role=editor --user_pass=password echo -e $(status_message "Editor created! Username: editor Password: password") else echo -e $(status_message "Editor already exists, skipping...") fi if [[ $(wp user get author --field=login 2>&1) != "author" ]]; then wp user create author author@example.com --role=author --user_pass=password --quiet echo -e $(status_message "Author created! Username: author Password: password") else echo -e $(status_message "Author already exists, skipping...") fi if [[ $(wp user get contributor --field=login 2>&1) != "contributor" ]]; then wp user create contributor contributor@example.com --role=contributor --user_pass=password --quiet echo -e $(status_message "Contributor created! Username: contributor Password: password") else echo -e $(status_message "Contributor already exists, skipping...") fi if [[ $(wp user get subscriber --field=login 2>&1) != "subscriber" ]]; then wp user create subscriber subscriber@example.com --role=subscriber --user_pass=password --quiet echo -e $(status_message "Subscriber created! Username: subscriber Password: password") else echo -e $(status_message "Subscriber already exists, skipping...") fi # Make sure the uploads and upgrade folders exist and we have permissions to add files. echo -e $(status_message "Ensuring that files can be uploaded...") container mkdir -p \ /var/www/html/wp-content/uploads \ /var/www/html/wp-content/upgrade container chmod 767 \ /var/www/html/wp-content \ /var/www/html/wp-content/plugins \ /var/www/html/wp-config.php \ /var/www/html/wp-settings.php \ /var/www/html/wp-content/uploads \ /var/www/html/wp-content/upgrade CURRENT_WP_VERSION=$(wp core version | tr -d '\r') echo -e $(status_message "Current WordPress version: $CURRENT_WP_VERSION...") if [ "$WP_VERSION" == "latest" ]; then # Check for WordPress updates, to make sure we're running the very latest version. echo -e $(status_message "Updating WordPress to the latest version...") wp core update --quiet echo -e $(status_message "Updating The WordPress Database...") wp core update-db --quiet fi # If the 'wordpress' volume wasn't during the down/up earlier, but the post port has changed, we need to update it. echo -e $(status_message "Checking the site's url...") CURRENT_URL=$(wp option get siteurl) if [ "$CURRENT_URL" != "http://localhost:$HOST_PORT" ]; then wp option update home "http://localhost:$HOST_PORT" --quiet wp option update siteurl "http://localhost:$HOST_PORT" --quiet fi # Activate Web Stories plugin. echo -e $(status_message "Activating Web Stories plugin...") wp plugin activate web-stories --quiet # Install & activate testing plugins. # Only install Gutenberg on latest version of WordPress. if [ "$WP_VERSION" == "latest" ]; then echo -e $(status_message "Installing Gutenberg plugin...") wp plugin install gutenberg --force --quiet fi echo -e $(status_message "Installing and activating RTL Tester plugin...") wp plugin install rtl-tester --activate --force --quiet echo -e $(status_message "Installing WordPress importer...") wp plugin install wordpress-importer --activate --force --quiet # WooCommerce 9.8 is the last version that still supports WordPress 6.6. echo -e $(status_message "Installing WooCommerce plugin...") if [ "$WP_VERSION" == "6.6" ]; then wp plugin install woocommerce --version=9.8.2 --activate --force --quiet else wp plugin install woocommerce --activate --force --quiet fi echo -e $(status_message "Installing AMP plugin...") wp plugin install amp --force --quiet echo -e $(status_message "Installing Classic editor plugin...") wp plugin install classic-editor --force --quiet echo -e $(status_message "Installing Classic Widgets plugin...") wp plugin install classic-widgets --force --quiet echo -e $(status_message "Activating Twenty Twenty theme...") wp theme install twentytwenty --activate --force --quiet echo -e $(status_message "Disable block directory and remote patterns...") wp plugin activate web-stories-test-plugins/disable-block-directory --quiet echo -e $(status_message "Disable Gravatar...") wp plugin activate web-stories-test-plugins/disable-gravatar --quiet # Set pretty permalinks. echo -e $(status_message "Setting permalink structure...") wp rewrite structure '%postname%' --hard --quiet # Configure site constants. echo -e $(status_message "Configuring site constants...") WP_DEBUG_CURRENT=$(wp config get --type=constant --format=json WP_DEBUG | tr -d '\r') if [[ "$WP_DEBUG" != $WP_DEBUG_CURRENT ]]; then wp config set WP_DEBUG $WP_DEBUG --raw --type=constant --quiet --anchor="That's all, stop editing" WP_DEBUG_RESULT=$(wp config get --type=constant --format=json WP_DEBUG | tr -d '\r') echo -e $(status_message "WP_DEBUG: $WP_DEBUG_RESULT...") fi SCRIPT_DEBUG_CURRENT=$(wp config get --type=constant --format=json SCRIPT_DEBUG | tr -d '\r') if [[ "$SCRIPT_DEBUG" != $SCRIPT_DEBUG_CURRENT ]]; then wp config set SCRIPT_DEBUG $SCRIPT_DEBUG --raw --type=constant --quiet --anchor="That's all, stop editing" SCRIPT_DEBUG_RESULT=$(wp config get --type=constant --format=json SCRIPT_DEBUG | tr -d '\r') echo -e $(status_message "SCRIPT_DEBUG: $SCRIPT_DEBUG_RESULT...") fi WEBSTORIES_DEV_MODE_CURRENT=!$WEBSTORIES_DEV_MODE; if [[ "$(wp config has --type=constant WEBSTORIES_DEV_MODE)" ]]; then WEBSTORIES_DEV_MODE_CURRENT=$(wp config get --type=constant --format=json WEBSTORIES_DEV_MODE | tr -d '\r') fi if [[ "$WEBSTORIES_DEV_MODE" != $WEBSTORIES_DEV_MODE_CURRENT ]]; then wp config set WEBSTORIES_DEV_MODE $WEBSTORIES_DEV_MODE --raw --type=constant --quiet --anchor="That's all, stop editing" WEBSTORIES_DEV_MODE_RESULT=$(wp config get --type=constant --format=json WEBSTORIES_DEV_MODE | tr -d '\r') echo -e $(status_message "WEBSTORIES_DEV_MODE: $WEBSTORIES_DEV_MODE_RESULT...") fi MEDIA_TRASH_CURRENT=!MEDIA_TRASH; if [[ "$(wp config has --type=constant MEDIA_TRASH)" ]]; then MEDIA_TRASH_CURRENT=$(wp config get --type=constant --format=json MEDIA_TRASH | tr -d '\r') fi if [[ "$MEDIA_TRASH" != $MEDIA_TRASH_CURRENT ]]; then wp config set MEDIA_TRASH $MEDIA_TRASH --raw --type=constant --quiet --anchor="That's all, stop editing" MEDIA_TRASH_RESULT=$(wp config get --type=constant --format=json MEDIA_TRASH | tr -d '\r') echo -e $(status_message "MEDIA_TRASH: $MEDIA_TRASH_RESULT...") fi # Let's make sure we have some media in the media library to work with. echo -e $(status_message "Import default set of media assets...") # TODO: use glob pattern to import items. See https://developer.wordpress.org/cli/commands/media/import/. WEBM_VIDEO_ID=$(wp media import /var/www/html/wp-content/e2e-assets/small-video.webm --porcelain) WEBM_VIDEO_POSTER_ID=$(wp media import /var/www/html/wp-content/e2e-assets/small-video-poster.jpg --post_id=$WEBM_VIDEO_ID --featured_image --porcelain) # So the poster is marked as such and hidden in the editor. wp post term add $WEBM_VIDEO_POSTER_ID web_story_media_source "poster-generation" --quiet wp post meta add $WEBM_VIDEO_ID web_stories_poster_id $WEBM_VIDEO_POSTER_ID --quiet wp media import /var/www/html/wp-content/e2e-assets/example-1.jpg --quiet wp media import /var/www/html/wp-content/e2e-assets/example-2.jpg --quiet wp media import /var/www/html/wp-content/e2e-assets/example-3.png --quiet # Ensures that the patch command below always works. wp option update web_stories_experiments '{}' --format=json wp option patch insert web_stories_experiments enableSVG 1 wp media import /var/www/html/wp-content/e2e-assets/video-play.svg wp option patch insert web_stories_experiments enableSVG 0 wp user list --format=yaml wp post list --post_type=attachment --format=yaml wp plugin list --format=yaml echo -e $(status_message "Import sample WooCommerce products...") wp import /var/www/html/wp-content/plugins/woocommerce/sample-data/sample_products.xml --authors=skip --quiet echo -e $(status_message "Deactivating WooCommerce again...") wp plugin deactivate woocommerce echo -e $(status_message "Deactivating WordPress importer again...") wp plugin deactivate wordpress-importer --quiet ================================================ FILE: bin/local-env/launch-containers.sh ================================================ #!/usr/bin/env bash # Exit if any command fails. set -e # Include useful functions. . "$(dirname "$0")/includes.sh" # Check that Docker is installed. if ! command_exists "docker"; then echo -e $(error_message "Docker doesn't seem to be installed. Please head on over to the Docker site to download it: $(action_format "https://www.docker.com/products/docker-desktop")") exit 1 fi # Check that Docker is running. if ! docker info >/dev/null 2>&1; then echo -e $(error_message "Docker isn't running. Please check that you've started your Docker app, and see it in your system tray.") exit 1 fi # Stop existing containers. echo -e $(status_message "Stopping Docker containers...") dc down --remove-orphans >/dev/null 2>&1 # Download image updates. echo -e $(status_message "Downloading Docker image updates...") dc pull # Launch the containers. echo -e $(status_message "Starting Docker containers...") dc up -d >/dev/null ================================================ FILE: bin/local-env/start.sh ================================================ #!/usr/bin/env bash # Exit if any command fails set -e # Include useful functions . "$(dirname "$0")/includes.sh" # Change to the expected directory cd "$(dirname "$0")/../.." # Check whether Docker is installed and running . "$(dirname "$0")/launch-containers.sh" # Set up WordPress Development site. # Note: we don't bother installing the test site right now, because that's # done on every time `npm run test-e2e` is run. . "$(dirname "$0")/install-wordpress.sh" CURRENT_URL=$(wp option get siteurl | tr -d '\r') echo -e "\nWelcome to the Web Stories development environment...\n" # Give the user more context to what they should do next: Build the plugin and start testing! echo -e "\nRun $(action_format "npm run dev") to build the latest version of the Web Stories plugin," echo -e "then open $(action_format "$CURRENT_URL") to get started!" echo -e "\n\nAccess the above install using the following credentials:" echo -e "Default username: $(action_format "admin"), password: $(action_format "password")" if [ -z "$CI" ]; then if command -v xdg-open > /dev/null; then xdg-open "$CURRENT_URL" elif command -v open > /dev/null; then open "$CURRENT_URL" elif command -v xdg-open > /dev/null; then xdg-open "$CURRENT_URL" fi fi ================================================ FILE: bin/local-env/stop.sh ================================================ #!/usr/bin/env bash # Exit if any command fails. set -e # Include useful functions. . "$(dirname "$0")/includes.sh" # Check that Docker is installed. if ! command_exists "docker"; then echo -e $(error_message "Docker doesn't seem to be installed. Please head on over to the Docker site to download it: $(action_format "https://www.docker.com/products/docker-desktop")") exit 1 fi # Check that Docker is running. if ! docker info >/dev/null 2>&1; then echo -e $(error_message "Docker isn't running. Please check that you've started your Docker app, and see it in your system tray.") exit 1 fi # Stop existing containers. echo -e $(status_message "Stopping Docker containers...") dc down --remove-orphans >/dev/null 2>&1 ================================================ FILE: bin/local-env/uploads.ini ================================================ upload_max_filesize = 100M; post_max_size = 100M; ================================================ FILE: bin/schemas/story.json ================================================ { "$id": "https://wp.stories.google/schema/story.json", "type": "object", "default": {}, "title": "Web Stories for WordPress Schema", "required": ["version", "pages"], "properties": { "version": { "type": "integer", "default": 0, "minimum": 0, "title": "The story's data version", "description": "Used for data migrations" }, "pages": { "type": "array", "minItems": 1, "title": "Story pages", "description": "Holds individual pages of a story", "items": { "$ref": "#/$defs/page" } } }, "$defs": { "https": { "type": "string", "format": "uri-reference", "pattern": "^(/|https://|https?://(127.0.0.1|([^/]+\\.)?localhost)(:[0-9]+)?/)" }, "animation": { "type": "object", "title": "An animation", "required": ["id", "type", "targets", "duration", "delay"], "properties": { "id": { "type": "string", "title": "Animation ID", "format": "uuid" }, "type": { "type": "string", "title": "Animation type", "enum": [ "blinkOn", "bounce", "effect-background-pan", "effect-background-pan-and-zoom", "effect-background-zoom", "effect-drop", "effect-fade-in", "effect-fly-in", "effect-pan", "effect-pulse", "effect-rotate-in", "effect-twirl-in", "effect-whoosh-in", "effect-zoom", "fade", "flip", "floatOn", "move", "pulse", "spin", "zoom" ] }, "targets": { "type": "array", "title": "Animation targets", "items": { "type": "string", "title": "Target element ID" } }, "panDir": { "type": "string", "title": "panDir direction", "enum": ["leftToRight", "topToBottom", "rightToLeft", "bottomToTop"] }, "duration": { "type": "integer", "title": "Duration in ms", "minimum": 0 }, "delay": { "type": "integer", "title": "Delay in ms", "minimum": 0 }, "zoomDirection": { "type": "string", "default": "", "title": "Zoom direction", "enum": [ "dynamicPropertyValue", "scaleIn", "scaleInBottomRight", "scaleInTopLeft", "scaleOut", "scaleOutTopRight", "scaleOutBottomLeft" ] } } }, "element": { "type": "object", "title": "Base element", "required": ["x", "y", "width", "height", "type", "id", "rotationAngle"], "properties": { "opacity": { "type": "integer", "minimum": 0, "maximum": 100, "default": 100, "title": "Element opacity", "$comment": "TODO: Set by default and make required" }, "flip": { "type": "object", "title": "Flip setting", "$comment": "TODO: Move somewhere else since text elements can't be flipped.", "required": ["vertical", "horizontal"], "properties": { "vertical": { "type": "boolean", "title": "Vertical flipping" }, "horizontal": { "type": "boolean", "title": "Horizontal flipping" } } }, "rotationAngle": { "type": "integer", "title": "Rotation angle", "$comment": "TODO: Set by default and make required", "minimum": -360, "maximum": 360 }, "lockAspectRatio": { "type": "boolean", "title": "Whether aspect ratio is locked", "$comment": "TODO: Set by default and make required" }, "backgroundColor": { "type": "object", "title": "Background color", "$ref": "#/$defs/pattern" }, "x": { "type": "number", "title": "X position" }, "y": { "type": "number", "title": "Y position" }, "width": { "type": "number", "title": "Element width", "minimum": 1 }, "height": { "type": "number", "title": "Element height", "minimum": 1 }, "mask": { "type": "object", "title": "Element mask", "required": ["type"], "properties": { "type": { "type": "string", "title": "Mask type", "enum": [ "heart", "star", "circle", "rectangle", "triangle", "rounded-rectangle", "rounded-rectangle-2", "pentagon", "hexagon", "cross", "arrow", "burst", "bullseye", "blob-1", "blob-2", "blob-3", "blob-4", "blob-5", "blob-6", "grid-1", "grid-2", "grid-3", "grid-4", "grid-5", "grid-6", "grid-7", "grid-8", "burst-outline", "arrow-1", "arrow-2", "arrow-3", "geography-1", "twitter", "instagram", "facebook", "youtube", "brush-stroke-1", "brush-stroke-2", "chat-bubble", "check-mark", "new-music", "music-note", "fashion-arrow", "fashion-arrow-2", "wellbeing-arrow" ] }, "showInLibrary": { "type": "boolean", "$comment": "TODO: Remove / do not store" }, "name": { "type": "string", "$comment": "TODO: Remove / do not store" }, "path": { "type": "string", "$comment": "TODO: Remove / do not store" }, "ratio": { "type": "number", "$comment": "TODO: Remove / do not store" }, "supportsBorder": { "type": "boolean", "$comment": "TODO: Remove / do not store" } } }, "type": { "type": "string", "title": "The element type", "enum": ["image", "sticker", "text", "video", "shape", "gif"] }, "id": { "type": "string", "title": "Element ID", "format": "uuid" }, "borderRadius": { "type": "object", "title": "Element border radius", "required": [ "locked", "topLeft", "topRight", "bottomRight", "bottomLeft" ], "properties": { "locked": { "type": "boolean", "title": "Whether padding is locked" }, "topLeft": { "type": "integer", "title": "Top left radius", "minimum": 0 }, "topRight": { "type": "integer", "title": "Top right radius", "minimum": 0 }, "bottomRight": { "type": "integer", "title": "Bottom right radius", "minimum": 0 }, "bottomLeft": { "type": "integer", "title": "Bottom left radius", "minimum": 0 } } }, "basedOn": { "type": "string", "default": "", "title": "Reference to the original element if this one was duplicated/copied", "$comment": "TODO: Remove?" }, "isBackground": { "type": "boolean", "title": "Whether it is the background element", "$comment": "TODO: Move somewhere else since text elements can't be background." }, "groupId": { "type": "string", "title": "Layer group ID" } } }, "textElement": { "type": "object", "title": "Text element", "allOf": [ { "$ref": "#/$defs/element" } ], "required": [ "backgroundTextMode", "font", "fontSize", "lineHeight", "textAlign", "content", "fontWeight" ], "properties": { "backgroundTextMode": { "type": "string", "title": "Text background mode", "enum": ["NONE", "FILL", "HIGHLIGHT"] }, "font": { "type": "object", "title": "The font Schema", "required": [ "family", "service", "fallbacks", "weights", "styles", "variants", "metrics" ], "properties": { "family": { "type": "string", "title": "Font family" }, "service": { "type": "string", "title": "Font provider", "examples": ["fonts.google.com"] }, "fallbacks": { "type": "array", "title": "Fallback fonts", "items": { "type": "string", "title": "Font family" }, "examples": [["sans-serif"], ["Helvetica", "sans-serif"]] }, "weights": { "type": "array", "title": "Supported font weights", "minItems": 1, "items": { "type": "integer", "title": "Font weight", "minimum": 100, "maximum": 900 } }, "styles": { "type": "array", "title": "Support font styles", "minItems": 1, "items": { "type": "string", "title": "Font style", "enum": ["italic", "regular"] } }, "variants": { "type": "array", "title": "Font variants", "items": { "type": "array", "title": "Font variant tuple", "minItems": 2, "maxItems": 2, "items": { "type": "integer" }, "examples": [ [0, 100], [1, 500], [1, 700] ] } }, "metrics": { "type": "object", "title": "Font metrics", "required": [ "upm", "asc", "des", "tAsc", "tDes", "tLGap", "wAsc", "wDes", "xH", "capH", "yMin", "yMax", "hAsc", "hDes", "lGap" ], "properties": { "upm": { "type": "integer" }, "asc": { "type": "integer" }, "des": { "type": "integer" }, "tAsc": { "type": "integer" }, "tDes": { "type": "integer" }, "tLGap": { "type": "integer" }, "wAsc": { "type": "integer" }, "wDes": { "type": "integer" }, "xH": { "type": "integer" }, "capH": { "type": "integer" }, "yMin": { "type": "integer" }, "yMax": { "type": "integer" }, "hAsc": { "type": "integer" }, "hDes": { "type": "integer" }, "lGap": { "type": "integer" } } } } }, "fontSize": { "type": "integer", "title": "Font size", "minimum": 1 }, "lineHeight": { "type": "number", "title": "Line height" }, "textAlign": { "type": "string", "title": "Text alignment", "enum": ["initial", "left", "center", "right"] }, "content": { "type": "string", "title": "The element's content. Can contain HTML." }, "padding": { "type": "object", "title": "Element padding", "required": [], "properties": { "horizontal": { "type": "integer", "title": "Horizontal padding", "minimum": 0 }, "vertical": { "type": "integer", "title": "Vertical padding", "minimum": 0 }, "locked": { "type": "boolean", "title": "Whether padding is locked" }, "hasHiddenPadding": { "type": "boolean", "title": "Whether the element has hidden padding" } } }, "marginOffset": { "type": "number", "title": "Margin offset" } } }, "stickerElement": { "type": "object", "title": "Sticker element", "allOf": [ { "$ref": "#/$defs/element" } ], "required": ["sticker"], "properties": { "sticker": { "type": "object", "title": "The sticker Schema", "required": ["type"], "properties": { "type": { "type": "string", "title": "The type Schema" } } } } }, "mediaElement": { "type": "object", "title": "Media element", "allOf": [ { "$ref": "#/$defs/element" } ], "required": ["resource"], "properties": { "resource": { "type": "object", "title": "The resource Schema", "required": [ "type", "mimeType", "src", "width", "height", "id", "alt", "sizes" ], "properties": { "type": { "type": "string", "title": "Resource type", "enum": ["image", "video", "gif"] }, "mimeType": { "type": "string", "title": "Mime type" }, "src": { "type": "string", "title": "Media source" }, "width": { "type": "integer", "title": "Media width", "minimum": 1 }, "height": { "type": "integer", "title": "Media height", "minimum": 1 }, "id": { "type": "integer", "title": "Resource ID" }, "alt": { "type": "string", "title": "Alt text" }, "sizes": { "type": "array", "title": "The sizes Schema", "items": {}, "$comment": "TODO: Complete schema" }, "isOptimized": { "type": "boolean", "title": "Whether the resource is considered optimized" }, "baseColor": { "type": "string", "title": "Average/Base color", "examples": ["#99684c", "#daa992"] }, "isExternal": { "type": "boolean", "title": "Whether it is an external resource" }, "creationDate": { "type": "string", "default": "", "title": "Date the media file was uploaded", "format": "date-time" }, "posterId": { "type": "integer", "default": 0, "title": "ID of the resource's poster" }, "scale": { "type": "number", "title": "Scaling", "minimum": 0 }, "focalX": { "type": "number", "title": "X axis focal point", "minimum": 0 }, "focalY": { "type": "number", "title": "Y axis focal point", "minimum": 0 } } }, "overlay": { "type": "object", "$ref": "#/$defs/linearPattern" } } }, "shapeElement": { "type": "object", "title": "Shape element", "allOf": [ { "$ref": "#/$defs/element" } ], "properties": { "isDefaultBackground": { "type": "boolean", "title": "Whether it is the default background" } } }, "pattern": { "type": "object", "oneOf": [ { "$ref": "#/$defs/solidPattern" }, { "$ref": "#/$defs/linearPattern" } ] }, "solidPattern": { "type": "object", "title": "Solid pattern", "required": ["color"], "properties": { "type": { "type": "string", "enum": ["none", "solid", "linear", "radial"] }, "color": { "type": "object", "title": "RGB(A) color", "required": ["r", "g", "b"], "properties": { "r": { "type": "integer", "title": "Red", "minimum": 0, "maximum": 255 }, "g": { "type": "integer", "title": "Green", "minimum": 0, "maximum": 255 }, "b": { "type": "integer", "title": "Blue", "minimum": 0, "maximum": 255 }, "a": { "type": "number", "title": "Alpha", "minimum": 0, "maximum": 1 } } } } }, "linearPattern": { "type": "object", "title": "Linear color pattern", "required": ["type", "stops"], "properties": { "type": { "type": "string", "enum": ["none", "solid", "linear", "radial"] }, "stops": { "type": "array", "minItems": 1, "items": { "type": "object", "allOf": [ { "$ref": "#/$defs/solidPattern" } ], "properties": { "position": { "type": "number", "minimum": 0, "maximum": 1 } } } }, "alpha": { "type": "number", "minimum": 0, "maximum": 1 }, "rotation": { "type": "number", "minimum": -359, "maximum": 359 } } }, "page": { "type": "object", "title": "Individual Page", "required": ["backgroundColor", "elements", "id", "type"], "properties": { "animations": { "type": "array", "title": "Animations on this page", "items": { "$ref": "#/$defs/animation" } }, "elements": { "type": "array", "minItems": 1, "title": "The page's elements", "items": { "anyOf": [ { "$ref": "#/$defs/element" }, { "$ref": "#/$defs/mediaElement" }, { "$ref": "#/$defs/shapeElement" }, { "$ref": "#/$defs/stickerElement" }, { "$ref": "#/$defs/textElement" } ] } }, "backgroundColor": { "type": "object", "title": "Background color", "$ref": "#/$defs/pattern" }, "defaultBackgroundElement": { "type": "object", "title": "The default background element", "$ref": "#/$defs/shapeElement", "$comment": "TODO: Move to story element or remove?" }, "id": { "type": "string", "title": "Page ID", "format": "uuid" }, "type": { "type": "string", "title": "The type Schema", "enum": ["page"], "$comment": "TODO: Remove?" }, "groups": { "type": "object", "title": "Layer groups", "patternProperties": { "^[\\w-]+": { "type": "object", "required": ["name", "isLocked"], "properties": { "name": { "type": "string" }, "isLocked": { "type": "boolean" }, "isCollapsed": { "type": "boolean" } } } } } } } } } ================================================ FILE: bin/setup-local-npm-registry.sh ================================================ #!/usr/bin/env bash # # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Echo every command being executed set -ex registry_url=http://localhost:4873 echo "Starting up local npm registry..." # Start local registry. tmp_registry_log=`mktemp` echo "Registry output file: $tmp_registry_log" curdir=$(dirname "$(realpath $0)") (cd && nohup npx verdaccio --config "$curdir/verdaccio-config.yml" &>$tmp_registry_log &) npm i --global verdaccio-memory # Wait for Verdaccio to boot. grep -q 'http address' <(tail -f $tmp_registry_log) echo "Local registry up and running! ${registry_url}" # Set registry to local registry export NPM_CONFIG_REGISTRY="$registry_url" echo "Logging in..." # Log in to Verdaccio so we can publish packages npx npm-cli-login -u admin -p password -e test@example.com -r $registry_url ================================================ FILE: bin/stop-local-npm-registry.sh ================================================ #!/usr/bin/env bash # # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # lsof -nti:4873 | xargs kill -9 unset NPM_CONFIG_REGISTRY ================================================ FILE: bin/verdaccio-config.yml ================================================ # # This is the default config file. It allows all users to do anything, # so don't use it on production systems. # # Look here for more config file examples: # https://github.com/verdaccio/verdaccio/tree/master/conf # # path to a directory with all packages storage: /tmp/verdaccio-workspace/storage web: title: Verdaccio auth: htpasswd: file: /tmp/verdaccio-workspace/htpasswd # a list of other known repositories we can talk to uplinks: npmjs: url: https://registry.npmjs.org/ packages: '@*/*': # scoped packages access: $all publish: $authenticated unpublish: $authenticated proxy: npmjs '**': # allow all users (including non-authenticated users) to read and # publish all packages # # you can specify usernames/groupnames (depending on your auth plugin) # and three keywords: "$all", "$anonymous", "$authenticated" access: $all # allow all known users to publish/publish packages # (anyone can register by default, remember?) publish: $authenticated unpublish: $authenticated # if package is not available locally, proxy requests to 'npmjs' registry proxy: npmjs server: # deprecated keepAliveTimeout: 60 middlewares: audit: enabled: true # log settings logs: # Logger as STDOUT { type: stdout, format: pretty, level: warn } # This affect the web and api (not developed yet) i18n: web: en-US ================================================ FILE: blocks/embed/block.json ================================================ { "name": "web-stories/embed", "title": "Web Stories", "description": "Embed Web Stories.", "category": "embed", "keywords": ["embed", "web stories", "story", "stories"], "textdomain": "web-stories", "usesContext": ["postId", "postType", "queryId"], "attributes": { "blockType": { "type": "string" }, "url": { "type": "string" }, "title": { "type": "string" }, "poster": { "type": "string" }, "width": { "type": "number", "default": 360 }, "height": { "type": "number", "default": 600 }, "align": { "type": "string", "default": "none" }, "stories": { "type": "array", "default": [] }, "viewType": { "type": "string", "default": "" }, "numOfStories": { "type": "number", "default": 5 }, "numOfColumns": { "type": "number", "default": 2 }, "circleSize": { "type": "number", "default": 96 }, "imageAlignment": { "type": "string", "default": "left" }, "order": { "type": "string", "default": "" }, "orderby": { "type": "string", "default": "" }, "archiveLinkLabel": { "type": "string", "default": "" }, "authors": { "type": "array", "default": [] }, "fieldState": { "type": "object", "default": {} }, "taxQuery": { "type": "object", "default": {} }, "previewOnly": { "type": "boolean", "default": false } }, "supports": { "align": ["wide", "full", "left", "right", "center"], "interactivity": true }, "viewScriptModule": "file:../../assets/js/web-stories-block-view.js" } ================================================ FILE: codecov.yml ================================================ # Overall settings for PR integration via codecov.io # See https://docs.codecov.io/docs/codecovyml-reference # Separate PR statuses for project-level and patch-level coverage # See https://docs.codecov.io/docs/commit-status coverage: status: # Project-level coverage project: default: base: auto # Disable once code base is more mature. informational: true only_pulls: true target: auto threshold: 1% php: paths: - includes dashboard: paths: - packages/dashboard/src dashboard.unit: paths: - packages/dashboard/src flags: - unittests dashboard.karma: paths: - packages/dashboard/src flags: - karmatests editor: paths: - packages/story-editor/src editor.unit: paths: - packages/story-editor/src flags: - unittests editor.karma: paths: - packages/story-editor/src flags: - karmatests # Patch-level coverage (how well is the PR tested) patch: default: base: auto # Disable once code base is more mature. informational: true only_pulls: true target: auto threshold: 50% # Pull request comments # See https://docs.codecov.io/docs/pull-request-comments comment: false # See https://docs.codecov.io/docs/ignoring-paths ignore: - bin - tests - .phpstorm.meta.php - scoper.inc.php - web-stories.php - uninstall.php - includes/namespace.php - includes/compat - includes/polyfills - includes/templates - includes/AMP/Integration/AMP_Story_Sanitizer.php - includes/REST_API/Stories_Settings_Controller.php - includes/REST_API/Stories_Users_Controller.php - packages/e2e-tests - packages/e2e-test-utils - packages/eslint-import-resolver - packages/fonts/scripts - packages/jest-amp - packages/jest-puppeteer-amp - packages/jest-resolver - packages/karma-failed-tests-reporter - packages/karma-fixture - packages/karma-puppeteer-client - packages/karma-puppeteer-launcher - packages/migration/scripts - packages/templates/scripts - packages/test-utils - '**/storybookUtils' - '**/testUtils' ================================================ FILE: composer.json ================================================ { "name": "googleforcreators/web-stories-wp", "description": "Visual storytelling for WordPress.", "license": "Apache-2.0", "type": "wordpress-plugin", "homepage": "https://github.com/googleforcreators/web-stories-wp", "require": { "php": "^7.4 || ^8.0", "ext-dom": "*", "ext-iconv": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ampproject/amp-toolbox": "*", "ampproject/amp-wp": "dev-develop", "enshrined/svg-sanitize": "^0.22.0", "mcaskill/composer-exclude-files": "^4.0", "symfony/polyfill-mbstring": "^1.18" }, "require-dev": { "automattic/vipwpcs": "^3.0.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "ergebnis/composer-normalize": "^2.28", "humbug/php-scoper": "^0.17.5", "php-stubs/woocommerce-stubs": "^10.0", "php-stubs/wordpress-tests-stubs": "^6.1.1", "phpcompatibility/phpcompatibility-wp": "^2.1", "phpmd/phpmd": "^2.9", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^2.0.1", "phpstan/phpstan-phpunit": "^2.0.1", "roave/security-advisories": "dev-latest", "sirbrillig/phpcs-variable-analysis": "^2.8", "slevomat/coding-standard": "^8.0.0", "sniccowp/php-scoper-wordpress-excludes": "^6.0", "swissspidy/phpstan-no-private": "^1.0.0", "szepeviktor/phpstan-wordpress": "^2.0.0", "wp-coding-standards/wpcs": "^3.0.0", "yoast/wp-test-utils": "^1.0.0" }, "suggest": { "ext-curl": "Used for modifying cURL requests in CORS proxy" }, "repositories": [ { "type": "vcs", "url": "https://github.com/ampproject/amp-wp", "no-api": true } ], "prefer-stable": true, "autoload": { "psr-4": { "AmpProject\\": "vendor/ampproject/amp-wp/lib/common/src", "AmpProject\\Optimizer\\": "vendor/ampproject/amp-wp/lib/optimizer/src", "Google\\Web_Stories\\": "includes" } }, "autoload-dev": { "psr-4": { "Google\\Web_Stories\\PHPStan\\": "tests/phpstan/src", "Google\\Web_Stories\\Tests\\Integration\\": "tests/phpunit/integration/includes", "Google\\Web_Stories\\Tests\\Shared\\": "tests/phpunit/shared", "Google\\Web_Stories\\Tests\\Unit\\": "tests/phpunit/unit/includes" } }, "config": { "allow-plugins": { "civicrm/composer-downloads-plugin": true, "composer/installers": true, "cweagans/composer-patches": true, "dealerdirect/phpcodesniffer-composer-installer": true, "ergebnis/composer-normalize": true, "mcaskill/composer-exclude-files": true, "phpstan/extension-installer": true }, "discard-changes": true, "platform": { "php": "7.4" }, "sort-packages": true }, "extra": { "enable-patching": true, "exclude-from-files": [ "ampproject/amp-wp/includes/bootstrap.php" ], "installer-disable": true, "patches": { "humbug/php-scoper": { "Fix using null as array key": "./patches/humbug-php-scoper-php85.diff" }, "thecodingmachine/safe": { "Fix explicit nullable errors": "./patches/thecodingmachine-safe-nullable.diff" } } }, "scripts": { "post-install-cmd": [ "@prefix-dependencies" ], "post-update-cmd": [ "@prefix-dependencies" ], "phpcbf": "phpcbf --severity=1", "phpcs": "phpcs --severity=1", "phpmd": "phpmd . text phpmd.xml", "phpstan": "phpstan analyse --memory-limit=2048M", "prefix-dependencies": [ "php-scoper add-prefix --output-dir=./third-party --force --quiet", "echo '{ \"autoload\": { \"classmap\": [\"\"] } }' > ./third-party/composer.json", "@composer dump-autoload --working-dir ./third-party --no-dev --classmap-authoritative", "sed -i'.bak' -e 's/Composer\\\\Autoload/Google_Web_Stories_Composer\\\\Autoload/' third-party/vendor/composer/*.php && rm -rf third-party/vendor/composer/*.php.bak", "echo '{ \"autoload\": { \"classmap\": [\"\"], \"files\": [\"polyfills/mbstring.php\"] } }' > ./includes/composer.json", "@composer dump-autoload --working-dir ./includes --no-dev --classmap-authoritative", "sed -i'.bak' -e 's/Composer\\\\Autoload/Google_Web_Stories_Composer\\\\Autoload/' includes/vendor/composer/*.php && rm -rf includes/vendor/composer/*.php.bak" ] } } ================================================ FILE: docs/README.md ================================================ # Technical Documentation ## Introduction * [Getting Started](./getting-started.md) * [Glossary](./glossary.md) ## Contributing * [Onboarding](./onboarding.md) ## Maintenance * [CDN Assets](./cdn.md) ## Development * [Accessibility Guidelines](./accessibility-guidelines.md) * [Architecture](./architecture.md) * [Browser and device support](./browser-support.md) * [Code Style](./code-style.md) * [Environment Variables](./environment-variables.md) * [Migrations](./migrations.md) * [Development Tools](./devtools.md) * [Feature Flags](./feature-flags.md) * [Design Docs](./design-docs.md) * [External Template Creation](./external-template-creation.md) * [Page Templates](./page-templates.md) ## Testing * [Manual Testing (QA)](./testing-qa.md) * [Unit Tests](./unit-tests.md) * [Integration Tests (Karma)](./integration-tests.md) * [End-to-End Tests](./e2e-tests.md) * [Accessibility Testing](./accessibility-testing.md) * [Testing Environments](./testing-environments.md) ## Tooling & Infrastructure * [Local Environment](./local-environment.md) * [Workflows](./workflows.md) ## WordPress Developers * [Embeds](./web-stories-embeds.md) ## Third-Party Integrations ### Dashboard * [Getting Started](./third-party-integration/dashboard/getting-started.md) * [API Callbacks](./third-party-integration/dashboard/api-callbacks.md) * [Integration Layer](./third-party-integration/dashboard/integration-layer.md) * [Tutorial](./third-party-integration/dashboard/tutorial.md) ### Story Editor * [Getting Started](./third-party-integration/story-editor/getting-started.md) * [API Callbacks](./third-party-integration/story-editor/api-callbacks.md) * [Integration Layer](./third-party-integration/story-editor/integration-layer.md) * [Tutorial](./third-party-integration/story-editor/tutorial.md) ================================================ FILE: docs/accessibility-guidelines.md ================================================ # Accessibility Guidelines This project follows web accessibility practices and patterns outlined by [WAI-ARIA](https://www.w3.org/WAI/standards-guidelines/aria/) to make the application more accessible to people with disabilities or people using assistive technologies. Please submit a ticket if any discrepancies are seen that will be detrimental the accessibility of this application. ## Special Concerns ### Dynamically changing content with Javascript When a portion of a page is updated with JavaScript, the update is usually highlighted with animation and bright colors, and is easy to see. But if you don’t have the ability to see the screen, you don’t know this has happened, unless the updated region is marked as an [ARIA live region](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions). The `useLiveRegion` hook is a simple tool that creates and appends an ARIA live notifications area to the element where developers can dispatch text messages. Assistive technologies will automatically announce any text change in this area. This ARIA live region has an ARIA role of “status” so it has an implicit aria-live value of polite and an implicit aria-atomic value of true. **Note**: It’s also possible that all the region content will be announced after an update, if the ARIA live region is too large. Please only provide users with just a simple, concise message. ### Testing Concerns Please visit the [accessibility testing](./accessibility-testing.md) documentation for more information. ================================================ FILE: docs/accessibility-testing.md ================================================ # Accessibility Testing ## Manual Testing ### Resources * [Introduction to Keyboard Navigation](https://rianrietveld.com/2016/05/keyboard/) ### Screen Reader **macOS**: VoiceOver (built-in) **Windows**: [NVDA](https://www.nvaccess.org/about-nvda/) (free) **ChromeOS**: ChromeVox **Browser**: [ChromeVox Classic](https://chrome.google.com/webstore/detail/chromevox-classic-extensi/kgejglhpjiefppelpmljglcjbhoiplfn?hl=en) (no longer supported, so use with care, and perhaps only in combination with NVDA/VoiceOver ### VoiceOver On a MacBook, press CMDF5 to enable VoiceOver. You can configure VoiceOver using the built-in VoiceOver Utility. Navigation: Control + Option is the VoiceOver key. Key | Behavior -- | -- VoiceOver key + left/right arrow | move around the page VoiceOver key + command + H | skip to headings VoiceOver key + command + J | skip to the next control Control | stop verbalization VoiceOver key + H > H | display a full list of shortcuts VoiceOver key + U | open rotor (which allows you to customize navigation and focus on browser content inside Chrome) VoiceOver key + Space | interact with an element Note: make sure to [enable full keyboard access](http://www.weba11y.com/blog/2014/07/07/keyboard-navigation-in-mac-browsers/) in the macOS system prefs. ### NVDA To configure NVDA, press Insert + N or Caps Lock + N and navigate to Preferences. Key | Behavior -- | -- NVDA key + Arrow | move around the page NVDA key + H | skip to headings NVDA key + B | skip to buttons Control | stop verbalization More shortcuts can be found on [WebAIM.org](https://webaim.org/resources/shortcuts/nvda). ### General Info #### Interaction Modes There are two different interaction modes that a screen reader can be navigating in: forms mode and application mode. If keys suddenly stop doing what you expect them to do, you may have unintentionally triggered a mode switch. You likely should restart your screen reader. See [Understanding screen reader interaction modes](https://tink.uk/understanding-screen-reader-interaction-modes/) for more information. #### Accessibility Tree The browser converts the DOM tree into an accessibility tree, which is what screen readers interact with. In Chrome, you can view the accessibility tree for easier debugging by going to `chrome://accessibility`. ### Dev Tools Check out the [Chrome Dev Tools Accessibility Reference](https://developers.google.com/web/tools/chrome-devtools/accessibility/reference). There's also an [aXe extension](https://chrome.google.com/webstore/detail/axe/lhdoppojpmngadmnindnejefpokejbdd) that complements the DevTools built-in feature. ## Unit Tests Use the `toHaveNoViolations` matcher provided by `jest-axe` to verify that a component does not have any accessibility issues. Example: ```js function MyAwesomeComponent() { // ... } it('should render with no accessibility issues', async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); }); ``` ## Integration Tests There's also a `toHaveNoViolations` matcher available in the Karma test suite. Example: ```js it('should render with no accessibility issues', async () => { fixture = new Fixture(); await fixture.render(); await expectAsync(fixture.my.custom.element.node).toHaveNoViolations(); }); ``` ================================================ FILE: docs/animations.md ================================================ # Animations Animations currently reside in `packages/animation`. The top level logic is held in `packages/animation/src/components/provider.js`. This package is responsible for taking a serializable array of animations objects and transforming the objects into WAAPI & AMP animations as well as exposing aggregate animation methods: `pause`, `play`, etc **Note:** Editing and updating the serializable array of animation objects is considered part of the story editor and held on the page level in the story reducer state. ## Generating an Animation from a Serializable Animation Object Our serializable animation objects currently have this structure: ```js // sample animation object const sampleAnimationObject = { id, // unique identifier for this animation type, // type of animation, `blink`, `bounce` etc. targets, // what elements in the story this animation is targeting //...args, whatever args this type of animation takes ie `ease` | `duration` ease, panDir, // etc }; ``` These objects are managed within the story reducer and passed to the animation provider. The animation provider then generates animations by passing this data to the function `AnimationPart(..)` located in `packages/animation/src/parts/index.js`. `AnimationPart(..)` maps the animations instantiation args to a generator function that will generate keyframes, WAAPI animation targets, AMP animation targets, etc. ### Animation Parts & Animation Effects There are 2 types of animation generators we have in the editor, the first is an `Animation Part`. #### Animation Parts Animation Parts are more atomic animations that animate one style property and are more configurable. Animation parts are located in `packages/animation/src/parts`. Animation parts are more granular and unopinionated than Animation Effects. i.e. the `zoom` animation part can animate an elements scale from one value to any other value. #### Animation Effects The second type of animations we have are `Effects`. Effects are more complex and opinionated. They are located in `packages/animation/src/effects`. Effects can compose multiple Animation Parts and often take more specified arguments as well as altering keyframe values depending on the element they're being applied to. i.e. The Background Pan animation effect takes the current offset & zoom of the background image into consideration so the user can't pan the background image past the borders of the page. #### Utilization of Parts & Effects within the Editor The current story editor only uses `Effects` directly. The effects can be seen in the animation selector in the editor when editing an elements style. #### Deviation in Spec from AMP Story Animation Presets Our effects roughly mirror [amp_story_animations](https://amp.dev/documentation/examples/visual-effects/amp_story_animations/) but slightly deviate from them. The primary deviations in spec come from adding more protection so users don't enter a "bad" state with their animation, as well as supporting the concept of background element animations. AMP Stories don't deliniate between background and foreground elements so this behavior is unique to stories created with story editor. ## Parity Between Editor & Output The generated animations share common keyframes between two formats, AMP & WAAPI ([Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API)). Parity here is essential so generated amp stories don't deviate from what the user is viewing in the editor when editing & adding animations to a story page. The WAAPI animations are used to apply animations to elements within the editor, while the AMP animations are used to apply animations to the generated story. You can view the generated story by clicking `preview` in the editor (next to the `publish` & `save` buttons). ## Integration into the Editor & Output `packages/animation/src/components` contains the components used to animate elements within the editor. The top level logic is contained in the `packages/animation/src/components/provider.js`, which you can see integrated into the story editor at `packages/story-editor/src/components/canvas/displayLayer.js`. `` takes the story elements & animations, interprets them and applies them to elements. Target DOM elements are applied to story element components through animation wrappers. You can see `` applied to display layer elements in the story editor through `packages/story-editor/src/components/canvas/displayElement.js`. You can see `` applied to the generated AMP Story element through `packages/output/src/element.js`. ## Misc Notes & History ### Single Animation Target In early specs for animations, we allowed a single animation to have multiple targets (be applied to multiple elements), since then, we have changed this spec to only allow an animation to have a single target element. This new spec is manually enforced in the editor code between the story reducer and the animation effect chooser. Remnants of this scope change can be seen in the serializable animation objects held in the story reducer state. These animations still take an array of targets even though that array should only ever hold one target while in use in the editor. ### Early Timeline Prototype For the initial animation & template implementation we were supporting very complex animations for the templates. Creating these template animations necessitated an internal timeline tool to compose animation parts manually on each template. Since then, we've simplified animations for users and tried to make them parallel the AMP Story Animation presets If we do introduce an animation timeline to the editor in the future, here's the reference to the PR where we took out the internal timeline tool: [#7067](https://github.com/GoogleForCreators/web-stories-wp/pull/7067) The commit hash right before that PR to remove the tool on main is `9b202aa628c6e44`. If you get a local version of that commit hash running, you can view the internal animation timeline tool by opening up the dashboard and navigating to `/story-anim-tool` in the url bar. ================================================ FILE: docs/architecture.md ================================================ # Architecture ## Design Principles The story editor’s architecture follows the following design principles (amongst other more generic ones): **Whitelabel**: The editor is written from scratch as a modern React codebase, with only thin layers (usually REST endpoints) connecting it to the underlying CMS. This careful design makes it possible to quickly port the editor to subsequent CMS’, beyond WordPress. **Touch-friendly**: The codebase does not discriminate when it comes to mouse or touch, and uses universal pointer events, touch-friendly tap targets and alternatives for hover UI to work across touch screens, touch pads and mouse. **Responsive**: The editor does not expect to run full screen, and can adapt to small and very large screen sizes, from iPad to 6K monitors. **Extensible**: The editor ships with the minimum viable set of media controls, document-level settings, analytics and so on. We’ll produce various integration points to extend the functionality of the editor (media, SEO, …). **Accessible**: Most WYSIWYG editors are not keyboard or screen-reader friendly, and many interactions are not defined in web accessibility specs. This doesn’t stop us from making it a clear goal to provide an excellent experience to the broadest user base possible. **Smooth**: Performance is not a recommendation, but a hard requirement. All user actions must have a visual response in less than 100ms, and continuous animation (like scrolling etc.) needs to update at 60fps generally, and 30 fps as fallback (no in-between). ## Browser Support See [detailed docs](./browser-support.md) on browser and device support. ## Editor * [Canvas Layering](./canvas.md) ================================================ FILE: docs/browser-support.md ================================================ # Browser and device support We support roughly the same set of browsers as the AMP Project to align with the AMP story runtime, with additional form factor limitations. In practice, this means: * The latest **two versions of all major browsers** (currently Chrome, Firefox, Edge, Safari and Opera) * We support **desktop & tablet versions** of these respective browsers * We accept fixes for all browsers with market share greater than 1 percent **Internet Explorer** is explicitly **not** supported. ================================================ FILE: docs/canvas.md ================================================ # Canvas ## Layers ![Canvas Layers](https://user-images.githubusercontent.com/726049/72654503-bfffe780-3944-11ea-912c-fc54d68b6100.png) ================================================ FILE: docs/cdn.md ================================================ # CDN Large assets to be used by the plugin are hosted on the [wp.stories.google](https://wp.stories.google) site instead of being bundled with the plugin. This includes, but is not limited to, assets for these areas: * Plugin activation message * Get Started story (FTUE) * Templates * Help Center (FTUE) Assets are versioned. Whenever new assets have been added, or existing assets modified, the version will be incremented upon the next release. ## Git LFS To keep repository size reasonable, CDN assets are stored with [Git Large File Storage (LFS)](https://docs.github.com/en/github/managing-large-files/about-git-large-file-storage). In order to be able to add new assets, you have to [install Git LFS](https://docs.github.com/en/github/managing-large-files/installing-git-large-file-storage) on your machine. You can download it from [git-lfs.github.com](https://git-lfs.github.com/) (or use `brew install git-lfs` if you're on Mac). Verify that the installation was successful: ```bash $ git lfs install > Git LFS initialized. ``` **Note:** If the above command prints warning regarding pre-existing Git hooks, run `git lfs update --manual` for instructions on how to merge hooks. ## Adding Assets **Important:** Make sure Git LFS is installed! First, add the new assets to the CDN by following these steps: 1. Clone the [GoogleForCreators/wp.stories.google](https://github.com/GoogleForCreators/wp.stories.google) repo. 2. Modify/add assets as desired in the `public/static/main` folder. 3. Create a new pull request with these changes. ## Usage in the plugin By default, the plugin will load assets from the `public/static/main` folder. Only production builds released on WordPress.org will reference the versioned folder, e.g. `puiblic/static/123`. ## Plugin Release Upon release, the `public/static/main` folder is compared against the latest version. If there are changes in `main`, the folder is copied to `public/static/`. ================================================ FILE: docs/checklist.md ================================================ # Checklist The Prepublish Checklist to help users make the best Stories possible. ## Logic - On an empty story none of the checklist categories are visible - When a story has more than one page the design and accessibility sections are visible - When a story has more than 4 pages the high priority section is also available. - If a user attempts to publish a story that's less than 5 pages they'll get a prompt to review the checklist first. That exposes all of the checklist sections. ## Structure - All checks are nested in checklist/checks, the logic and requirements for each check are stored here. They render their own component based on the check and that component is given to the checklist category to render making the list self contained. ## Adding a new check - Follow patterning in `checklist/checks`. The logic for the check should be kept internal to the check itself. - Give it a unique name when hooking into `useRegisterCheck`, this is what tracks issue counts. - Tests for checks are stored in `checklist/checks/test`. ## Context Comes in three sections. You need varying degrees of access depending on where you are in the app. - CheckpointProvider: Wraps around Layout so that the publish button also has access to the current checkpoint. If requirements are met then a dialog appears prompting the user to review the checklist before publishing. - ChecklistProvider: Wraps around the carousel so that the toggle buttons in the secondary menu know when the checklist is open and the other popup menus there (help center) can respect it. - ChecklistCountProvider, ChecklistCategoryProvider: Wrap around various parts of the checklist itself to provide counts of issues. ## Roles Depending on the user's `role`, the user may or may not be able to fix specific checklist items. A checklist card will be hidden if the user is unable to fix the issue. This is outlined in the table below: |Story issue/card|Role(s) necessary to resolve the issue| |--|--| |Story has no poster image attached|`hasUploadMediaAction`| |Increase poster image's size|`hasUploadMediaAction`| |Increase poster images's aspect ratio|`hasUploadMediaAction`| |Increase publisher logo size|`hasUploadMediaAction`| |Video missing poster image|`hasUploadMediaAction`| |Video not optimized|`hasUploadMediaAction`| |Video element resolution too low|`hasUploadMediaAction`| |Media element resolution too low|`hasUploadMediaAction`| |Video element missing captions|`hasUploadMediaAction`| ## Future Improvements - Now that the checklist has self contained checks there's room to improve perf on one off situations. - Add role based checks ================================================ FILE: docs/code-style.md ================================================ # Code Style ## Formatting Linting is done through [ESLint](https://eslint.org/), code formatting is handled by [Prettier](https://prettier.io/), but integrated with ESLint. ## Organizing Components Rule of thumb: small components belong to `index.js` file. Larger ones should be split up and moved in a folder, where `index.js` exports the public parts of it. ## Naming Conventions * Use `camelCase` for file names * Use `PascalCase` for component names (components should always be named!) ================================================ FILE: docs/design-docs.md ================================================ # Design docs Most of these docs are in Google Docs for ease of modification and commenting. * [Story Editor e2e Testing Ideas](https://docs.google.com/document/d/1kKu8QRPYNtLjhc3botd4sEIwPnUSemLXFyNPIw8Pf50/edit#) ================================================ FILE: docs/design-panel-push-update-flow.md ================================================ # Design Panel Element Update Data Flow Below is a rough outline of the data flow following input change updates in the design panel to how they update the element in the top level story provider. Most of this code can be found in `packages/story-editor/src/components/style/designPanel.js` : ```mermaid flowchart TB subgraph story_reducer[story reducer] direction LR subgraph ACTION direction TB L[UPDATE_ELEMENTS] --> M[Story Updated] end end subgraph design_panels[StylePanels] subgraph design_panel_state[State] direction TB design_panel_state_el_updates((elementUpdates Array)) design_panel_state_el_handlers((presubmitHandlers Array)) end subgraph design_panel_callbacks[Callbacks] direction LR subgraph submit_handler[submit handler] subgraph SUBMIT [ ] direction TB E[onSubmit] --> F[[internalSubmit]] F --> G[[apply presubmit element mutations on all staged elementUpdates]] G --> H[[onSetProperties]] H --> I[updateElementsById] end end subgraph submit_event[submit event] subgraph DOM EVENT FIRED [ ] direction TB K{Dispatch Event - 'submit'} end end subgraph push_update[pushUpdate called] subgraph INPUT ONCHANGE[ ] direction TB A[pushUpdate or pushUpdateForObject] --> |update, submit| B[[setElementUpdates - Add update to update queue]] B -->|submit == false| C[elementUpdates - locally stored updates] B --> |submit == true| D[submit] D --> J[[setTimeout to dispatch 'submit' event]] end end end end push_update .-> submit_event submit_event .-> submit_handler design_panels .-> story_reducer ``` ## Current Usage Most of the time, we are passing `submit` as `true` for the `pushUpdate(update, submit)` or `pushUpdateForObject(...)` callback args. The current setup is mainly used so the presubmit hooks can alter an update to an element before sending the element updates to the story reducer. Most precommit hooks do things like clamp a value within a certain range, or resize the text element depending on updates to text properties. ================================================ FILE: docs/design-system.md ================================================ # Design System ## Overview Web Stories exists as two apps right now, the editor and the dashboard. Both have their own set of components that they are built off of. Now that we have a robust design system [in Figma](https://www.figma.com/file/bMhG3KyrJF8vIAODgmbeqT/Design-System?node-id=1906%3A0), we won't need to duplicate foundational and base components by having separate copies in the editor and dashboard. The idea here is to make this transition as painless as possible, no massive structural changes. This is a consolidation of components and themes. ## What goes in the design system? The Design System is **only** for shared basics and components. In [atomic design terms](https://bradfrost.com/blog/post/atomic-web-design) we are sharing atoms, molecules and (some) organisms.  If it is a small pattern that’s repeated in a more complicated structure (like a menu that’s used in a dropDown, a search component and a popover menu) then that structure should exist in the design system. If it’s shared across both the editor and the dashboard, it should exist in the design system - like a button or a dialog. If it’s something complex only used by the editor, say the color picker, which uses a slider and some other smaller components in the design system, the color picker would live in the editor because it is not shared by the dashboard. Things that are not easily generalized should not live in the design system. ## Theme The design system contains a full new color palette and theme. The structure is changing a little bit to try and keep things more organized. You can dig into the app [packages/design-system/src/theme](../packages/design-system/src/index.js) to see organization. Here’s brief overview of the organization to contextualize what you’re looking at. 1. Colors, Typography, Borders, Breakpoints are in separate files and all added to the [theme index](../packages/design-system/src/theme/index.js) that can be ingested at the root of each app. This file separation makes it easier to find theme values for developers who prefer to look in source code for the value they need. 2. The new theme provides a dark and a light variant. Dark is the default. All colors are available regardless of variant, but the `theme.colors.bg` and `theme.colors.fg` are what you should gravitate towards using because the light and dark theme colors mirror each other. This will make dark and light mode functionality easier to handle down the road. 3. Subdirectory called [helpers](../packages/design-system/src/theme/helpers/index.js) - these are styles meant to be quick references for one off components in the editor and dashboard that aren’t in the design system. They’re also used in the design system for continuity. 4. Subdirectory called [global](../packages/design-system/src/theme/global/index.js) - these are exported as `ThemeGlobals` and should be added to the app root to handle global styles for both the dashboard and editor. 5. Subdirectory called [constants](../packages/design-system/src/theme/constants/index.js) - these are constants that are used in the dashboard, the editor and across the design system so that we can have a single source of truth. You can import them via `THEME _CONSTANTS`. These include things like static widths of wordPress menus that all projects reference. It’s important to note that this is a living resource - we fully anticipate the need to add to the theme as we begin to use it more robustly. The design system is being created iteratively as we go through implementing new designs. If there’s a new need for a helper, add it! If there’s a color lacking, add it in following the existing patterns. Our goal here was to create a base that we could easily keep updated to avoid needless duplication. ## Components The hope here is to make the transition to using the components in the design system straightforward and easy. If a component in the design system already existed in the editor or dashboard, that source code was used as a reference for the new one in almost all cases. Props should be the same or similar for components that were based off of existing code. ### Differences from the editor - Icon buttons are now a button type where you give the icon to the button instead of specific button components. - Button refs are readily available - Typography is available in molecules - there’s `Display`, `Headline`, `Text`, and `Link` that match the figma specs for type and can take in a styled component `as` of `forwardedAs` to keep HTML semantic but avoid needlessly styling each text element. If you do need to do that there’s a `themeHelpers.expandPresetStyles` or `themehelpers.expandTextPreset` you can look to. ### How to add components to the design system There are going to be things missing from the design system. Designs have been in flux and we’ve been adding things as we need them in the redesign. That said, feel free to add missing components. Components live in [design-system/components](../packages/design-system/src/components/index.js). This directory’s pretty flat so that things are easy to find at 1 level.  The general rule here, as with any shared design system, is that we keep things generic and non-opinionated as much as possible so that the usability remains flexible. All components have storybook demos that allow all props passed to the component to be seen as `controls` as well as dark and light versions so that theme colors can be checked. ### Some caveats Buttons! -The designs have 3 types of buttons: Primary, Secondary, Tertiary. The difference between a secondary button and a tertiary button is the active state. - There’s a 4th type of button in the design system called Plain - this button type has no styles, it’s just a reset button without any outline or background color that can be used when we need something to semantically be a button but the designs disagree. This shouldn’t happen often and should be a bit of an escape hatch. - Button variants in the designs are square and rectangle. The additional variants of circle and icon were added because there are occasions in the editor and dashboard where there are no shared button styles but the element is expected to behave as a button. By having a button variant of icon we can eliminate the need of having specific exportable buttons like `ArrowDown`. - Button types, sizes, and variants (circle, rectangle, square, icon) are all available as [constants](../packages/design-system/src/components/button/constants.js) and importable via `BUTTON_TYPES`, `BUTTON_SIZES`, `BUTTON_VARIANTS` so that we don’t have to worry about miscellaneous strings. - Every button combination has a [demo in storybook](https://googleforcreators.github.io/web-stories-wp/storybook/?path=/story/designsystem-components-button--default). Typography - [design-system/typography](../packages/design-system/src/components/typography/index.js) has the only nested content. This follows the foundation set up in the designs of `Display`, `Headline`, `Label`, `Paragraph`, `Link`. - Each typography type has a set of presets that are stored in the theme and matching constants ([theme/constants/typography](../packages/design-system/src/theme/constants/typography.js)). - The typography components are ``, ``, ``, ``, found in [`components/typography`](../packages/design-system/src/components/typography/index.js). - Each is a styled component and can be passed whatever element you want it to be with `as` or `forwardedAs` depending on your situation. - These are meant to decrease our need to individually style text elements and ensure consistency. - There’s a demo for every [type setting in storybook](https://googleforcreators.github.io/web-stories-wp/storybook/?path=/story/designsystem-components-typography-display--default). Circular dependencies - If you are adding a component and you need to reference anything in the design-system you’ll need to import it from its nearest location. Do not import it from the top level index. - While this works just fine in the app (because you’re outside of the design system), storybook will yell at you because it thinks that imports aren’t loaded in time. - As an example, I’m creating this new `` and I need a `` component. I’d need to `import { Text } from ‘../typography’;` not `../` if I’m in `components/taco`. ## Icons Icons are separated from components and already exported as `ReactComponent` . You can import them via `/theme/icons` or `/theme`. ### How to add icons Icons are all located directly in the `design-system/icons/` folder. A few things to note: - All icons are 32x32px and should never be resized or scaled in any way. - Icons are named after their symbol, not their function. So a ⚙️  icon is named `Gear`, and not `Settings` or some other function, it might serve. - If an icon has several sizes, denote this as a suffix as `GearLarge`, `GearTiny` etc. - If an icon has a border, name it either just as `GearOutline` or as the shape of the border - e.g. `GearTriangle` if it's a gear inside a triangular border. - Try to group similar icons alphabetically, so name icons e.g. `MagnifierPlus` and `MagnifierMinus` rather than `PlusMagnifier` and `MinusMagnifier`. The process for adding an icon is as follows: 1. Export your SVG from Figma by selecting the 32x32px icon boundary box and selecting _"Export"_ in the right panel 2. Add the file to directory [design-system/icons](../packages/design-system/src/icons/index.js) 3. Export it from the index of that directory as a ReactComponent (please alphabetize the list, thanks!) 4. Import from the design-system wherever you want to use it. ================================================ FILE: docs/devtools.md ================================================ # DevTools There is a global shortcut (`Command+Shift+Option+J / Control+Shift+Alt+J`) for the editor DevTools. ## Sharing story data You can use DevTools to import/export the story data and optionally replace all media resources with placeholders. ================================================ FILE: docs/e2e-tests.md ================================================ # End-to-End Tests This project leverages the [local Docker-based environment](./local-environment.md) to facilitate end-to-end (e2e) testing using [Puppeteer](https://github.com/puppeteer/puppeteer). As with the [JavaScript unit tests](./unit-tests.md), Jest is used as the task runner. Tests are written using [`jest-puppeteer`](https://github.com/smooth-code/jest-puppeteer). Tests are written using the [Jest API](https://jestjs.io/docs/en/api) in combination with the [Puppeteer API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md), facilitated by [`jest-puppeteer`](https://github.com/smooth-code/jest-puppeteer). To run the full test suite, you can use the following command: ```bash npm run test:e2e ``` You can also watch for any file changes and only run tests that failed or have been modified: ```bash npm run test:e2e:watch ``` Not using the built-in local environment? Set the `WP_BASE_URL` environment variable to point to your URL of choice. ```bash WP_BASE_URL=https://web-stories.local npm run test:e2e ``` **Note**: All tests run serially in the current process using the `--runInBand` Jest CLI option to avoid conflicts between tests. This is caused by the fact that they share the same WordPress instance. ## Environment Variables Check out the [separate document](./environment-variables.md) about supported environment variables for tweaking the E2E tests. ## Debugging For debugging purposes, you can also run the E2E tests in non-headless mode: ```bash npm run test:e2e:interactive ``` Note that this will also slow down all interactions during tests by 80ms. You can control these values individually too: ```bash PUPPETEER_HEADLESS=false npm run test:e2e # Interactive mode, normal speed. PUPPETEER_SLOWMO=200 npm run test:e2e # Headless mode, slowed down by 200ms. ``` It's also possible to enable node's inspector agent for the tests as follows: ```bash npm run test:e2e:debug ``` Since these tests run both in the node context and the browser context, not all lines of code can have breakpoints set within the inspector client. Only the node context is debugged in the inspector client. The code executed in the node context includes all of the test files excluding code within `page.evaluate` functions. This is because the `page.evaluate` functions are executed within the browser context. To also debug the browser context, set `PUPPETEER_DEVTOOLS=true` and `PUPPETEER_HEADLESS=false` when running the `test:e2e:debug` script. This will launch the browser with the devtools already open. Breakpoints can then be set in the browser context within devtools. ## Common Mistakes * Missing `await` on statements/assertions. * Not waiting for an element to be present in DOM and to be visible before trying to interacting with it (race conditions). ## Writing Tests ### Useful Resources * [`jest-puppeteer`](https://github.com/smooth-code/jest-puppeteer#api) * [`expect-puppeteer`](https://github.com/smooth-code/jest-puppeteer/blob/master/packages/expect-puppeteer/README.md#api) * [Available WordPress-specific test utils](https://www.npmjs.com/package/@wordpress/e2e-test-utils) (e.g. `loginUser`, `visitAdminPage`) ## Test Utilities Sometimes one might want to test additional scenarios that aren't possible in a WordPress installation out of the box. The tests setup allows adding some utility plugins that can be activated during E2E tests. These plugins can be added to `packages/e2e-tests/src/plugins` and then activated via the WordPress admin. Test utilities like `createNewStory()` reside in the `packages/e2e-test-utils/src` folder. ================================================ FILE: docs/environment-variables.md ================================================ # Environment Variables Throughout the project some environment variables are in use: ## Application **DISABLE_PREVENT** (bool): Set this environment variable to disable unwanted `beforeunload` dialogs when reloading or closing the browser. Mostly useful during development. Default: `false` **DISABLE_OPTIMIZED_RENDERING** (bool): Set this environment variable to disable optimized rendering for text sets (it will render the actual components instead of images). Required for text sets images generation script. Default: `false` **DISABLE_QUICK_TIPS** (bool): Set this environment variable to prevent quick tips from automatically opening. Default: `false` ## Bundler **NODE_ENV** (`production`|`development`|`test`): Defines the mode webpack and other tools operate in. Automatically set by tests / build tools, usually no need to override manually. **BUNDLE_ANALYZER** (bool): Enables the `webpack-bundle-analyzer` plugin to analyze the final bundle composition. Default: `false` **BABEL_CACHE_DIRECTORY** (string): Babel uses a directory within local node_modules by default. Use the environment variable option to enable more persistent caching. ## E2E Tests **PUPPETEER_TIMEOUT** (number): Set the default Jest timeout for Puppeteer test. It is already increased because these tests are a bit slow due to the browser overhead. Default: `100000` (ms) **EXPECT_PUPPETEER_TIMEOUT** (number): Set default timeout for individual expect-puppeteer assertions. Default: `500` (ms) **WP_BASE_URL** (string): Point the E2E tests to a different URL, i.e. when not using the built-in Docker environment. Default: `http://localhost:8899`. **WP_USERNAME** (string): Username to use for logging in during E2E tests. Default: `admin` **WP_PASSWORD** (string): Password to use for logging in during E2E tests. Default: `password` **PUPPETEER_PRODUCT** (string): Either `chrome` or `firefox`. Default: `chrome` **PUPPETEER_HEADLESS** (bool): Whether to run Puppeteer in headless mode or not. Default: `true` **PUPPETEER_SLOWMO** (number): Slow down all interactions by a certain time for easier debugging. Useful in combination with `PUPPETEER_HEADLESS`. Default: `0` (ms) **PUPPETEER_DEVTOOLS** (bool): Whether to open dev tools during tests. Default: `false` ## Local Environment **PHP_VERSION** (string): PHP version which local environment runs in. Default: `8.2` ================================================ FILE: docs/external-template-creation.md ================================================ # Creating a new template ## Where things are stored - The **story JSON representation** for each template is stored in [`packages/templates/src/raw/`](https://github.com/googleforcreators/web-stories-wp/tree/main/packages/templates/src/raw) (in the `main` branch). - The **SVGs** used in each template are stored in [`packages/stickers/src/`](https://github.com/googleforcreators/web-stories-wp/tree/main/packages/stickers/src) (in the `main` branch). - The (non-SVG) **image & video files** used in each template are stored in [`public/static/main/images/templates/`](https://github.com/GoogleForCreators/wp.stories.google/tree/main/public/static/main/images/templates) (in the [GoogleForCreators/wp.stories.google](https://github.com/GoogleForCreators/wp.stories.google) repo, using [Git LFS](https://git-lfs.github.com/)). ## Overview To add a new template to the editor: 1. [Engineer] Commit all SVGs used in the template to the codebase as stickers ([details](#adding-svgs-to-the-codebase-as-stickers)). 2. [Designer] Create a new story in your shared WP environment and replicate the template design. - SVGs should already be available in the Stickers panel from step (1) — click on a sticker to insert it into the story. - Upload (drag & drop) images and videos into the story to add them. 3. [Engineer] Commit all images & videos used in the template to the codebase. - Filenames should follow the existing convention e.g. `travel_page9_bg.jpg`. - Make sure images are not too large — full-width images should be 1080p, large images should be 720p, and small images should be 480p. See [#6485](https://github.com/googleforcreators/web-stories-wp/pull/6485) for an example. - Make sure videos are 720p. - Make sure caption files are also committed along with the videos, when available. 4. [Engineer] Get the story JSON from your shared WP environment, modify its image & video URLs, and integrate it into the codebase (see [details](#get-the-story-json)). 5. [Both] Verify that new template shows up in the template library and looks as expected. ## Pitfalls 1. Don't upload SVGs as images. SVGs should only be added as stickers in step (1). 2. Avoid "`-`" suffixes on images and videos e.g. `some_image-1.png`. See [details below](#filenames-for-images-and-videos). 3. Don't add animations to the first page. First page animations are not supported by AMP. [More details](https://wp.stories.google/docs/how-to/animations/#First-Page) ## Detailed steps ### Adding SVGs to the codebase as stickers To add new stickers to the codebase, obtain the raw svg file, and view it in your code editor. Paste the contents of the raw svg file into a react component in `packages/stickers/src//.js`. Remove extraneous attributes on the base `svg` component and make sure your component takes a `style` property that it applies to the base svg element. Also make sure to remove any explicit `height` and `width` attributes and see that they only are applied to the viewbox. Lastly, be sure to add a title for accessibility. By the end, your component should look something like this: ```js const title = __('Some Descriptor', 'web-stories'); function MySticker({ style }) { return ( {title} {/*Contents*/} ); } ``` At the bottom of the file, make sure there's one default export that exports the svg component and the aspect ratio based off the viewbox as well as the human readable title: ```js export default { aspectRatio: width / height, svg: MySticker, title, }; ``` Create a new `index.js` file under your `` directory and add your new sticker to the export object: ```js //... export { default as mySticker } from './mySticker'; ``` The last step of this process is navigating to `packages/stickers/src/index.js` and adding your new stickers to the default export object: ```js //... import * as myStickers from './'; export default { //... ...myStickers, }; ``` Once you've completed this step, your new sticker should now appear with the other stickers appended to the bottom of the shapes panel. ### Inserting stickers (SVGs) into a story To add stickers to your template story, go to the `Experiments` page (in the left-hand sidebar under the `Stories` section) and check the `Enable Stickers` checkbox. Once turned on, all available stickers in the codebase should be available for selection under our base shapes in the shapes library panel. ### Get the story JSON To get the JSON representation of a story in the editor: 1. In the editor, open the story. 2. Press `Command+Shift+Option+J` (Mac) or `Control+Shift+Alt+J` (Windows/Linux) in the editor. 3. A dialog will appear where you can copy/paste story JSON. 4. Check the `Template` checkbox that is present at the top of the dialog. Screenshot of the dev tools with the Templates checkbox #### Alternative Another way to get the story JSON is by inspecting the network request that saves the story to the backend. 1. In the editor, open the story. 2. Open `Chrome DevTools > Network`. 3. In the editor, click the "Save draft" button. 4. You should see a POST XHR with JSON in the request payload. In that payload JSON, find and right-click the `story_data` field and click `Copy value`. 5. The story JSON should now be copied to your clipboard and ready to paste into a new file. ### Adding story JSON to the codebase as a new template Once you have the story JSON, several code changes are needed to add it to the list of default templates in the editor. 1. In [`packages/templates/src/raw/`](https://github.com/googleforcreators/web-stories-wp/tree/main/packages/templates/src/raw), create a new directory `` for your template. Now add your template's story JSON in a new file e.g. `/template.json`. - Make following changes to the template JSON, - Reset following extraneous properties, - `current: null` - `selection: []` - `story: {}` - First change all image & video URLs to use `__WEB_STORIES_TEMPLATE_BASE_URL__` as the base, which then will be replaced by the CDN url. - Ensure to also change poster image URLs to use `__WEB_STORIES_TEMPLATE_BASE_URL__`. - Change `posterId` and `id` for all elements of type image and video to `0`, these are the WP media ids that are not used in templates. - Make sure that the images and videos have appropriate title and alt text set for better accessibility. - If the story JSON is copied from the devTools dialog as mentioned in [Get The Story JSON](#get-the-story-json), the JSON will have some of the changes already present. - The 'Template' checkbox does following: - Resets extraneous properties. - Changes resource URLs to use replaceable CDN constant - Resets `sizes` property for images to `[]`. - Resets all `id` and `posterId` to 0 for image and video type resources. NOTE: Check all resource URLs and properties are set properly before committing the template. 2. Create a new file `metaData.js` in your newly created `` directory. Your `/metaData.js` file would then look something like this with object corresponding to the new template and properties `id`, `title`, `tags`, `colors`, `creationDate`, etc. ```javascript //... /** * External dependencies */ import { __, _x } from '@googleforcreators/i18n'; export default { slug: 'template-name', creationDate: '2021-07-12T00:00:00.000Z', title: _x('Your Template Title', 'template name', 'web-stories'), tags: [ _x('Tags', 'template keyword', 'web-stories'), ], // Array of color objects with name and hex values. colors: [ { label: _x('Blue', 'color', 'web-stories'), color: '#1f2a2e' }, ], description: __( 'A short text describing your story template.', 'web-stories' ), vertical: _x('Vertical name', 'template vertical', 'web-stories'), }; ``` 3. Create a new file `index.js` in your newly created `` directory and import the `template.json` file and `metaData.js` file. Your `/index.js` file would then look something like this: ```javascript //... /** * Internal dependencies */ import { default as template } from './template'; import { default as metaData } from './metaData'; export default { ...metaData, ...template, }; ``` 4. In [`packages/templates/src/getTemplates.ts`](https://github.com/googleforcreators/web-stories-wp/blob/main/packages/templates/src/getTemplates.ts), add `""` to the string array in the `getTemplates()` function. 5. Verify in your WP environment that the new template is visible in the editor's "Explore Templates" section. 6. Create a single pull request with all of the changes in steps 1-3. ### Adding SVGs to the codebase as shapes First and foremost, shapes aren't complete svgs, just a single normalized path (coordinates should be defined in a 0->1 space). To accommodate for this, there are a few formatting steps that must be taken in an svg editing software (like illustrator), and a few steps within the codebase to get a proper working path in the editor. #### Getting your svg to be a single path Since we must have a single path, we can't utilize any other aspects of the svg spec. This means all shapes must be converted to a single path in the svg editing software of your choosing. For the purposes here, we will outline the steps in illustrator because it's what we've used thus far, but these svg operations should be applicable to other programs as well. - Open svg file in illustrator - Select shape - (If there's any stroke) Click on `Object -> Path -> Outline Stroke` - Make sure your shape is selected and click `Object -> Compound Path -> Make` - Open your code editor to any scratch pad svg file (will just be using this file for copy and pasting, won't save it at all) - Select svg in illustrator and copy - Paste into your code editor In your editor, you should see something like this: ```svg ``` If your svg markup consists of anything other than a single path, you must go back to your svg editing application and keep playing with it until you can copy the svg into your code editor and it only consists of a single path. In the path's `d` attribute you'll see a mix of numbers and letters. The letters are draw commands and the numbers after it specify coordinate arguments for the draw command. Those coordinates are drawn relative to the coordinate space depicted in the `viewBox` attribute. It's important that the path fits nicely in the viewbox. For Illustrator it creates the viewbox & path coordinates based on the bounding box of all selected elements you're copying onto your clipboard. If you need to alter the path to be drawn relative to a slightly larger viewbox, you can always place a square/rectangle (larger than original selected elements) within the selected elements before copying over to your editor to dictate the size of the viewbox and illustrator will update the path coordinates accordingly. #### Normalizing your svg path All the SVG paths in the shapes panel are declared in a 0->1 coordinate space. In theory you could scale down your SVG in illustrator and it would update the path coordinates accordingly, but we found that Illustrator lacks the level of precision needed for large shapes with small details. This can lead to necessary parts of your path getting rounded off. To accommodate for this, we created a little node script you can run your SVG path through and it will normalize the path with a much higher level of precision that won't round off details in your shape. This script is available via in `npm run workflow:normalize-path`. You'll need to copy the path and viewbox from your scratchpad svg file and pass them to the command like so: You can then run the command like so: ```bash npm run workflow:normalize-path 392 392 "M392,196c0,108.25-87.75,196-196,196S0,304.25,0,196S87.75,0,196,0S392,87.75,392,196z M196,30 c-44.34,0-86.03,17.27-117.38,48.62C47.27,109.97,30,151.66,30,196s17.27,86.03,48.62,117.38C109.97,344.73,151.66,362,196,362 s86.03-17.27,117.38-48.62C344.73,282.03,362,240.34,362,196s-17.27-86.03-48.62-117.38C282.03,47.27,240.34,30,196,30 M196,0 c108.25,0,196,87.75,196,196s-87.75,196-196,196S0,304.25,0,196S87.75,0,196,0L196,0z" ``` It should output a normalized path like this in the console: ```text M 1.000000 , 0.500000 c 0.000000 , 0.276148 -0.223852 , 0.500000 -0.500000 , 0.500000 S 0.000000 , 0.776148 , 0.000000 , 0.500000 S 0.223852 , 0.000000 , 0.500000 , 0.000000 S 1.000000 , 0.223852 , 1.000000 , 0.500000 z M 0.500000 , 0.076531 c -0.113112 , 0.000000 -0.219464 , 0.044056 -0.299439 , 0.124031 C 0.120587 , 0.280536 , 0.076531 , 0.386888 , 0.076531 , 0.500000 s 0.044056 , 0.219464 , 0.124031 , 0.299439 C 0.280536 , 0.879413 , 0.386888 , 0.923469 , 0.500000 , 0.923469 s 0.219464 -0.044056 , 0.299439 -0.124031 C 0.879413 , 0.719464 , 0.923469 , 0.613112 , 0.923469 , 0.500000 s -0.044056 -0.219464 -0.124031 -0.299439 C 0.719464 , 0.120587 , 0.613112 , 0.076531 , 0.500000 , 0.076531 M 0.500000 , 0.000000 c 0.276148 , 0.000000 , 0.500000 , 0.223852 , 0.500000 , 0.500000 s -0.223852 , 0.500000 -0.500000 , 0.500000 S 0.000000 , 0.776148 , 0.000000 , 0.500000 S 0.223852 , 0.000000 , 0.500000 , 0.000000 L 0.500000 , 0.000000 z ``` #### Creating a shape from your normalized path Copy the outputted path from your terminal and navigate to `packages/story-editor/src/masks/constants.js`. In that file create a new key describing your shape in `MaskTypes`. For the shape shown above an apt description would be something like: ```javascript export const MaskTypes = { // ..., [RING]: 'ring', }; ``` Then navigate down to `CLIP_PATHS` and add your normalized path like so: ```javascript const CLIP_PATHS = { // ..., [MaskTypes.RING]: `M 1.000000 , 0.500000 c 0.000000 , 0.276148 -0.223852 , 0.500000 -0.500000 , 0.500000 S 0.000000 , 0.776148 , 0.000000 , 0.500000 S 0.223852 , 0.000000 , 0.500000 , 0.000000 S 1.000000 , 0.223852 , 1.000000 , 0.500000 z M 0.500000 , 0.076531 c -0.113112 , 0.000000 -0.219464 , 0.044056 -0.299439 , 0.124031 C 0.120587 , 0.280536 , 0.076531 , 0.386888 , 0.076531 , 0.500000 s 0.044056 , 0.219464 , 0.124031 , 0.299439 C 0.280536 , 0.879413 , 0.386888 , 0.923469 , 0.500000 , 0.923469 s 0.219464 -0.044056 , 0.299439 -0.124031 C 0.879413 , 0.719464 , 0.923469 , 0.613112 , 0.923469 , 0.500000 s -0.044056 -0.219464 -0.124031 -0.299439 C 0.719464 , 0.120587 , 0.613112 , 0.076531 , 0.500000 , 0.076531 M 0.500000 , 0.000000 c 0.276148 , 0.000000 , 0.500000 , 0.223852 , 0.500000 , 0.500000 s -0.223852 , 0.500000 -0.500000 , 0.500000 S 0.000000 , 0.776148 , 0.000000 , 0.500000 S 0.223852 , 0.000000 , 0.500000 , 0.000000 L 0.500000 , 0.000000 z`, }; ``` Lastly go down to `MASKS` and add an entry for your newly updated mask: ```javascript export const MASKS = [ // ..., { type: MaskTypes.RING, showInLibrary: true, // mark this as true if you would like the shape to be user facing name: _x('Ring', 'shape/mask name', 'web-stories'), path: CLIP_PATHS[MaskTypes.RING], ratio: 392 / 392, // / } ]; ``` That's it! After you recompile your application you should now see your new shape show up in the shapes panel of the editor. ## Appendix ### Filenames for images and videos WordPress has a naming convention where if you upload an image with the same name multiple times, it adds a `-x` suffix. **i.e** if I upload `some_image.png` 3 times to the WordPress media upload in the story editor, it will store those images as: 1. `some_image.png` 2. `some_image-1.png` 3. `some_image-2.png` Because of this, our conversion script will strip the `-x` suffix off of the image source so it can point to the correct image stored in our repo. This is an important note to make when naming images for upload into a story as **a `-x` suffix will always be stripped**. This naming convention is reserved for WordPress and will break your template creation if you use it in your image naming. ================================================ FILE: docs/feature-flags.md ================================================ # Feature flags The React apps leverage the [`flagged`](https://www.npmjs.com/package/flagged) library to allow guarding code and components behind flags. This allows hiding unfinished functionality until it is ready to be shown/enabled in the application. ## Adding a feature flag ### PHP changes In `includes/Experiments.php`, add a new entry like this in the `get_experiments` method: ```php ... /** * Author: @yourGitHubUsername * Issue: #12345 * Creation date: 2020-01-01 */ [ 'name' => 'showEasterEgg', 'label' => __( 'Easter Egg', 'web-stories' ), 'description' => __( 'Show jumping bunnies', 'web-stories' ), 'group' => 'editor', ], ... ``` Notes: * `group` can be either `dashboard`, `editor`, or `general`, if the feature is used in both places. ### Usage in JavaScript You can then use the flag in your code by following the examples in the [flagged documentation](https://www.npmjs.com/package/flagged). ## Enabling feature flags by default At some point, a feature flag will be turned on permanently. To do this, add a `default` field like so: ```php ... /** * Author: @yourGitHubUsername * Issue: #12345 * Creation date: 2020-01-01 */ [ 'name' => 'showEasterEgg', 'label' => __( 'Easter Egg', 'web-stories' ), 'description' => __( 'Show jumping bunnies', 'web-stories' ), 'group' => 'editor', 'default' => true, ], ... ``` Before publishing the next release, all remnants of permanently enabled feature flags shall be removed from the code base. ## Turning on/off feature flags with the Experiments tab Feature flags can be managed via a hidden "Experiements" tab within the Stories WordPress nav. There you can turn on/off all experiments defined in `includes/Experiments.php`. To turn it on, add the following to your `wp-config.php` file. ```php define( 'WEBSTORIES_DEV_MODE', true ); ``` ================================================ FILE: docs/getting-started.md ================================================ # Getting Started ## Requirements To contribute to this plugin, you need the following tools installed on your computer: - [PHP](https://www.php.net/) - version 7.2 or higher, preferably installed via [Homebrew](https://brew.sh/). - [Composer](https://getcomposer.org/) (PHP package manager) - version 2.3 or higher, to install PHP dependencies. - [Node.js](https://nodejs.org/en/) (current LTS) - to install JavaScript dependencies. - [WordPress](https://wordpress.org/download/) - to run the actual plugin. - [Docker Desktop](https://www.docker.com/products/docker-desktop) and [Docker Compose](https://docs.docker.com/compose/install/) - for using the local environment You should be running a Node version matching the [current active LTS release](https://github.com/nodejs/Release#release-schedule) or newer for this plugin to work correctly. You can check your Node.js version by typing `node -v` in the Terminal prompt. If you have an incompatible version of Node in your development environment, you can use [nvm](https://github.com/creationix/nvm) to change node versions on the command line: ```bash nvm install ``` **Note:** Using `nvm` is recommended, as it makes it easier to stay up-to-date with any Node.js version requirement changes in the project. ## Development First, you need to make sure that all PHP and JavaScript dependencies are installed: Install Composer by following [installation instructions](https://getcomposer.org/download/). Make sure to add `composer` to your `$PATH` if it is not already there. The local environment won't run unless `composer` is in your `$PATH`. **WSL users** may also need to install `make`. On WSL Ubuntu you should be able to use: ```bash sudo apt-get install make ``` or ```bash sudo apt-get install build-essential ``` To install all the required composer packages, run: ```bash composer install ``` --- To install all the required npm packages, run: ```bash npm install ``` Whether you use the pre-existing local environment or a custom one, any PHP code changes will be directly visible during development. However, for JavaScript this involves a build process. To watch for any JavaScript file changes and re-build it when needed, you can run the following command: ```bash npm run dev ``` This way you will get a development build of the JavaScript, which makes debugging easier. For watching for any JavaScript file changes and automatically refreshing the app in the browser (React Fast Refresh), use: ```bash npm run serve ``` This makes for a much better developer experience as you don't have to manually refresh your browser tab every time you make changes. To get a production build, run: ```bash npm run build:js ``` ## Local Environment You will need a WordPress environment to run the plugin. Check out the [Local Environment](./local-environment.md) document for details on how to set one up. ================================================ FILE: docs/glossary.md ================================================ # Glossary **Checklist**: The checklist is a tab in the Design Panel that represents another, cleaner way of browsing through all current errors, warnings and suggestions that originate via helper mode or the editor itself. **Dashboard**: The CMS portion of the editor. Includes “Dashboard” dashboard page, Template Gallery, Bookmarks. **Design Panel**: The right-hand sidebar of the editor. The Design Panel is where you modify the properties of the currently selected element or page. **Element**: A type of content that can be added to a page. The most common elements are text, image, video and shape elements. **Element Panel**: The left-hand sidebar that features media, shapes, animations and other elements that can be added to a page. **Helper Mode**: Special assistive mode that when active, offers design suggestions next to the Workspace area to aid the user in producing a beautiful, functional story. **Page (Canvas)**: The main ‘canvas’ that shows the current story page. This is where most of the action happens, and the user designs via drag & drop and other actions. See also: [Canvas Layering](./canvas.md) **Text magic**: Special assistive mode that when active, makes automatic corrections to things involving text, such as text contrast. Its behavior can be modified with the overlay actions that appear when a text element is selected. **Workspace**: The entire dark gray area, which includes page thumbnails, page and title and publishing/saving-related buttons. ================================================ FILE: docs/integration-tests.md ================================================ # Integration Tests Karma + Jasmine are used for running integration tests in the browser. More details can be found in the `/karma` folder and the corresponding `README`s. ## Running Karma Tests To run the full test suite, you can use the following commands: ```bash npm run test:karma:story-editor -- --headless --viewport=1600:1000 npm run test:karma:dashboard -- --headless --viewport=1600:1000 ``` To run Karma once: ```sh npm run test:karma ``` To run Karma in the watch mode: ```sh npm run test:karma:watch ``` To run Karma in the headless mode: ```sh npm run test:karma -- --headless ``` or ```sh npm run test:karma:watch -- --headless ``` ## Custom Matchers There are a few custom matchers inspired by Jest and `jest-dom`: * `toBeEmpty` * `toHaveFocus` * `toHaveStyle` * `toHaveTextContent` * `toHaveProperty` * `toBeOneOf` Plus another custom async matcher inspired by [`jest-axe`](https://github.com/nickcolley/jest-axe) to test for accessibility violations: * `toHaveNoViolations` ## Writing Tests There will be times when test must wait for an async function or process. The `waitFor` util from react test library may be used to wait for a condition to be satisfied before running the next part of the test. When an `expect` or any other function within the `waitFor` block throws an error, this util will wait `50ms` before trying again. The `waitFor` helper needs to receive an error in order for it to re-poll. `waitFor` will continue to re-poll until the timeout limit is reached. By default, the timeout is `1000ms`. `timeout`, `interval` and other `waitFor` options are customizable. [More configuration options](https://testing-library.com/docs/dom-testing-library/api-async/); The `findBy` query method is preferred, when applicable, since `findBy` is a combination of the `getBy` query and the `waitFor` util. `findBy` accepts the `waitFor` options as the last argument. If `findBy` doesn’t fit your use case, throw an error in the body of `waitFor` while waiting for a condition to be met. Examples ```javascript const dialogs = await fixture.screen.findAllByRole('dialog', { timeout: 2000 }); ``` ```javascript await waitFor(() => { if (!node) { throw new Error('this will make it so that the waitFor polls until no error is thrown'); } expect(node).toBeTruthy(); }); ``` ### Useful Resources * [DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro) * [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) * ["Which query should I use?"](https://testing-library.com/docs/guide-which-query) * [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) * [React Hooks Testing Library](https://react-hooks-testing-library.com/) ## Debugging Karma tests To debug a Karma test, you can follow the following steps: 1. Focus the test you want to debug by change it's `it` to `fit`. 2. Add `await karmaPause()` command in a desired place in the test. The test will execute all test steps prior to `karmaPause()` and will block. 3. Run `npm run test:karma:watch`. 4. Once the Karma window is opened, click on the "DEBUG" button in the header. This will open the debug page at [http://localhost:9876/debug.html](http://localhost:9876/debug.html). 5. Open DevTools. A tip: you can also run Karma with the `--devtools` option to open DevTools automatically. 6. A test can be unblocked by executing `karmaResume()` in the DevTools console. A test can be blocked/resumed any number of times using `karmaPause()` and `karmaResume()` during the debugging session. ## Browser support Currently, only Puppeeter option is configured. It can run Chrome and Firefox browsers. In the near future this may also be expanded to Playwright to support Safari and other browsers. For details on how to customize browser options, see [karma-puppeteer-launcher](./karma-puppeteer-launcher/README.md). For details on exposed native browser APIs, see [karma-puppeteer-client](./karma-puppeteer-client/README.md). ## Common issues ### Annoying firewall question: "Do you want to the application Chromium.app to accept incoming network connections?" See [Puppeteer issue 4752](https://github.com/puppeteer/puppeteer/issues/4752). ```sh sudo codesign --force --deep --sign - ./node_modules/puppeteer/.local-chromium/mac-*/chrome-mac/Chromium.app ``` ### Test failure: ResizeObserver loop limit exceeded See [ResizeObserver#observe() firing the callback immediately results in an infinite loop](https://github.com/WICG/resize-observer/issues/38#issuecomment-422126006). If you are creating a new `observe()` event using the `resize-observer-polyfill` package, wrapping the body of the function which is provided to the `ResizeObserver` constructor with `window.requestAnimationFrame` will prevent the `ResizeObserver loop limit exceeded` error. e.g. ```javascript const observer = new ResizeObserver(() => { // requestAnimationFrame prevents the 'ResizeObserver loop limit exceeded' error // https://stackoverflow.com/a/58701523/13078978 window.requestAnimationFrame(() => { // handle resize observation }); }); observer.observe(node); ``` There is also a `design-system` util called `useResizeEffect` which takes a node ref and a handler function which should be sufficient for resize observation requirements. ================================================ FILE: docs/local-environment.md ================================================ # Local Environment Currently, the two most common ways for setting up a local environment for working on the Web Stories plugin are: 1. Using the provided Docker container, which is also used for running the e2e tests. 2. Using [Local](https://localwp.com/) The former can be set up using the command line, the latter provides an easy-to-use GUI. Local also makes it easy to switch PHP & WordPress versions, and supports HTTPS. HTTPS is required for some features like video optimization to work properly. ## Docker Container Since you need a WordPress environment to run the plugin in, the quickest way to get up and running is to use the provided Docker setup. **Windows user?** It is highly recommended to use Windows Subsystem for Linux (WSL) 2 before trying to set up Docker on Windows. Setups without WSL have not been tested and might not work. The following command will automatically verify whether Docker is configured properly and start the local WordPress instance. ```bash npm run env:start ``` The WordPress installation should be available at `http://localhost:8899`. To access the WordPress admin, visit `http://localhost:8899/wp-admin` (**Username**: `admin`, **Password**: `password`). To later turn off the local environment, you can run: ```bash npm run env:stop ``` To bring it back later, run the previous command: ```bash npm run env:start ``` Also, if you need to reset the local environment's database, you can run: ```bash npm run env:reset-site ``` ### Specifying PHP Version It is also possible to run the local environment in different PHP versions, like so: ```bash PHP_VERSION=8.0 npm run env:start ``` ## Custom Environment If you use something like [Local](https://localwp.com/), you can simply clone this repository right into your `wp-content/plugins` directory. ```bash cd /path/to/your/local/site/wp-content/plugins && git clone git@github.com:googleforcreators/web-stories-wp.git web-stories ``` Using symlinks works too, if you prefer having your Git projects in some other folders or if you want to use the Web Stories plugin on multiple environments. ================================================ FILE: docs/migrations.md ================================================ # Migrations Migration reducers can and should be written whenever the underlying data model is being changed. This way, old stories get updated to the newest version once they're loaded. See [`migration/migrate.js`](../packages/migration/src/migrate.js) for details. **Note**: migration functions should be self-contained and not import anything from outside components as they might change and thus affect the migration. ================================================ FILE: docs/onboarding.md ================================================ # Onboarding Short checklist for onboarding of new contributors to the team. Requirements: * Email address * GitHub username Items to check: | Task | Who | Notes | |------------------------------------- |-------- |------------------------------------------------------------------------------------------------------- | | Add to Slack channel | XWP | | | Add to shared calendar and meetings | XWP | | | GitHub access | Google | Usually done by @swissspidy. "Write" access is usually enough. Engineers also need merge permissions. | | Figma access | Google | Usually done by @samitron7 | | Access to [staging site](https://stories-new-wordpress-amp.pantheonsite.io/) | Google | Usually done by @swissspidy | | Access to [QA site](https://stories-qa-wordpress-amp.pantheonsite.io/) | Google | Usually done by @swissspidy | | Access to [Percy](https://percy.io/google) | Google | | | Add to [mailing lists](https://groups.google.com/g/story-editor-team) | Google | | ================================================ FILE: docs/page-templates.md ================================================ # Page Templates Page templates allow people to apply individual pages defined in templates to their story without choosing an entire template. Page templates are based on the same JSON definitions as templates defined in `packages/templates/src/raw`. ## How Page Templates differ from Template Pages Since page templates are based on the same source files as templates, some customization of raw templates is required for them to work correctly with Page Templates. ### Page Template Types and Naming In the page templates pane, pages are labeled and able to be filtered by their page type (Examples: Cover, Section, Quote, ...). These types are defined in the source template's raw JSON. To set page template type for a template page, set `pageTemplateType` on the template JSON's page objects to one of the types defined at `packages/story-editor/src/components/library/panes/pageTemplates/constants.js`. If `pageTemplateType` is set to `null`, the page will be omitted from the page templates pane. Page Templates are named based on their original Template Name + Page Template Type like "Cooking Cover". ### All template images are converted to grid placeholders Unlike templates which come with lots of stock photography when creating a story based on a template, page templates replace all (yes, all) images with a placeholder grid image. In order to make the grid consistent, a single placeholder grid image is scaled to look consistent across many image sizes. To preserve images to persist into the page template, they need to be converted to shapes. Shapes will be not be replaced with placeholder grids when rendered as page templates and applied to pages. There has been consideration to make it so that some images can be passed through in [Issue #6032](https://github.com/googleforcreators/web-stories-wp/issues/6032). ### Page Template Previews Page Templates preview using the same mechanisms as browsing templates on the dashboard. Animations are included when applying page templates and are included in previews. ## Applying Page Templates Page templates are designed to be a page replacement. On application, they will replace the entire page's contents. If the page has changes, a confirmation is required before overwriting existing changes. Applying page templates copies over grid placeholders and all associated animations from the original template page. ## Technical notes and considerations ### RTL Support In order for page templates and templates to render the same as they will when selected and published, they need to bypass some of the default RTL support in the app. All preview pages used for rendering page templates and templates are wrapped in ``. ### Page Templates are Template Pages In order to make templates and page templates easier to maintain, page templates are built off of standard templates. Use `"pageTemplateType": null` (see above) to omit template pages from being able as page templates. There are currently no mechanisms for creating page templates that are not a part of templates. ================================================ FILE: docs/performance.md ================================================ # Performance Performance for the editor follows the [RAIL model](https://web.dev/rail/). Most of our in app interactions will be categorized by the [Animation](https://web.dev/rail/#animation:-produce-a-frame-in-10-ms) & [Response](https://web.dev/rail/#response:-process-events-in-under-50ms) section of this model. It's important to measure the performance of the app with the react production build (`npm run build`) in an incognito window. This imitates what a user will experience and will prevent any chrome extensions from effecting performance times. It's also important to be aware of what machine you're auditing the app on relative to the user base of the application. An M1 macbook pro can perform about twice as fast as an intel macbook pro. This is something you should account for when trying to compare or justify results. ## Existing Performance Work To view any prior performance PRs, just search github pulls and issues for anything with the `performance` tag. The two main milestone audits exist here: - [Performance Report: January 2022 ](https://github.com/GoogleForCreators/web-stories-wp/issues/10158) - [Performance Report: April 2022](https://github.com/GoogleForCreators/web-stories-wp/issues/11263) ## Chrome DevTools Performance Tab vs React Profiler To identify a performance bottleneck in the application, you should use the chrome devtools performance tab in an incognito window, record a sample interaction, and see if it violates the RAIL Model. If there is a violation of the RAIL Model, you should use React Profiler on the development build to guide your architectural decisions. **Note:** Performance optimizations can often come at the cost of Legibility & Maintainability. They can often also be footguns for performance if used incorrectly. It's important to have documentable proof of a performance bottleneck before altering code for the sake of performance improvements. ### React Profiler The React Profiler is beneficial for identifying where and how you can make the application more performant. Once you've recorded an interaction within the application, you can audit the commits and component renders of the application for that interaction. There are essentially two things to look for when auditing a flame graph in the React Profiler, extraneous renders & expensive renders. #### Extraneous Renders Extraneous renders are categorized as renders that occur, that don't need to, for a particular update. You can think about these the difference between what React diffs for a particular update, and the minimum that needs to be diffed for our UI to visually update for a user. This difference is an extraneous render. There's no need to eliminate all extraneous renders, but it is important to be able to identify, and remove, areas that have large amounts of extraneous renders. The classic example within the context of this app is updating an element. If we have 50 elements on a story page, and update the position of 1 element, only 1 element needs a visual update for the user. However, if all 50 elements re-render, and 49 of them are empty re-renders (re-render where nothing actually updated), those 49 re-renders are extraneous re-renders that should be eliminated. There are many approaches to eliminate empty re-renders. The use case and existing architecture will dictate whatever approach you take, but ultimately any approach that is verified with the react flame graph should work. Here is a great article to reference: [When Does React Render Your Component?](https://www.zhenghao.io/posts/react-rerender) #### Expensive Renders Expensive renders are components that show up in the react flame graph and take >1ms to re-render. Like extraneous renders, this is not a hard rule, but you should look for components that are eating up a significant amount of your response budget when you identify a bottleneck. Once expensive renders are identified in the flame graph from the React Profiling tool, recording the same interaction in the Chrome Dev Tools Performance tab, then inspecting the callstack is best to identify calls are making the components render expensive. ## Using Context Performantly All of our context is held using the [use-context-selector](https://github.com/dai-shi/use-context-selector) package, and we have a small layer around it that allows it to use shallow equality vs referential equality to detect if a component should re-render based on the return value of its selector. You can see this code in `packages/react/src/useContextSelector.js`. As a general rule of thumb, it is best practice to pull only essential data needed for your component through a selector when using `useContextSelector(...)`. This practice helps eliminate empty/extraneous renders induced from context. [Read more about it here](https://github.com/GoogleForCreators/web-stories-wp/issues/2662#issuecomment-1011372651). ## Main Areas to Identify Performance Regressions There is always a chance for performance regressions as features get altered and added. The main regression to watch out for in the story editor is where a component is rendered for every element in state. Examples of this include the Display Layer, the Frames Layer, the Layer Panel. It should be verified that not every instance of said component re-renders when only one or a few of those element components needs updating. All these components are prone to large performance regressions if hooks or props pass data around unwisely. It would be beneficial to audit these components from time to time in the react profiler. An audit can be as simple as recording an update to selection, or updating the position of a single element. If the react flame graph indicates that this is causing every element to re-render vs only the effected elements, a large performance regression has occurred. Be sure to check for this regression on PRs that effect display elements, frame elements, layers in the layers panel, or any story state hooks. ================================================ FILE: docs/quick-actions.md ================================================ # Quick Actions Some actions are cumbersome in the editor as it's difficult to find them in the Design panel. The quick actions menu allows users to perform actions and locate specific panels in the editor. ## Overview Quick actions hook into the `highlight` api that is used in the pre-publish checklist. Using this, the quick actions can highlight specific panels in the editor. The quick action menu is located in the nav layer. This allows users to navigate around using the keyboard in an intuitive way. Clicking the quick action menu should not remove focus from any elements that have focus. Quick actions have two main functions: 1. Focusing a specific part of the editor 2. Editing an element in the canvas Quick actions are not limited to just the above functionality. In the future it may be necessary to add more functionality. ### Action Types The actions that are displayed dynamically change depending on which element type is selected. These are outlined below: | Selected Element Type | Available actions | |-----------------------|-----------------------------------------------------------------------------------------------------------| | Nothing selected | - Change background color
- Insert media
- Insert text | | Background Image | - Replace background
- Add animation*
- Clear filters and animation | | Foreground Image | - Replace media
- Add animation*
- Add link
- Clear animation | | Video | - Replace media
- Add animation*
- Add link
- Add captions
- Clear filters and animation | | Shape | - Change color
- Add animation*
- Add link
- Clear filters and animation | | Text | - Change color
- Edit text
- Add animation*
- Add link
- Clear animation | ### Action functionality Reference this table when needing to know what a quick action will do when clicked. | Quick Action | Result | |---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Add animation* | Open and highlight the `animation` panel in the `design pane`. Focus the effect chooser dropdown. | | Add caption | Open and highlight the `captions and subtitles` panel in the `design pane`. Focus the input. | | Add link | Open and highlight the `link` panel in the `design pane`. Focus the input. | | Change background color | Open and highlight the `page background` panel in the `design pane`. Focus the input. | | Clear animation/filters and animation | Remove all filters and/or animations as specified. Display a snackbar to the user indicating that they were removed. The snackbar will contain an action button that when clicked will re-apply the styles that were removed. | | Edit text | Open and highlight the `text` panel in the `design pane`. Focus the first input. | | Insert media | Open and highlight the `media pane` in the `library`. Focus the media tab button. | | Insert text | Open and highlight the `text pane` in the `library`. Focus the text tab button. | | Replace background/media | Open and highlight one of the media panes in the `library`. The media pane opened will depend on the type of the element selected. Focus the relevant tab button. | | Trim video | Enter video trim mode similar to button in design panel. | > *_Add animation_ action is not available on the first page of a story. ### Visibility The quick actions menu is visible when: 1. there are no elements selected or 2. if a single element is selected (including the background) This feature does not support selecting multiple elements, so the menu will be hidden if more than one element is selected. ## Adding new quick actions Quick actions hook into the highlight api. To add a new quick action, the following steps must be completed. If you are not highlighting, skip the first two steps: 1. If it doesn't already exist, add a key to `packages/story-editor/src/app/highlights/states.js` that will reference the panel/pane/element that you are trying to highlight. 2. Attach the new highlight state to the panel/pane/element that will be focused with the `useHighlights()` context. See existing usages for an example. 3. Create the quick action in the `useQuickActions` hook located at `packages/story-editor/src/app/highlights/quickActions/useQuickActions.js` ## Technical notes and considerations ### Focus Clicking the quick action menu should not remove focus from an element. To do this, two things were added: 1. An `onMouseDown` event handler is passed to the buttons. This event handler prevents the mouse down event from unfocusing the canvas element. 2. The context menu is given an `onMouseDown` event handler to prevent mouse events on the menu _in between the buttons_ from bubbling up and unfocusing the element in the canvas. A user should be able to navigate the quick actions menu with their keyboard. ### Highlights This feature uses the `highlights` api that was implemented in [this pr: #6150](https://github.com/googleforcreators/web-stories-wp/pull/5965). ### Testing In order to prevent regression testing, unit and karma tests have been created. It is recommended to add a karma test for each new quick action to make sure that it behaves properly. ================================================ FILE: docs/right-click-menu.md ================================================ # Right Click Menu (Context Menu) The Right-click Menu provides additional functionality to the user. The actions displayed on this Menu will change depending on which type of Element the user has selected on the canvas: Page, Text, Media, or Shapes. ## Overview Right click menu will give the user a right click menu that is intuitive to use in the editor. Using this custom context menu, the user will be able to edit element style properties, store data in the clipboard, and paste data from their clipboard back into the editor. ### Right click menu action types |Group|Description| |--|--| |Main actions|These options can apply across any element or media| |Layer distribution options/actions|These options help the user move the selected element between the different layers (objects in the canvas)| |Element styling options/actions|These options help the user manage the Element's style properties| |Page action options|These options help the user manage the different Pages in the story| ### Actions Different actions will be rendered depending on the Element that is right clicked. The actions will do the following:
Page element actions |Action text|Action description| |--|--| |Add new page after|Adds a page after the current page.| |Add new page before|Adds a page before the current page.| |Duplicate page|Creates a new page that is identical to the current page. All animations, styles, and elements should be the same.| |Delete page|Deletes the current page (if the page can be deleted).|
Text element actions |Action text|Action description| |--|--| |Send to back|Set the text element behind all other elements on the page.| |Send backward|Bring the text element backward one layer.| |Bring forward|Bring the text element forward one layer.| |Bring to front|Set the text element in front of all elements on the page.| |Copy style|The styles of the selected text element are copied to the clipboard. A snackbar is displayed on completion.| |Paste style|The styles that are saved to the clipboard are pasted to the currently selected text box. This does not update the text in the textbox. A snackbar is shown on completion.| |Add style to "Saved styles"|The style of the currently selected textbox is saved to the "Saved styles" panel in the style pane. This action opens the design tab, opens the "Saved styles" panel, and collapses all other panels in the style pane. The "Saved styles" panel should be highlighted when opened.| |Add color to "Saved colors"|The color(s) of the currently selected textbox is saved to the "Saved colors" panel in the style pane. This action opens the design tab, opens the "Saved colors" panel, and collapses all other panels in the style pane. The "Saved colors" panel should be highlighted when opened. A snackbar is displayed on completion.|
Background media actions |Action text|Action description| |--|--| |Detach image from background|Removes the media from the background of the page and sets it in the foreground.| |Scale & crop background image|Show the scale and crop UI so that the user may scale or crop the image to the desired size.|
Foreground media actions |Action text|Action description| |--|--| |Send to back|Place media behind all other elements. Disabled if the layer is all the way back.| |Send backward|Bring media one layer backwards. Disabled if the layer is all the way back.| |Bring forward|Bring media one layer forwards. Disabled if the layer is all the way forward.| |Bring to front|Place media in front of all other elements. Disabled if the element is all the way forward.| |Copy image styles|Copy all styles applied to the media to the clipboard. A snackbar is displayed on completion.| |Paste image styles|Add all styles in the clipboard to the selected media. A snackbar is displayed on completion.|
Shape element actions |Action text|Action description| |--|--| |Send to back|Place shape behind all other elements. Disabled if the layer is all the way back.| |Send backward|Bring shape one layer backwards. Disabled if the layer is all the way back.| |Bring forward|Bring shape one layer forwards. Disabled if the layer is all the way forward.| |Bring to front|Place shape in front of all other elements. Disabled if the element is all the way forward.| |Copy shape styles|Copy styles from the shape to the clipboard. A snackbar is displayed on completion.| |Paste shape styles|Add styles from the clipboard to the selected shape. A snackbar is displayed on completion.| |Add color to "Saved colors"|The color(s) of the currently selected shape is saved to the "Saved colors" panel in the style pane. This action opens the design tab, opens the "Saved colors" panel, and collapses all other panels in the style pane. The "Saved colors" panel should be highlighted when opened. A snackbar is displayed on completion.|
## Technical notes and considerations ### Copying and pasting styles When a user right clicks elements, they may be given the option to copy an element's styles and paste them to another element of the same type. **Note**: This does not override all styles of the element. Only styles that are in the table below are able to be copied and pasted: |Element type|Properties that are copy/paste-able| |--|--| |Text|- `backgroundColor`
- `backgroundTextMode`
- `border`
- `border-radius`
- `flip`
- `font`
- `fontSize`
- `lineHeight`
- `opacity`
- `padding`
- `rotationAngle`
- `textAlign`| |Foreground Media|- `border`
- `border-radius`
- `flip`
- `opacity`
- `overlay`
- `rotationAngle`| |Shape|- `backgroundColor`
- `flip`
- `opacity`
- `rotationAngle`| A user may copy styles from any text, foreground media, and shape element. A user may not copy background media element styles. Once styles are copied, a user may select an element of the same type and 'paste' those onto the selected element. The selected element's properties will be overridden by the 'copied' styles. ### Testing In order to prevent regression testing, unit and karma tests have been created. It is recommended to add a karma test for each new right click action to make sure that it behaves properly. ================================================ FILE: docs/storybook.md ================================================ # Storybook A living components library is maintained using [Storybook](https://storybook.js.org/). The latest version of the project's storybook can be found at [https://googleforcreators.github.io/web-stories-wp/storybook](https://googleforcreators.github.io/web-stories-wp/storybook). To run it locally, use the following command: ```bash npm run storybook ``` In addition to the components library, the storybook also contains a story editor "playground" that can be used to test-drive the editor. ================================================ FILE: docs/svgs.md ================================================ # SVGs SVGs (also known as icons) are needed in different formats across this project. This project uses webpack's [asset modules](https://webpack.js.org/guides/asset-modules/) and [SVGR](https://react-svgr.com/) to convert SVGs into different formats. **Note**: Each raw SVG must be in a separate file that is named with the `.svg` extension. ## SVGs as Inline Assets SVGs may be embedded as a small file inline by converting the SVG into a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). To use an SVG as a data URL, add the SVG in a folder named `/inline-icons`. ## SVGs as React Components or Images SVGR is used to transform raw SVGs into React Components or Images so that SVGs can be more easily used in the dashboard and editor. ### Using SVG as a React Component To use an SVG as React Component, add an SVG in a folder named `/icons`. Webpack will see the `/icons` folder and process those SVGs through SVGR. The built file will have a default export that is a react component. ### Using SVG as an Image To use an SVG as an image, add an SVG in a folder named `/images`. Webpack will see the `/images` folder and process those SVGs through SVGR. The built file will have a default export that is an image. ================================================ FILE: docs/testing-environments.md ================================================ # Testing Environments ## Staging Environment The latest version of the plugin (i.e. current master branch) is up and running on [https://stories-new-wordpress-amp.pantheonsite.io/](https://stories-new-wordpress-amp.pantheonsite.io/) and ready for maintainers to test. Ping one of the leads if you need access to the site. **Purpose**: Reviewing and testing the latest nightly build **Release cadence**: automatically **Target audience**: Engineering, PM, QA, UX ## QA Environment The Web Stories plugin is also active on [https://stories-qa-wordpress-amp.pantheonsite.io/](https://stories-qa-wordpress-amp.pantheonsite.io/) and ready for maintainers to test. The [Web Stories Tester](./testing-qa.md) plugin is installed that lets you easily switch between testing the `master` branch and individual PRs. Ping one of the leads if you need access to the site. **Purpose**: Reviewing new changes before they are merged (QA / UAT) **Release cadence**: manually **Target audience**: Engineering, PM, QA, UX ## Stories Labs For internal testing with a larger audience the [Stories Labs](https://stories-labs.dev/) site is more suitable. Ping one of the leads if you need access to the site. Ping @swissspidy if the site needs to be updated. **Purpose**: Test-driving the latest version **Release cadence**: manually (every few weeks / when there is a new release) **Target audience**: Internal testers ================================================ FILE: docs/testing-qa.md ================================================ # Manual Testing (QA) ## ZIP Files Every **pull request** automatically has a ZIP file attached to it that can be downloaded and installed on a WordPress site. To get the ZIP file: 1. Open pull request 2. Click on "Checks" at the top ![PR Overview](https://user-images.githubusercontent.com/841956/80692983-a6894280-8ad2-11ea-8c5c-2c30ce89dc5f.png) 3. Click on "Continuous Integration" on the left-hand side ![Continuous Integration](https://user-images.githubusercontent.com/841956/80693011-b0ab4100-8ad2-11ea-80aa-a1167c25fd14.png) 4. Download `web-stories.zip` ![ZIP file list](https://user-images.githubusercontent.com/841956/80693026-b6a12200-8ad2-11ea-8ae0-76dc279f3250.png) The same is also possible for the **master** branch. For this, navigate to [the Actions tab](https://github.com/googleforcreators/web-stories-wp/actions?query=workflow%3A%22Continuous+Integration%22+branch%3Amaster) in the repository navigation and click on the latest Continuous Integration run to download the ZIP file. ## Web Stories Tester Plugin To make switching between individual PRs easier, there is a Web Stories Tester WordPress plugin that you can install on your site. 1. [Download Web Stories Tester](https://drive.google.com/file/d/1cKR2Yb7oY6JRyGvreVoCTBa_nfWp2Dqr/view?usp=sharing) 2. Install plugin on your site by uploading it in WordPress 3. Use the new menu item in the admin toolbar to switch between the master branch and individual PRs. ![Web Stories Tester plugin in action](https://user-images.githubusercontent.com/841956/81189411-cfb84000-8fb6-11ea-80db-adddebb06ef2.png) ## Sharing story data You can use [DevTools](./devtools.md) to share story data for debugging purposes. ================================================ FILE: docs/third-party-integration/dashboard/README.md ================================================ # Dashboard * [Getting Started](./getting-started.md) * [API Callbacks](./api-callbacks.md) * [Integration Layer](./integration-layer.md) * [Tutorial](./tutorial.md) ================================================ FILE: docs/third-party-integration/dashboard/api-callbacks.md ================================================ # API Callbacks Side effects are added to the Dashboard by defining callbacks. One such callback is `fetchStories` which is used to get stories from the backend and is the only required callback. Other callbacks can be used to add or handle functionalities for story management. All API callbacks are asynchronous and should eventually resolve to their corresponding expected responses. Below is a list of available API callbacks categorized in different sections. ## Story Management A set of callbacks that help in performing create, update, delete and read operations on web stories stored in the CMS. ### `fetchStories` A callback which fetches stories to be shown in the dashboard. Arguments - `queryParams` - type: `Object` - description: An object which contains the query parameters. - `status` - type: `string` - description: Status of a story. One of `draft`, `pending`, `public`, `future` or `private`. - `searchTerm` - type: `string` - description: Search string. - `sortOption` - type: `string` - description: User selected option from a dropdown. Sort by `title`,`date`,`date` or `story_author`. - `sortDirection` - type: `string` - description: Sort order. One of `asc` or `desc`. - `author` - type: `string` - description: Author name. Expected Response: An `Object` with the following shape. `fetchedStoryIds` - type: `array` - required: Yes - description: An array of story id's corresponding to the order of the stories to be shown in the dashboard. `stories` - type: `Object` - required: Yes - description: Object consisting of story details of each story, where story-id is the key and [story object](#story-shape-object) is the value. `totalPages` - type: `number` - required: Yes - description: Total number of pages. `totalStoriesByStatus` - type: `Object` - required: Yes - description: A map with data about how many stories have a particular status. ### Story Shape Object - `author` - type: `Object` - required: No - description: Name and id of the author for the web story. - `name` - type: `string` - required: No - description: Name of the author. - `id` - type: `number` - required: No - description: Id of the author. - `bottomTargetAction` - type: `string` - required: No - description: Link to edit the story in story-editor. - `capabilities` - type: `Object` - required: No - description: Capabilities of user to perform on current story. - `hasEditAction` - type: `boolean` - required: No - description: Defines the capability of user to edit the story. - `hasDeleteAction` - type: `boolean` - required: No - description: Defines the capability of user to delete the story. - `created` - type: `string` - required: Yes - description: Created date and time of the story without timezone. - `createdGmt` - type: `string` - required: Yes - description: Created date and time of the story with timezone. - `editStoryLink` - type: `string` - required: Yes - description: Link to open story in story-editor. - `featuredMediaUrl` - type: `string` - required: No - description: Link to show the featured media/poster. - `id` - type: `number` - required: Yes - description: Id of the current story. - `link` - type: `string` - required: No - description: Link to display the web-story output. - `modified` - type: `string` - required: Yes - description: Latest modified date and time of the story without timezone. - `modifiedGmt` - type: `string` - required: Yes - description: Latest modified date and time of the story with timezone. - `previewLink` - type: `string` - required: No - description: Link to preview the story. - `status` - type: `string` - required: Yes - description: Publishing status of the web story. - `title` - type: `string` - required: Yes - description: Title of the web-story. Example ( Story Object ) ```JSON { "author": { "name": "dev", "id": 1 }, "bottomTargetAction": "https://example.org/post.php?post=1&action=edit", "capabilities": { "hasEditAction": true, "hasDeleteAction": true }, "created": "2022-01-18T07:36:32", "createdGmt": "2022-01-18T07:36:32Z", "editStoryLink": "https://example.org/post.php?post=1&action=edit", "featuredMediaUrl": "", "id": 1, "link": "https://example.org/?post_type=web-story&p=1", "modified": "2022-01-18T07:36:32", "modifiedGmt": "2022-01-18T07:36:32Z", "previewLink": "https://example.org/?post_type=web-story&p=1&preview=true", "status": "draft", "title": "Test Title", } ``` Example ( Expected response ) ```JSON { "fetchedStoryIds": [], "stories":{ "1":{...}, "2":{...}, }, "totalPages": 1, "totalStoriesByStatus": {...} } ``` ### `updateStory` Arguments - `data` - type: `Object` - description: The Object containing params which contain details to update the story. - `id` - type: `number` - description: The id of the story that needs to be updated - `author` - type: `number` - description: The id of the author. - `title` - type: `number` - description: The changed story title. Response: An `Object` of `` shape. Example ( Expected response ) ```JSON { "id": 1, "status": "draft", "title": "Test", "created": "2022-03-15T08:28:02", "createdGmt": "2022-03-15T08:28:02Z", "modified": "2022-03-15T08:28:02", "modifiedGmt": "2022-03-15T08:28:02Z", "author": { "name": "Dev", "id": 1 }, "bottomTargetAction": "https://example.org/post.php?post=2247&action=edit", "featuredMediaUrl": "", "editStoryLink": "https://example.org/post.php?post=2247&action=edit", "previewLink": "https://example.org/?post_type=web-story&p=2247&preview=true", "link": "https://example.org/?post_type=web-story&p=2247", "capabilities": { "hasEditAction": true, "hasDeleteAction": true } } ``` ### `trashStory` Arguments - `id` - type: `number` - description: The story id which needs to be trashed. Response - `void` ### `createStoryFromTemplate` Arguments - `template` - type: `Object` - description: The details of a template - `id` - type: `number` - description: Id of the template - `createdBy` - type: `string` - description: Name of the creator of the template. - `modified` - type: `string` - description: Date and time of modification of the template. - `slug` - type: `string` - description: Slug of the template. - `creationDate` - type: `string` - description: Date and time of creation of the template. - `title` - type: `string` - description: Title of the template which will be default title for story. - `tags` - type: `Array` - description: set of words that describe the template. - `colors` - type: `Array` - description: Set of colors which are mostly used in template. - `color` - type: `string` - description: Hex code of the color. - `family` - type: `string` - description: Name of the base color. - `label` - type: `string` - description: Name of the color. - `description` - type: `string` - description: A brief description of the template. - `vertical` - type: `string` - description: Category of the template - `version` - type: `number` - description: Version of the template used for migration. - `pages` - type: `array` - description: Each attay item corresponds to each page in template.[ page object ](../story-editor/integration-layer-api/api-callbacks.md/#page-object-shape) - `postersByPage` - type: `array` - description: Each object consisting of the URL for the page poster. - `png` - type: `string` - description: URL for png image. - `type` - type: `string` - description: Type of poster image. - `webp` - type: `string` - description: URL for webp image. - `status` - type: `string` - description: String showing the status of template. Expected Response: An `Object` of the following shape. `editLink` - type: `string` - required: Yes - description: Link to edit the new story created from template. Example (Template) ```JSON { "id": 51, "createdBy": "Google", "modified": "2020-04-21T00:00:00.000Z", "slug": "celebrity-life-story", "creationDate": "2021-08-25T00:00:00.000Z", "title": "Celebrity Life Story", "tags": [ "Entertainment", "Celebrity", "Pop", "Bright", "Black" ], "colors": [ { "label": "Phantom Black", "color": "#020202", "family": "Black" }, { "label": "Gecko Green", "color": "#80FF44", "family": "Green" } ], "description": "With an upbeat neon green color and a powerful headings font, this template is great for creating stories around pop culture, music and the show business.", "vertical": "Entertainment", "version": 40, "pages": [{...}], "postersByPage": [{...}], "status": "template", "isLocal": false } ``` Example ( Expected Response ) ```JSON { "editLink":"https://example.org/post.php?post=1&action=edit" } ``` Note that when the "Use template" button is clicked, it internally needs to know the registered element types. Therefore, you need to register them using `elementTypes.forEach(registerElementType)` in the same way as it is done for the editor itself (see [Getting Started](./../story-editor/getting-started.md)). ### `duplicateStory` Arguments - `story` - type: `id` - description: Id of the story which is to be dupilicated Response An `Object` in `` shape. Example ( Expected response ) ```JSON { "id": 1, "status": "draft", "title": "Test", "created": "2022-03-15T08:28:02", "createdGmt": "2022-03-15T08:28:02Z", "modified": "2022-03-15T08:28:02", "modifiedGmt": "2022-03-15T08:28:02Z", "author": { "name": "Dev", "id": 1 }, "bottomTargetAction": "https://example.org/post.php?post=2247&action=edit", "featuredMediaUrl": "", "editStoryLink": "https://example.org/post.php?post=2247&action=edit", "previewLink": "https://example.org/?post_type=web-story&p=2247&preview=true", "link": "https://example.org/?post_type=web-story&p=2247", "capabilities": { "hasEditAction": true, "hasDeleteAction": true } } ``` ## Filtering stories A dropdown with a list of all the authors for filtering the stories would be added if the callback below is defined. ### `getAuthors` A callback used to get all the authors of the CMS. Response from this will be used in a dropdown menu from which selecting a particular author will invoke `fetchStories` with the required arguments to get stories created by this author. Arguments - `search` - type: `string` - description: Search string entered by the user. Expected Response: Array of the `Object` which describes author details. - `id` - type: `number` - required: Yes - description: User id. - `name` - type: `string` - required: Yes - description: Name of the author. Example ( Expected response ) ```json [ { "id": 1, "name": "dev" }, { "id": 2, "name": "admin" } ] ``` ================================================ FILE: docs/third-party-integration/dashboard/getting-started.md ================================================ # Dashboard If you want a companion dashboard to show stories created by the user or a list of story templates to choose from, you may use `Dashboard` from the `@googleforcreators/dashboard` package. The dashboard can also be used to create a settings page for the story editor. ## Scaffolding a Story Dashboard You can quickly spin up a dashboard in a few steps described below. ### Step 1: Set up a React project The easiest way of setting up an SPA React project is to use [CRA](https://create-react-app.dev/) or to use templates such as [react-webpack-babel-starter](https://github.com/vikpe/react-webpack-babel-starter). ### Step 2: Install dependencies For a minimal story dashboard, you need to install three packages ```sh npm install @googleforcreators/dashboard @googleforcreators/element-library @googleforcreators/elements ``` ### Step 3: Use the `Dashboard` component to render the dashboard ```js import { Dashboard, InterfaceSkeleton } from "@googleforcreators/dashboard"; const EditorDashboard = () => { const apiCallbacks = { fetchStories: () => Promise.resolve({ stories: {}, fetchedStoryIds: [], totalPages: 1, totalStoriesByStatus: { all: 0, publish: 0, }, }), }; return ( ); }; export default EditorDashboard; ``` By default, the dashboard looks like this: ![dashboard](https://user-images.githubusercontent.com/841956/159524205-43e27097-9321-487c-b236-28f9414f539e.png) The dashboard application can be split into 2 different areas: ![dashboard-labelled](https://user-images.githubusercontent.com/841956/159524265-bf0a117a-2432-469d-8733-d61f1e94b3aa.png) The left rail / sidebar (1) provides a list of menu items for different pages configured in the dashboard. Clicking on a menu item will display the page's content on the right (2). Out of the box, the left rail only contains a menu item for the main page showing all existing stories. ================================================ FILE: docs/third-party-integration/dashboard/integration-layer.md ================================================ # Integration Layer Similar to the editor, the Dashboard can be integrated by configuring the `Dashboard` and other components like `InterfaceSkeleton`. As seen in the [Getting Started](./getting-started.md) guide, a minimal dashboard can be created by using the two main components `Dashboard` and `InterfaceSkeleton` like below. ```js import { Dashboard, InterfaceSkeleton } from "@googleforcreators/dashboard"; const StoriesDashboard = () => { const apiCallbacks = { fetchStories: () => Promise.resolve({ stories: {}, fetchedStoryIds: [], totalPages: 1, totalStoriesByStatus: { all: 0, publish: 0, }, }), }; return ( ); }; export default StoriesDashboard; ``` ## `Dashboard` This is the top level component which has all the provider components but doesn't render any UI itself. **Props:** - `config` - type: `Object` - required: Yes - description: Used for most of the dashboard configuration. See the [Dashboard Config](#dashboard-config) section below for full detail. ## `InterfaceSkeleton` This component is responsible for rendering the story editor interface UI and can be configured by using multiple props. Please look at the `InterfaceSkeleton` section below for detailed documentation of this component. **Props:** - `additionalRoutes` - type: `array` - required: No - description: Used to add additional routes to the left rail. - `path` - type: `string` - required: Yes - description: Unique path to this page. For example - `/editor-settings`. - `component` - type: `React.ReactElement` - required: Yes - description: A component which will be rendered on the page content area if corresponding link is selected ## Dashboard Config To configure the dashboard to your needs you can pass various config options to the story dashboard via `config` prop of the `Dashboard` component. - `isRTL` - type: `boolean` - required: No - description: It defines the direction of the layout of the dashboard. - `userId` - type: `number` - required: No - description: The id of the user who is currently viewing the dashboard. - `locale` - type: `Object` - required: No - description: It is a set of parameters that defines the user's language, region and any special variant preferences that the user wants to see in their user interface. - `newStoryURL` - type: `string` - required: Yes - description: This specifies the exact url to create a new story. - `archiveURL` - type: `string` - required: No - description: URL to archives page for web story. - `defaultArchiveURL` - type: `string` - required: No - description: default URL archives page for web stories - `cdnURL` - type: `string` - required: No - description: URL to dashboard resources. - `allowedImageMimeTypes` - type: `array` - required: Yes - description: It specifies the allowed Image types that are supported by web stories - `version` - type: `string` - required: No - description: Shows the current version of web-story - `encodeMarkup` - type: `boolean` - required: No - description: It specifies whether the markup need to be encoded while making api calls. - `api` - type: `Object` - required: Yes - description: It specifies the URL for different api calls. - `maxUpload` - type: `number` - required: No - description: It specifies the maximum size of a file that can be uploaded to the backend. - `maxUploadFormatted` - type: `string` - required: No - description: It specifies the maximum size of a formatted file that can be uploaded to the backend - `capabilities` - type: `Object` - required: - description: - `canManageSettings` - type: `boolean` - required: No - description: It specifies the capabilities of the user to manage settings of web story. - `canUploadFiles` - type: `boolean` - required: No - description: It specifies the capabilities of the user to upload files. - `canViewDefaultTemplates` - type: `boolean` - required: No - description: It specifies ability of user to view default templates. - `localeData` - type: array - required: Yes - description: Returns the translation of the translatable strings. - `apiCallbacks` - type: `Object` - required: Yes - description: It consists of functions that are used to make request to API endpoints. - `leftRailSecondaryNavigation` - type: `array` - required: No - description: It shows the navigation panel on the left side. - `value` - type: `string` - required: No - description: It specifies the value of its corresponding label in navigation panel. - `label` - type: `string` - required: No - description: It shows the value to be shown to the user for certain links. - `isExternal` - type: `boolean` - required: No - description: Defines the link is external or not. - `trackingEvent` - type: `string` - required: No - description: defines tracking event for the link for google analytics. - `documentTitleSuffix` - type: `string` - required: No - description: Suffix to be used in the document title. - `styleConstants` - type: `Object` - required: No - description: This contains various style constants which are used while calculating position for various components in dashboard. - `topOffset` - type: `boolean` - required: No - description: It defines the top bar height, which is used in calculation for placement of components. - `containerId` - type: `string` - required: No - description: The id of the upper dashboard container which defines the available space for dashboard. If not defined `window.innerWidth` will be used for calculating the space. - `siteKitStatus` - type: `Object` - required: No - description: It specifies the status of installation of site kit by google. - `installed` - type: `boolean` - required: No - description: It specifies whether site kit is installed. - `active` - type: `boolean` - required: No - description: It specifies whether site kit is active. - `analyticsActive` - type: `boolean` - required: No - description: It specifies whether analytics in site kit is active. - `adsenseActive` - type: `boolean` - required: No - description: It specifies whether adsense in site kit is active. - `analyticsLink` - type: `string` - required: No - description: It specifies the analytics link where analytics data are being recorded. - `adsenseLink` - type: `string` - required: No - description: It specifies adsense link where adsense data are being recorded. ================================================ FILE: docs/third-party-integration/dashboard/tutorial.md ================================================ # Tutorial This tutorial explains how to create a standalone story dashboard step by step. It covers implementing the following functionalities: - Fetching hard-coded stories. - Adding filters for browsing stories. - Adding a custom settings page in the dashboard ## Step 1: Setting up the dashboard with minimum config Installing dependencies ```sh npm install @googleforcreators/dashboard ``` After that you can use code block given below to scaffold a minimal story dashboard. ```js import { Dashboard, InterfaceSkeleton } from "@googleforcreators/dashboard"; const CustomDashboard = () => { const apiCallbacks = { fetchStories: () => Promise.resolve({ stories: {}, fetchedStoryIds: [], totalPages: 1, totalStoriesByStatus: { all: 3, draft: 0, future: 0, pending: 0, private: 0, publish: 0, }, }), }; return ( ); }; export default CustomDashboard; ``` Minimum requirement for a story dashboard is to fetch stories, so the user can browse them go to the editor to update them. The response for `fetchStories` that is shown in the above code sample emulates a CMS having no stories saved. ## Step 2: Adding hard-coded stories and updating `fetchStories` to handle its argument `fetchStories` callback be updated as shown in the below code sample to serve hard-coded data. ```js const STORIES_RESP = { stories: { 1: { id: 1, status: "publish", title: "Example story", created: "2021-11-04T10:12:47", createdGmt: "2021-11-04T10:12:47Z", author: { name: "Author 1", id: 1, }, featuredMediaUrl: "https://wp.stories.google/static/main/images/templates/food-and-stuff/page1_bg.jpg", }, 2: { id: 2, status: "publish", title: "Example story 2", created: "2021-12-05T10:12:47", createdGmt: "2021-12-05T10:12:47Z", author: { name: "Author 2", id: 2, }, featuredMediaUrl: "https://wp.stories.google/static/main/images/templates/fresh-and-bright/page8_figure.jpg", }, 3: { id: 3, status: "publish", title: "Example story 3", created: "2021-12-06T10:12:47", createdGmt: "2021-12-06T10:12:47Z", author: { name: "Author 3", id: 3, }, featuredMediaUrl: "https://wp.stories.google/static/main/images/templates/fresh-and-bright/page7_product2.jpg", }, }, fetchedStoryIds: [1, 2, 3], totalPages: 1, totalStoriesByStatus: { all: 3, publish: 2, }, }; const fetchStories = () => Promise.resolve(STORIES_RESP); export default fetchStories; ``` You can also update `fetchStories` as below to allow basic filtering and ordering stories on the basis of their statuses. ```jsx const fetchStories = ({ status, sortDirection, }) => { let newFetchedIds = []; try { const statusArray = status.split(","); if (statusArray.length === 6) { newFetchedIds = STORIES_RESP.fetchedStoryIds; } else { Object.values(STORIES_RESP.stories).forEach( ({ status: storyStatus, id }) => { if (statusArray.includes(storyStatus)) { newFetchedIds.push(id); } } ); } if (sortDirection && sortDirection === "desc") { newFetchedIds = newFetchedIds.reverse(); } return Promise.resolve({ ...STORIES_RESP, fetchedStoryIds: newFetchedIds, //stories: newStories, }); } catch (err) { return Promise.reject( err ); } }; export default fetchStories; ``` Similar to how `status` and `sortOrder` in used in the code sample you can add other filter based on parameters passed to `fetchStories`. See [API callbacks](./api-callbacks.md) for more information. ## Step 4: Adding a custom settings page You can add a custom page or an external link to the dashboard's menu by passing required values to `config` and `InterfaceSkeletons`'s prop `additionalRoutes`. Below is a code sample doing just that. ```js import { PageHeading, Layout } from "@googleforcreators/dashboard"; export function EditorSettings() { return (
{"Settings"}
); } export const leftRailRoutes = [ { value: "/settings", label: "Settings", }, { value: `https://googleforcreators.github.io/web-stories-wp/storybook/iframe.html?id=playground-dashboard--default&args=&viewMode=story#/`, label: "External link", isExternal: true, }, ]; ``` Now let's pass the required values to `config` and `additionalRoutes`. ```js import { Dashboard, InterfaceSkeleton } from "@googleforcreators/dashboard"; import fetchStories from "./fetchStories"; import { leftRailRoutes, EditorSettings } from "./settings"; const CustomDashboard = () => { const apiCallbacks = { fetchStories, }; return ( , }, ]} /> ); }; export default CustomDashboard; ``` ================================================ FILE: docs/third-party-integration/story-editor/README.md ================================================ # Story Editor * [Getting Started](./getting-started.md) * [API Callbacks](./api-callbacks.md) * [Integration Layer](./integration-layer.md) * [Tutorial](./tutorial.md) ================================================ FILE: docs/third-party-integration/story-editor/api-callbacks.md ================================================ # API Callbacks Similar to the dashboard, side effects are added to the Story Editor by defining callbacks. One such callback is `saveStoryById` which is used to save stories and is the only required callback. Other callbacks can be used to add or handle functionalities like first-party media and custom page templates etc. All API callbacks can be asynchronous and should eventually resolve to their corresponding expected responses. Below is a list of available API callbacks categorized in different sections. ## Story Editing ### `saveStoryById` The only required callback, that is used for saving stories. Arguments - `storyState` : - type: `Object` - description: The current state of the story. The required shape of the object is described in the table below. - `storyId` - type: `number` - description: Unique id for the story. - `title` - type: `string` - description: Tittle of the story. - `excerpt` - type: `string` - description: Short description of the story. - `version` - type: `number` - description: Version of the story saved ( latest is `DATA_VERSION` from @googleforcreators/migration). - `currentStoryStyles` - type: `Object` - description: Saved styles available the current story (`colors`). - `colors` - type: `array` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `globalStoryStyles` - type: `Object` - description: Saved styles available to all stories (`colors` & `textStyles`). - `colors` - type: `array` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `textStyles` - type: `array` - description: Saved text styles. See [text style shape](#text-style-shape) for more detail. - `pages` - type: `array` - description: Array of page objects in the story. See [page object shape](#page-object-shape) #### Page object shape - `elements` - type: `array` - description: Array of elements in this page. - `backgroundColor` - type: `Object` - description: Background color of this page. - `color` - type: `Object` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `type` - type: `string` - description: Page type. - `id` - type: `string` - description: Unique id for this page. ### Text style shape - `backgroundColor` - type: `Object` - description: Background color of saved text style. - `color` - type: `Object` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `backgroundTextMode` - type: `Object` - description: Background text mode. - `font` - type: `Object` - description: Object defining font style. See [font object](#font-object-shape) shape for more detail. - `fontSize` - type: `number` - description: Font size in pixels. - `lineHeight` - type: `number` - description: Line height in pixels - `padding` - type: `Object` - description: Data about padding - `locked` - type: `boolean` - description: used to lock padding manipulation other styles - `hasHiddenPadding` - type: `boolean` - description: - `textAlign` - type: `string` - description: Text alignment. - `color` - type: `Object` - description: Font color. - `color` - type: `Object` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `fontWeight` - type: `number` - description: Font weight. - `isItalic` - type: `boolean` - description: Flag to denote the text is italic. - `isUnderline` - type: `boolean` - description: Flag to denote the text has an underline. - `letterSpacing` - type: `number` - description: Letter spacing in pixels. Expected response An `Object` with the following shape - `storyId` - type: `number` - required: No - description: Unique id for the story. - `title` - type: `string` - required: No - description: Short description of the story. - `excerpt` - type: `string` - require: No - description: Short description of the story. - `version` - type: `number` - require: No - description: Version of the story saved. - `currentStoryStyles` - type: `Object` - require: No - description: Saved styles available in the current story (`colors`). - `colors` - type: `array` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `globalStoryStyles` - type: `Object` - require: No - description: Saved styles available to all stories (`colors` & `textStyles`). - `colors` - type: `array` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `textStyles` - type: `array` - description: Saved text styles. See [text style shape](#text-style-shape) for more detail. - `pages` - type: `array` - require: No - description: Array of page objects in the story. See [page object shape](#page-object-shape) for more details. Example ( Expected response ) ```json { "storyId": 1, "title": "Epic story", "excerpt": "A totally epic story", "version" : 39, "currentStoryStyles": { "colors": [] }, "globalStoryStyles": { "textStyles": [], "colors": [] }, "pages" :[] } ``` ### `getStoryById` Fallback of `initialEdits` prop of `StoryEditor` component. If `initialEdits` prop is undefined, this callback will be used to populate the initial story. Arguments - `storyId` : - type: `number ` - description: Unique id of a story that needs to be loaded. Expected response - `title` - type: `Object` - required: No - description: Tittle of the story. - `excerpt` - type: `Object` - required: No - description: Story description of the story. - `storyData` - type: `Object` - required: No - description: Story data. - `version` - type: `number` - required: Yes - description: Version of the story shape used. - `pages` - type: `array` - require: No - description: Array of page objects in the story. See [page object shape](#page-object-shape) for more details. - `autoAdvance` - type: `boolean` - required: No - description: Flag to denote if pages of the story advances automatically. - `defaultPageDuration` - type: `number` - required: No - description: Duration after which a page auto advances to a new page in seconds. - `currentStoryStyles` - type: `Object` - require: No - description: Saved styles available in the current story (`colors`). - `colors` - type: `array` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `backgroundAudio` - type: `Object` - required: No - description: Details about background audio. - `resource` - type: `Object` - required: Yes - description: Details about background audio. - `id` - type: `number` - required: Yes - description: Id of the resource. - `length` - type: `number` - required: Yes - description: Length of the audio file in seconds. - `lengthFormatted` - type: `string` - required: Yes - description: Length of the audio file formatted as MM:SS. - `mimeType` - type: `string` - required: Yes - description: Mime type of the audio file. - `src` - type: `string` - required: Yes - description: URL of the audio file. - `author` - type: `Object` - required: No - description: Details of author who created the story. - `id` - type: `number` - required: Yes - description: Author id. - `name` - type: `string - required: Yes - description: Author name. - `stylePresets` - type: `Object` - required: No - description: Saved style Presets. Example ( Expected response ) ```json { "title": { "raw": "Epic story" }, "excerpt": { "raw": "A totally epic story" }, "storyData": { "version": 39, "pages": [], "autoAdvance": true, "defaultPageDuration": 7, "currentStoryStyles": {}, "backgroundAudio": {} }, "author": { "id": 1, "name": "John Doe" }, "stylePresets": { "colors": [], "textStyles": [] } } ``` ## Custom Page Templates In the element library, there is a section for page templates, by default it only serves pre-built templates. You can enable users to save and use their own custom page templates by defining these 3 callbacks. ### `getCustomPageTemplates` For fetching custom page templates. Arguments - `page` : - type: `number ` - description: Page number for a set of template. - `searchTerm` - type: `string` - description: Search string entered by the user. Expected response An array of the template objects whose shape is described below. #### Template object shape - `templateId` - type: `number` - required: Yes - description: Template id. - `version` - type: `string` - required: Version of the story shape used ( `DATA_VERSION` ) - description: Details of author who created the story. - `elements` - type: `array` - required: Yes - description: Array of elements used in the template. See [element object](#element-object-shape). - `backgroundColor` - type: `Object` - required: Yes - description: Background color RGB values. - `color` - type: `Object` - `r` - type: `number` - description: Red value. - `g` - type: `number` - description: Blue value. - `b` - type: `number` - description: Green value. - `type` - type: `string` - required: Yes - description: Template type. - `id` - type: `string` - required: Yes - description: Page id. - `image` - type: `Object` - required: Yes - description: Placeholder image data. -`id` - type: `string` - required: Yes - description: Image id. -`height` - type: `string` - required: Yes - description: Height of the placeholder image. -`width` - type: `string` - required: Yes - description: Width of the placeholder image. -`url` - type: `string` - required: Yes - description: Link to the placeholder image. #### Element object shape - `opacity` - type: `number` - description: Opacity of element in percent. - `flip` - type: `Object` - description: Flip data of the element. - `vertical` - type: `boolean` - description: Vertical flip. - `horizontal` - type: `boolean` - description: horizontal flip. - `rotationAngle` - type: `number` - description: Rotation angle of the element. - `lockAspectRatio` - type: `boolean` - description: Flag to lock aspect ratio. - `scale` - type: `number` - description: Scale of element in percent. - `focalX` - type: `number` - description: X focal point in percent. - `focalY` - type: `number` - description: Y focal point in percent. - `resource` - type: `Object` - description: Element resource data. Same as [media object shape](#media-object-shape) - `type` - type: `string` - description: Element type. - `x` - type: `number` - description: X coordinate of the element on canvas in pixels. - `y` - type: `number` - description: Y coordinate of the element on canvas in pixels. - `width` - type: `number` - description: Width of the element in pixels. - `height` - type: `number` - description: Height of the element in pixels. - `mask` - type: `Object` - description: Details about mask over the element. - `type` - type: `string` - description: Mask type. - `showInLibrary` - type: `boolean` - description: - `name` - type: `string` - description: Mask name. - `path` - type: `string` - description: Mask path. - `ratio` - type: `number` - description: Mask ratio. - `supportsBorder` - type: `boolean` - description: - `id` - type: `string` - description: Unique id for this element. Example ( Expected response ) ```json { "templateId": 358, "version": 39, "elements": [], "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "type": "page", "id": "0d009686-a8d7-4cd9-a076-80e3e56efb68", "image": { "id": 0, "height": 0, "width": 0, "url": "https://url-to-image" } } ``` ### `addPageTemplate` For adding custom page templates. Arguments - `template` : - type: `Object` - description: See [Template Object Shape](#template-object-shape) for object shape. Expected response - `void` ### `deletePageTemplate` For deleting custom page templates. Arguments - `templateId` : - type: `number` - description: Template Id. Expected response - `void` ## First party Media Support Adding usage of first-party media for the user includes defining a few callbacks listed below and a component `MediaUpload` ### `getMedia` Fetches first-party media in the element library. Arguments - `params` : - type: `Object` - description: Describes search or filter parameters which includes `mediaType`, `searchTerm`& `pagingNum` - `mediaType` - type: `string` - description: Media type requested by the user. One of `image`, `video` or `gif` - `searchTerm` - type: `string` - description: Search string entered by the user. - `pagingNum` - type: `string` - description: Page number Expected response - An array of **Media Object** #### Media Object Shape - `baseColor` : - type: `string` - required: No - description: Dominant color in hexadecimal format. - `blurHash` : - type: `string` - required: No - description: Blur hash string of the media. - `type` : - type: `string` - required: Yes - description: Type of media object. One of `image`, `video` or `gif`. - `mimeType` : - type: `string` - required: Yes - description: Media mime type. - `creationDate` : - type: `string` - required: No - description: Media creation date in UTC format. - `src` : - type: `string` - required: Yes - description: Media source URL. - `width` : - type: `number` - required: Yes - description: Media width in pixels. - `height` : - type: `number` - required: Yes - description: Media height in pixels. - `id` : - type: `number` - required: Yes - description: Media id. - `alt` : - type: `string` - required: No - description: Media Alt text. - `sizes` : - type: `Object` - required: Yes - description: Map of media in various sizes (thumbnail, medium, full). - `file` - type: `string` - required: Yes - description: Width of this particular size. - `width` - type: `number` - required: Yes - description: Width of this particular size. - `height` - type: `number` - required: Yes - description: Height of this particular size. - `mimeType` - type: `string` - required: Yes - description: Mime type of this particular size. - `sourceURL` - type: `string` - required: Yes - description: Link for this particular size. - `isPlaceholder` : - type: `boolean` - required: No - description: Flag to denote if media is placeholder image. - `isOptimized` : - type: `boolean` - required: No - description: Flag to denote if media is optimized. - `isMuted` : - type: `boolean` - required: No - description: Flag to denote if media is muted. - `isExternal` : - type: `boolean` - required: No - description: Flag to denote if media is external. - `needsProxy` : - type: `boolean` - required: No - description: Flag to denote if getting media requires proxy. Example ( Expected response ) ```json { "baseColor": "#ffffff", "blurHash": "UmJ*SDxtIVWB~VayRkj[M|Rkj[ofM|WBWBay", "type": "image", "mimeType": "image/jpeg", "creationDate": "2022-02-08T11:13:06", "src": "http://webstories.local/wp-content/uploads/2022/02/laptop_man.jpg", "width": 367, "height": 267, "id": 251, "alt": "laptop_man", "sizes": { "medium": { "file": "laptop_man-300x218.jpg", "width": 300, "height": 218, "mimeType": "image/jpeg", "sourceUrl": "http://webstories.local/wp-content/uploads/2022/02/laptop_man-300x218.jpg" }, "thumbnail": { "file": "laptop_man-150x150.jpg", "width": 150, "height": 150, "mimeType": "image/jpeg", "sourceUrl": "http://webstories.local/wp-content/uploads/2022/02/laptop_man-150x150.jpg" }, "full": { "file": "laptop_man.jpg", "width": 367, "height": 267, "mimeType": "image/jpeg", "sourceUrl": "http://webstories.local/wp-content/uploads/2022/02/laptop_man.jpg" } }, "isPlaceholder": false, "isOptimized": false, "isMuted": false, "isExternal": false, "needsProxy": false } ``` ### `getMediaById` Used to get media for video trim functionality Arguments - `id` : - type: `number` - description: Media id. Expected response - **Media Object** (see [Media Object Shape](#media-object-shape)) ### `getMutedMediaById` Used to get muted version of a video Arguments - `id` : - type: `number` - description: Media id. Expected response - **Media Object** (see [Media Object Shape](#media-object-shape)) ### `getOptimizedMediaById` Used to get optimized version of a video Arguments - `id` : - type: `number` - description: Media id. Expected response - **Media Object** (see [Media Object Shape](#media-object-shape)) ### `updateMedia` The story editor calculates and updates data about media elements stored in the back-end using these callbacks. Users can also update data if required interface is added. Out of the box users can only update `altText`. Arguments - `id` : - type: `number` - description: Media id. - `data` : - type: `Object` - description: Updated data of a media object. - `posterId` : - type: `number` - description: ID of the poster image element of this media element. - `storyId` : - type: `number` - description: ID of the story in which a video element's poster was generated. - `isMuted` : - type: `boolean` - description: Flag to identify if this element has audio. - `mutedId` : - type: `number` - description: ID of the muted version of this media element. - `mediaSource` : - type: `string` - description: Source from which the media was uploaded. One of `source-image`, `source-video`. - `optimizedId` : - type: `number` - description: ID of optimized version of this media element. - `altText` : - type: `string` - description: Alt text for media. - `baseColor` : - type: `string` - description: Calculated base color (most prominent color) for media element. - `blurHash` : - type: `string` - description: Calculated blur hash string for media element. Expected response - `void` ### `uploadMedia` Called when editor uploads - - Poster image for a video. - Poster image for a story. - Poster image for a template. - Altered media (muted, trimmed, optimized or converted to GIF) After uploading altered media Story editor calls `updateMedia` to update original version of altered media. Arguments - `file` : - type: `blob` - description: Media File to upload. - `data` : - type: `Object` - description: Updated data of a media object. - `originalId` : - type: `number` - description: ID of a media of which this element is an altered version of. - `mediaId` : - type: `number` - description: ID of a video of which this element is a poster. - `storyId` : - type: `number` - description: ID of a story of which this element is a poster. - `templateId` : - type: `number` - description: ID of a template of which this element is a poster. - `isMuted` : - type: `boolean` - description: flag to identify if this element has audio. - `mediaSource` : - type: `string` - description: Upload source. One of `video-optimization`, `editor`, `poster-generation`,`gif-conversion`or `page-template`. - `trimData` : - type: `Object` - description: `TrimData` data object linking a trimmed video to its original. - `start` - type: `string` - description: Start Time stamp of start time of new video. Example '00:01:02.345'. - `end` - type: `string` - description: End Time stamp of end time of new video. Example '00:02:00'. - `baseColor` : - type: `string` - description: Calculated base color (most prominent color) for media element. - `blurHash` : - type: `string` - description: Calculated blur hash string for media element. Expected response - `null` ### `deleteMedia` For deleting any media Arguments - `id` : - type: `number` - description: ID of a media element which needs to be deleted. Expected response - `null` ### `MediaUpload` component `MediaUpload` component is a modal rendered which will be used to render media upload button and open a media upload modal when a user instantiates a media upload by clicking previously mentioned button. This modal should provide the user to insert any media already with the CMS or upload new items. - `title` : - type: `string` - required: No - description: Title for the modal. - `buttonInsertText` : - type: `string` - required: No - description: Text to use for the "Insert" button. - `onSelect` : - type: `function` - required: Yes - description: Selection callback. Used to process the inserted image. - `onSelectErrorMessage` : - type: `function` - required: Yes - description: Text displayed when incorrect file type is selected. - `onClose` : - type: `function` - required: Yes - description: Modal close callback. - `onPermissionError` : - type: `function` - required: No - description: Callback for when user does not have upload permissions. - `type` : - type: `array` - required: Yes - description: Array of allowed mime types modal should present or accept when a user uploads a new file. - `multiple` : - type: `boolean` - required: No - description: Whether multi-selection should be allowed. - `cropParams` : - type: `Object` - required: No - description: Width and height for cropped images. - `render` : - type: `function` - required: Yes - description: React functional component responsible for rendering required media upload button. Takes a callback which should instantiate the modal for media upload. ```jsx function MediaUpload({ buttonInsertText, onSelect, onSelectErrorMessage, onClose, onPermissionError, type, multiple, cropParams, render, }) { const openModal = () =>{ // routine to open modal // this modal will call callbacks passed to MediaUpload according to the context. } return render(openModal); } ``` ## Adding Custom Fonts Customize the Story editor for users to use with custom fonts. ### `getFonts` Used to get fonts Arguments - `params` - type: `Object` - description: Filter parameters for fetching fonts. - `search` - type: `string` - description: Search string entered by the user. - `service` - type: `string` - description: One of `builtin` or `custom`. - `include` - type: `string` - description: Comma separated names for font in the curated list. Expected response - Array of **Font Object** #### Font Object Shape - `id` - type: `string` - required: No - description: Unique ID of the font. - `name` - type: `string` - required: No - description: Name of the font. - `value` - type: `string` - required: No - description: Font value. - `family` - type: `string` - required: Yes - description: Family of the font. - `fallbacks` - type: `array` - required: No - description: Array of fallback font names. - `weights` - type: `array` - required: No - description: Array of available font weight. - `variants` - type: `array>` - required: No - description: Array of variant tuple. - `service` - type: `string` - required: Yes - description: URL to font service which hosts the font. - `metrics` - type: `Object` - required: No - description: Font metrics. Example ( Expected response ) ```json { "id": "Alegreya", "name": "Alegreya", "value": "Alegreya", "family": "Alegreya", "fallbacks": [ "serif" ], "weights": [ 400 ], "styles": [ "regular", "italic" ], "variants": [ [0,400] ], "service": "fonts.google.com", "metrics": { "upm": 1000, "asc": 1016, "des": -345, "tAsc": 1016, "tDes": -345, "tLGap": 0, "wAsc": 1123, "wDes": 345, "xH": 452, "capH": 637, "yMin": -293, "yMax": 962, "hAsc": 1016, "hDes": -345, "lGap": 0 } } ``` ## Hotlinking media Add support for inserting media by URL in addition to or instead of uploading files. ### `getHotlinkInfo` Used to get data about links while hot linking media in media pane. Arguments - `url` - type:`string` - description: External link. Expected response - `ext` - type:`string` - required: Yes - description: File extension. - `mimeType` - type:`string` - required: Yes - description: File mime type. - `type` - type:`string` - required: Yes - description: File type. One of `image` or `video`. - `fileName` - type:`string` - required: Yes - description: File name. Example ( Expected response ) ```json { "ext": "jpg", "mimeType": "image/jpeg", "type":"image", "fileName": "file_name.jpg" } ``` ## Adding Links to story elements Links can be added to any elements in the Story editor through design panel, the only requirement being defining the callback `getLinkMetadata` ### `getLinkMetadata` Arguments - `url` - type: `string` - description: External Link. Expected response - `title` - type: `string` - required: Yes - description: OG title. - `icon` - type: `string` - required: Yes - description: OG image. Example ( Expected response ) ```json { "title": "link to external", "icon": "https://link-to-icon" } ``` ## Adding user capabilities Caters Story editor with different capabilities on per-user basis ### `getCurrentUser` Fetch details about the current user. Arguments None Expected response - `id` - type: `number` - required: Yes - description: Current user id. - `trackingOptin` - type: `boolean` - required: Yes - description: Flag to know if the user has opted in for tracking. - `onboarding` - type: `boolean` - required: Yes - description: Flag to know if the user needs to be shown onboarding. - `mediaOptimization` - type: `boolean` - required: Yes - description: Flag to know if the user can upload optimized media. Example ( Expected response ) ```json { "id": 1, "trackingOptin": true, "onboarding": false, "mediaOptimization": true } ``` ### `updateCurrentUser` Update details about the current user. Arguments - `id` - type: `number` - description: User Id. - `data` - type: `Object` - description: Data to be updated. - `trackingOptin` - type: `boolean` - description: Flag to know if the user has opted in for tracking. - `onboarding` - type: `boolean` - description: Flag to know if the user needs to be shown onboarding. - `mediaOptimization` - type: `boolean` - description: Flag to know if the user can upload optimized media. Expected response - `null` ================================================ FILE: docs/third-party-integration/story-editor/getting-started.md ================================================ # Getting Started The following documentation explains how to integrate the visual story editor with any platform. The documentation is divided into 3 sections: - Getting Started - This current doc provides a setup guide to instantiate a story editor with bare minimum functionality and explains significant UI elements of the editor. - [Standalone Editor Tutorial](./tutorial.md) - A tutorial to create a standalone story editor which works without a CMS. - [Integration Layer API](./integration-layer.md) - A comprehensive guide on the integration layer of the story editor ## Minimal Setup Guide You can quickly spin up a story editor in a few steps described below. ### Step 1: Set up a React project The easiest way of setting up a simple React project is by using [Create React App](https://create-react-app.dev/) or templates such as [react-webpack-babel-starter](https://github.com/vikpe/react-webpack-babel-starter). ### Step 2: Install dependencies For a minimal story editor, you need to install three packages ```sh npm install @googleforcreators/story-editor @googleforcreators/element-library @googleforcreators/elements ``` ### Step 3: Use the `StoryEditor` component to render the editor The code sample given below shows how to scaffold a story editor. ```jsx import { StoryEditor, InterfaceSkeleton } from '@googleforcreators/story-editor'; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ); }; export default Editor; ``` Note: You may have to wrap the editor in `
`, if the parent container doesn't have any height set. You should now have a story editor that looks like this: ![editor](https://user-images.githubusercontent.com/841956/159525789-9c669dc1-78a4-473b-a30f-6bf0bd72cc8b.png) To learn more about the individual aspects of the story editor UI and the components it's comprised of, check out the [Web Stories for WordPress user documentation](https://wp.stories.google/docs/) as a reference point. For a more in-depth example of setting up a custom story editor, check out the more in-depth [Standalone Editor Tutorial](./tutorial.md) ================================================ FILE: docs/third-party-integration/story-editor/integration-layer.md ================================================ # Integration Layer The story editor can be integrated with any platform by configuring the `StoryEditor` and other components like `InterfaceSkeleton`. This section of the documentation gives a comprehensive guide on what aspects of the story editor can be modified and how to modify them. As seen in the [Getting Started](./getting-started.md) guide, a minimal story editor can be created by using the two main components `StoryEditor` and `InterfaceSkeleton` like below. ```js import { StoryEditor, InterfaceSkeleton } from '@googleforcreators/story-editor'; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ); }; ``` ## `StoryEditor` This is the top level component of the story editor which has all the provider components but doesn't render any UI itself. **Props:** - `config` - type: `Object` - required: Yes - description: Used for most of the editor configuration. See the [Editor Config](#editor-config) section below for full detail. - `initialEdits` - type: `Object` - required: Yes - description: The initial edits/state of the story editor. Currently it only supports `story`. - `story` - type: `Object` - required: Yes - description: The story editor needs initial story when it loads. You can either provide the initial story object from this prop or via `getStoryById` API callback. See the [expected response](./api-callbacks.md#getstorybyid) of `getStoryById` for the shape of this object. ## `InterfaceSkeleton` This component is responsible for rendering the story editor interface UI and can be configured by using multiple props. Please look at the [InterfaceSkeleton](#interfaceskeleton) section below for detailed documentation of this component. **Props:** - `header` - type: `React.ReactElement` - required: No - description: Used for rendering the workspace header. - `footer` - type: `Object` - required: No - description: Used for rendering some parts of the footer. - `sidebarTabs` - type: `Object` - required: No - description: Used for rendering the sidebar tabs of the story editor. ## Editor Config To configure the editor to your needs you can pass various config options to the story editor via `config` prop of the `StoryEditor` component. ### `apiCallbacks` - type: `Object` - description: Takes various callback functions for story editor's side effects. The only required API callback is `saveStoryById`. For detailed documentation of `apiCallbacks`, see [API Callbacks](./api-callbacks.md) section. ### `additionalTips` - type: `array` - description: Used to provide additional tips in editor help center. - example : ```js const additionalTips = [{ title: 'Example Tip Title', figureSrcImg: 'http://link/to/image', figureAlt: 'Figure alt text', description: [ 'This is an example tip used for testing. Learn more', ], href: 'https://external/link', }] ``` You can also provide an external link in the description of the tip. ### `allowedMimeTypes` - type: `Object` - description: A map of file mime types accepted by element library. - example : ```js const allowedMimeTypes = { audio: ['audio/mpeg', 'audio/aac', 'audio/wav', 'audio/ogg'], image: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'], caption: ['text/vtt'], vector: ['image/svg+xml'] }; ``` ### `autoSaveInterval` - type: `number` - description: Time Interval (in seconds) after which story editor automatically saves a story by calling `saveStoryById`. ### `localAutoSaveInterval` - type: `number` - description: Time Interval (in seconds) after which story editor automatically saves the story data into sessionStorage. ### `canViewDefaultTemplates` - type: `boolean` - description: Flag to allow for enabling default page template in the editor. ### `capabilities` - type: `Object` - description: Controls story editor's capabilities, currently 2 capabilities can be customized. - `hasMediaUploadAction` - type: `boolean` - description: Allow media upload. - `canManageSettings` - type: `boolean` - description: Allow visiting settings page on the dashboard. - example : ```js const capabilities = { "hasUploadMediaAction": true, "canManageSettings": true } ``` ### `cdnURL` - type: `string` - description: URL to element resources. ### `dashboardLink` - type: `string` - description: URL to story editor's dashboard. ### `encodeMarkup` - type: `boolean` - description: Flag to toggle markup generation in story data. ### `ffmpegCoreUrl` - type: `string` - description: URL to `ffmpeg` core required for optimizing uploaded media. ### `flags` - type: `Object` - description: Many experimental features can be enabled/disabled in the story editor. For a current list of existing flags, check out [`Experiments.php`](https://github.com/GoogleForCreators/web-stories-wp/blob/main/includes/Experiments.php). ### `generalSettingsLink` - type: `string` - description: URL for settings page of the dashboard ### `isRTL` - type: `boolean` - description: switches all styles to accommodate RTL languages. ### `locale` - type: `Object` - description: locale data - `locale` - type: `string` - description: Locale code. - `dateFormat` - type: `string` - description: Date format used by the editor. - `timeFormat` - type: `string` - description: Time format used by the editor. - `gmtOffset` - type: `string` - description: GMT offset of user's location - `timezone` - type: `string` - description: User's time zone - `months` - type: `array` - description: Ordered array of month names. - `monthsShort` - type: `array` - description: Ordered array of shortened month names. - `weekdays` - type: `array` - description: Ordered array of weekday names. - `weekdaysShort` - type: `array` - description: Ordered array of shortened weekday names. - `weekdaysInitials` - type: `array` - description: Ordered array of weekday initial. - `weekStartsOn` - type: `number` - description: Index of the first day of the week in `weekdays` array - example : ```js const locale ={ "locale": "en-US", "dateFormat": "F j, Y", "timeFormat": "g:i a", "gmtOffset": "-4", "timezone": "America/New_York", "months": [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], "monthsShort": [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ], "weekdays": [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ], "weekdaysShort": [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ], "weekdaysInitials": [ "S", "M", "T", "W", "T", "F", "S" ], "weekStartsOn": 1 } ``` ### `maxUpload` - type: `number` - description: Size limit on uploaded media in bytes. ### `MediaUpload` - type: `React.ReactElement` - description: React component used for rendering media upload modal. See [First party Media Support](./api-callbacks.md#First-party-Media-Support) ### `showMedia3p` - type: `boolean` - description: Flag used to enable or disable third party media usage. ### `storyId` - type: `number` - description: ID of the current story being rendered by the editor. ### `styleConstants` - type: `Object` - description: Style constants for story editor modals. - `topOffset` - type: `number` - description: Top offset for modal overlay. - `leftOffset` - type: `number` - description: Left offset for modal overlay. ## InterfaceSkeleton Many aspects of the editor can be customized by adding custom components through A component called `InterfaceSkeleton` from `@googleforCreators/story-editor`. This section will give details about what aspects of the story editor can be modified by custom components. Before reading this you might want to check out [Getting Started](./getting-started.md) to know about different visual components of the editor. ## Workspace ### Header Editor Workspace has reserved space to render a custom header. This can be used to provide custom UI elements for users to interact with. Although you can use custom UI elements in the header, it is advised to use elements provided in `@googleForCreators/design-system`. That will guarantee design consistency and compatibility with the `RTL` layout. An example use case is available in [Standalone Editor Tutorial](./tutorial.md) where a few buttons have been added to the header. ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const CustomHeader = () => (
{" Custom Header "}
); const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return (
} />
); }; ``` ### Footer Editor workspace also has a footer space, but unlike header space, this can only be partially modified. Modifications include adding a footer to the Help center Menu and adding a pre-publish checklist. ### Adding a footer to Help Center Adding a footer to the help center is straightforward, just pass a component to the `InterfaceSkeleton` component as shown below. ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const CustomFooter = () =>
{" Custom Help Center Footer "}
; const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return (
); }; ``` ### Adding a pre-publish checklist You can also initialize a checklist to assist the user by offering design suggestions. There are many pre-built checks in `@googleForCreators/story-editor` for you to use. It's also possible to add additional checks if needed. The application accepts 3 categories of checks, below is the list of all categories with corresponding pre-built checks available for initializing the checklist. Accessibility Checks : - `PageBackgroundTextLowContrast` - `TextElementFontSizeTooSmall` - `VideoElementMissingDescription` - `VideoElementMissingCaptions` - `ElementLinkTappableRegionTooSmall` - `ElementLinkTappableRegionTooBig` - `ImageElementMissingAlt` Design Checks : - `StoryPagesCount` - `PageTooMuchText` - `PageTooLittleText` - `PageTooManyLinks` - `VideoElementResolution` - `ImageElementResolution` - `FirstPageAnimation` Priority Checks : - `StoryMissingTitle` - `StoryMissingPublisherName` - `StoryTitleLength` - `StoryMissingExcerpt` - `StoryPosterAttached` - `StoryPosterSize` - `PublisherLogoMissing` - `PublisherLogoSize` - `VideoElementMissingPoster` - `VideoOptimization` - `StoryAmpValidationErrors` #### Using pre-built checks ```jsx import { StoryEditor, InterfaceSkeleton, PageBackgroundTextLowContrast, TextElementFontSizeTooSmall, StoryPagesCount, PageTooMuchText, StoryMissingTitle, StoryTitleLength, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const Accessibility = () => ( <> ); const Design = () => ( <> ); const Priority = () => ( <> ); const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ); }; ``` #### Implementing a custom check Below is an example of a custom check, this check will pop up in the checklist if story has less than 2 pages. ```jsx import { ChecklistCard, DefaultFooterText, useRegisterCheck, useIsChecklistMounted, useStory, } from "@googleforcreators/story-editor"; const LessThan2PageCheck = () => { const isChecklistMounted = useIsChecklistMounted(); const { pagesLength } = useStory(({ state: { pages } }) => ({ pagesLength: pages.length, })); const hasLessThan2Pages = pagesLength < 2; useRegisterCheck("LessThan2PageCheck", hasLessThan2Pages); return ( LessThan2PageCheck && isChecklistMounted && ( {"Please add more pages"} } /> ) ); }; export { LessThan2PageCheck }; ``` ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; import React, { useState } from "react"; import { LessThan2PageCheck } from "./footer/checks"; const CustomDesignChecklist = () => ( <> ); const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ); }; ``` ## Enabling Shopping Tab The shopping tab allows inserting a product attachment with a draggable drawer that links to the product URL. To enable the shopping tab, you need to make a couple of changes. In the `StoryEditor` config, add `isShoppingEnabled: true` and `shoppingProvider: 'None'`. If you have a shopping provider like WooCommerce or Shopify you can add 'Woocommerce' or 'Shopify' instead of 'None'. ```jsx } sidebarTabs={{ document: { title: 'Document', Pane: DocumentPane, }, }} /> ``` Next, provide `getProducts` in `apiCallbacks`: ```jsx const getProducts = () => { const products = [ { productId: "1", productBrand: "Nike", productDetails: "Random Nike sneaker", productImages: [ { alt: "Image", url: "https://static.nike.com/a/images/c_limit,w_592,f_auto/t_product_v1/4f37fca8-6bce-43e7-ad07-f57ae3c13142/air-force-1-07-shoes-WrLlWX.png", }, ], productPrice: 1000, productPriceCurrency: "INR", productTitle: "Nike Sneaker", productUrl: "https://www.nike.com/in/t/air-force-1-07-shoes-WrLlWX/315122-111", }, ]; return Promise.resolve({ products }); }; export default getProducts; ``` After configuring these, you will see a shopping bag icon which will hold products you return from the `getProducts` function. Clicking on the product will insert an attachment. ## Sidebar Tabs ### Document Pane The story editor can have an additional Document pane alongside the Insert and Style panes, which is meant to provide UI elements for editing data about the story as a whole. The Document pane can be configured as shown below. ```jsx import { StoryEditor, InterfaceSkeleton, } from '@googleforcreators/story-editor'; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const CustomDocumentPanel = ()=>(
{" Custom Document Panel "}
) const Editor = () =>{ const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ) } ``` ================================================ FILE: docs/third-party-integration/story-editor/tutorial.md ================================================ # Standalone Editor Tutorial This tutorial explains how to create a standalone story editor using React that works without any CMS. It encompasses saving stories to the browser's local storage as well as previewing created stories. ## Step 1: Setting up the editor with minimum config You can bootstrap your React project using something like [Create React App](https://create-react-app.dev/) or similar. Let's start with installing the required dependencies. ```sh npm install @googleforcreators/story-editor @googleforcreators/design-system @googleforcreators/migration @googleforcreators/elements @googleforcreators/element-library ``` The following code block scaffolds a minimal story editor: ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; const Editor = () => { const apiCallbacks = { saveStoryById: () => Promise.resolve({}), }; elementTypes.forEach(registerElementType); return ( ); }; export default Editor; ``` ## Step 2: Saving stories in local storage We will now define the API callback `saveStoryById` which is invoked by the editor while saving a story. For details about other API callbacks accepted by the story editor, check out the [API Callbacks](./api-callbacks.md) section. The following code block is given to show an example of how a story can be saved to the browser's local storage. ```jsx import { DATA_VERSION } from "@googleforcreators/migration"; const saveStoryById = ({ pages, globalStoryStyles, currentStoryStyles, content, title, }) => { const storySaveData = { title: { raw: title, }, storyData: { version: DATA_VERSION, pages, currentStoryStyles, }, stylePresets: globalStoryStyles, permalinkTemplate: "https://example.org/web-stories/%pagename%/", }; window.localStorage.setItem("STORY_CONTENT", JSON.stringify(storySaveData)); window.localStorage.setItem("STORY_MARKUP", content); return Promise.resolve({}); }; export default saveStoryById; ``` The `saveStoryById` callback expects an object which has data about the story (`pages`, `globalStoryStyles`, `currentStoryStyles`) which will be stored in the local storage. Story editor also creates markup for the story with the key `content`, which will also be saved in the local storage and will be used for creating a preview in later sections of the tutorial. Now let's pass down this callback to the editor and use the data stored in local storage to hydrate it. ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; import saveStoryById from "./saveStoryById"; const Editor = () => { const apiCallbacks = { saveStoryById, }; elementTypes.forEach(registerElementType); const content = window.localStorage.getItem("STORY_CONTENT"); const story = content ? JSON.parse(content) : {}; return ( ); }; export default Editor; ``` ## Step 3: Adding Save Button Now we can add a Save button which will call the `saveStoryById` callback from the previous step in order to save story data in local storage. The following code shows how to accomplish that by using components from `@googleforcreators/design-system`: ```jsx import { Button, BUTTON_SIZES, BUTTON_TYPES, BUTTON_VARIANTS, useSnackbar, } from "@googleforcreators/design-system"; import { useStory } from "@googleforcreators/story-editor"; function SaveButton() { const { isSaving, saveStory } = useStory( ({ state: { meta: { isSaving }, }, actions: { saveStory }, }) => ({ isSaving, saveStory, }), ); const { showSnackbar } = useSnackbar(); const handleSaveButton = () => { saveStory().then(() => { showSnackbar({ message: "Story Saved", }); }); }; return ( ); } export default SaveButton; ``` In the above code, `useStory` is a custom hook that exposes functionality to read and manipulate data of the story currently being edited. A state variable `isSaving` is being used to disable the save button when a story is being saved and the `saveStory` action is being used to actually save the story. The `useSnackBar` hook exposes a `showSnackBar` action to display a snackbar message in the editor. ### Header Now that we have a Save button, we can display it in the editor in a dedicated header area shown at the top of the workspace area. Here is how such a header component can be passed to the story editor in order to display the Save button: ```jsx import SaveButton from "./saveButton"; function HeaderLayout() { return (
); } export default HeaderLayout; ``` Passing the component to the editor: ```jsx import { StoryEditor, InterfaceSkeleton, } from "@googleforcreators/story-editor"; import { elementTypes } from '@googleforcreators/element-library'; import { registerElementType } from '@googleforcreators/elements'; import saveStoryById from "./saveStoryById"; import HeaderLayout from "./header"; const Editor = () => { const apiCallbacks = { saveStoryById, }; elementTypes.forEach(registerElementType); const content = window.localStorage.getItem("STORY_CONTENT"); const story = content ? JSON.parse(content) : {}; return ( } /> ); }; export default Editor; ``` ## Step 4: Adding Preview button Similar to the Save button in the previous step, we can add a new button that will open a new tab with the preview of a story. ```jsx import { Tooltip, useStory } from "@googleforcreators/story-editor"; import { Button, BUTTON_SIZES, BUTTON_TYPES, BUTTON_VARIANTS, Icons, } from "@googleforcreators/design-system"; function PreviewButton() { const { isSaving, saveStory } = useStory( ({ state: { meta: { isSaving }, }, actions: { saveStory }, }) => ({ isSaving, saveStory, }), ); const openPreviewLink = async () => { await saveStory(); const previewLink = window.origin + "/preview"; // Start a about:blank popup with waiting message until saving operation // is done. That way, we will not bust the popup timeout. try { const popup = window.open("about:blank", "story-preview"); if (popup) { popup.document.write(""); popup.document.write(""); popup.document.write("Generating the preview…"); popup.document.write(""); popup.document.write(""); popup.document.write("Please wait. Generating the preview…"); // Output "waiting" message. // Force redirect to the preview URL after 5 seconds. The saving tab // might get frozen by the browser. popup.document.write( ``, ); } } catch (e) { console.log(e); } }; return ( ); } export default PreviewButton; ``` This new button is very similar to the Save button, but it is styled a little differently and uses an icon (`Icons.Eye`) from the component library rather than a text label. Clicking this button will: 1. Store the story data and markup in local storage. 2. Open a new tab with some loading state text. 3. Redirect to the preview once the preview has been generated, or after a given timeout is reached. This button can then be displayed in the header area next to the Save button. ```jsx import SaveButton from "./saveButton"; import PreviewButton from "./previewButton"; function HeaderLayout() { return (
); } export default HeaderLayout; ``` A new route has to be introduced which will serve a preview of the story, for example using [React Router](https://reactrouter.com/) or a server-side solution. On this route, simply display story markup which was previously stored in the browser's local storage using `saveStoryById`. You can use the following code sample to describe a component which overrides page's HTML with the one in the local storage. ```jsx import { useEffect } from "@googleforcreators/react"; function Preview() { useEffect(() => { const content = window.localStorage.getItem("STORY_MARKUP"); if (content) { document.open(); // Note that the use of document.write for replacing the whole page is to quickly set up a preview for demo but may not be ideal for production. document.write(content); document.close(); } }, []); return null; } export default Preview; ``` This is it! You should now have a working story editor which is capable of saving stories and generating a preview. Check out the [Integration Layer API](./integration-layer.md) for a comprehensive documentation of the story editor's integration layer if you want to implement your own custom version. ================================================ FILE: docs/unit-tests.md ================================================ # Unit Tests ## PHP This project uses the [PHPUnit](https://phpunit.de/) testing framework to write unit and integration tests for the PHP part. Tests are run against a running WordPress instance through the [WordPress PHPUnit Test Suite](https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/). For improved cross version compatibility for PHPUnit, the [WP Test Utils](https://github.com/Yoast/wp-test-utils) is implemented. To run the full test suite, you can use the following command: ```bash npm run test:php ``` You can also just run test for a specific function or class by using something like this: ```bash npm run test:php -- --filter=Story_Post_Type ``` See `npm run test:php:help` to see all the possible options. ### Writing PHP Tests #### Useful Resources for PHP Tests * [Using the WordPress PHPUnit Test Suite](https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/) ## JavaScript [Jest](https://jestjs.io/) is used as the JavaScript unit testing framework. To run the full test suite, you can use the following command: ```bash npm run test:js ``` You can also watch for any file changes and only run tests that failed or have been modified: ```bash npm run test:js:watch ``` See `npm run test:js:help` to get a list of additional options that can be passed to the test runner. ### API The [Jest docs](https://jestjs.io/docs/en/getting-started) have a good introduction into how to write tests using Jest. [`jest-dom`](https://github.com/testing-library/jest-dom) and [`jest-extended`](https://github.com/jest-community/jest-extended) are used in the project to extend test assertions beyond Jest's defaults. **Attention**: both these libraries have a `toBeEmpty` assertion. If you want the one from `jest-dom`, use `toBeEmptyNode` instead. React components and hooks are tested using [Testing Library](https://testing-library.com/docs/intro). ### Test Utils * In the project, there are `testUtils` folders with custom test utilities like `renderWithTheme` that can be used to make writing tests easier. ### Custom Matchers The following custom matchers exist in the project: * `toBeValidAMP` * `toBeValidAMPStoryPage` * `toBeValidAMPStoryElement` ### Writing JavaScript Tests #### Useful Resources for JavaScript Tests * [Jest documentation](https://jestjs.io/docs/en/getting-started) * [DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro) * [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) * ["Which query should I use?"](https://testing-library.com/docs/guide-which-query) * [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) * [React Hooks Testing Library](https://react-hooks-testing-library.com/) * [`jest-dom` API](https://github.com/testing-library/jest-dom#custom-matchers) * [`jest-extended` API](https://github.com/jest-community/jest-extended#api) #### Naming Conventions **File Names**: If a component or function to be tested resides in `foo/bar/baz.js`, it is recommend to place the tests in `foo/bar/tests/baz.js`. There is no need for a `.test.js` file name suffix. **Test Names**: The test name should be a proper sentence, starting with `it`. This provides more readable test failures. Example: ```js describe('sum', () => { it('adds 1 + 2 to equal 3', () => { expect(sum(1, 2)).toBe(3); }); }); ``` This will be reported as "sum > (it) adds 1 + 2 to equal 3". ### Snapshot Testing Try to avoid using snapshots when rendering components works just as well. If needed, use `toMatchInlineSnapshot`. ### Code Coverage Use the following command to run code JavaScript tests with code coverage. ```bash npm run test:js:coverage ``` One tests ran successfully, the coverage report will automatically open in your browser. ================================================ FILE: docs/web-stories-embeds.md ================================================ # Web Stories Embeds Theme developers can add varying degrees of support for Web Stories based on their requirements. ## Customizer Integration This basic integration guide will walk you through the simplest integration steps with which you can integrate web stories in your theme within 5 minutes and a couple of lines of code. ### Add Theme Support for Web Stories Themes can opt into adding support for Web Stories, which will enable a new Customizer settings panel to control the appearance of stories in the theme. This step is mandatory to integrate web stories into the theme via the Customizer. Add the following code to the theme’s `functions.php` file to add support: ```php function mytheme_setup() { add_theme_support( 'web-stories' ); } add_action( 'after_setup_theme', 'mytheme_setup' ); ``` This is the minimal code which is required to get the "Web Stories" panel visible in the Customizer. This will work with the default settings options provided by the plugin, which can be changed. ![Customizer Web Stories Options](https://user-images.githubusercontent.com/6906779/112966823-3793ee00-9168-11eb-850f-aee953814217.png) ### Display Stories in Theme Based on the Customizer settings, a theme can display stories in appropriate place with the following code: ```php [ 'view_type' => [ 'enabled' => [ 'circles' ], // possible values: circles, grid, carousel, list. 'default' => 'circles', ], 'title' => [ 'enabled' => true, 'default' => true, ], 'excerpt' => [ 'enabled' => true, 'default' => false, ], 'author' => [ 'enabled' => true, 'default' => true, ], 'date' => [ 'enabled' => false, 'default' => true, ], 'archive_link' => [ 'enabled' => true, 'default' => true, 'label' => __( 'View all stories', 'web-stories' ), ], 'sharp_corners' => [ 'enabled' => false, 'default' => false, ], 'order' => [ 'default' => 'DESC', // or 'ASC'. ], 'orderby' => [ 'default' => 'post_date', // or post_title. ], 'circle_size' => [ 'default' => 150, // 80 to 200 ], 'number_of_stories' => [ 'default' => 10, ], 'number_of_columns' => [ 'default' => 2, ], 'image_alignment' => [ 'default' => is_rtl() ? 'right' : 'left', ], ], ] ); ``` ## Displaying Stories ### Template Tag If you want to display web stories anywhere in your theme, unrelated to customizer Web Story Option settings, you can use the following template tag. It will display 10 stories in grid format: ```php 'grid', // Possible values: circles, grid, carousel, list. 'number_of_columns' => 2, 'show_title' => false, 'show_author' => false, 'show_date' => false, 'show_excerpt' => false, 'show_archive_link' => false, 'sharp_corners' => false, 'archive_link_label' => __( 'View all stories', 'your-theme-slug' ), 'class' => 'your-class-name', ]; $args = [ 'posts_per_page' => 10, 'post_status' => 'publish', 'suppress_filters' => false, 'no_found_rows' => true, ]; \Google\Web_Stories\render_stories( $attrs, $args ); } ``` ## Custom Renderers Renderer classes are primarily responsible for Rendering Stories. Renderer classes must implement the [`Renderer` interface](../includes/Interfaces/Renderer.php). Generally, rendering decisions are taken based on the view type. The Web Stories plugin has two concrete implementations of `Renderer`: * **Generic Renderer** - Used for all view types, but Carousel and Circles views. * **Carousel Renderer** - Used for displaying stories in Carousel and Circles view mode. Both of these classes extend the abstract `Renderer` class which itself implements the `Renderer` interface and takes care of some common operations for those concrete implementations to avoid code repetition. ### Renderer Interface and Methods The `Renderer` interface is already depicted in the above section. Following is the description of methods: 1. `init()` This is used to perform any operations when the Renderer object is created. This method can be used to some essential properties, add hooks etc. 2. `render()` This method is used to render the stories and its markup. 3. `render_single_story_content()` This method should be used to return the markup of a single story. This is useful while we iterate over the list of stories to get the accumulative markup. ### Creating your own Renderer Classes If needed, you can write your own Renderer class to generate custom markup for stories. Following is an example of such a custom Renderer class. ```php stories = $query->get_stories(); } /** * Initialization actions. */ public function init(): void { add_action( 'some_action', [ $this, 'some_callback' ] ); } /** * Return the stories markup as string which needs to be * echoed further. * * @param array $args Rendering args like height, width etc. * * @return string */ public function render( array $args = array() ): string { ob_start(); ?>
    stories ) ) { foreach ( $this->stories as $story ) { $this->current_story = $story; $this->render_single_story_content(); } } ?>
%s', esc_html( $this->current_story->post_title ) ); } } ``` Once the Renderer class is ready, you can use this Renderer to display stories as follows. ```php $story_query_attrs = [ 'view' => 'circles' ]; $story_query_args = [ 'posts_per_page' => 8 ]; $story_query = new \Google\Web_Stories\Story_Query( $story_query_attrs, $story_query_args ); $renderer = new ExampleRenderer( $story_query ); echo $renderer->render(); ``` ## CSS Guide Web Stories use minimal required HTML to render stories based on selected view type. This block has four view types in total, which are as follows: * Generic View 1. Grid View 2. List View * Carousel View 1. Box Carousel 2. Circles Carousel As mentioned above, these four view types have further been divided into two types of renderers. Following are the general full structure of the rendered web stories for each renderer type. Some of these elements are conditional and will only render if dependent conditions are met, like the excerpt ( ‘.story-content-overlay__excerpt’ ) will only be shown for the list view type and if control to show the excerpt is set to true. **Generic View:** ```html
``` **Carousel View:** ```html
``` ## Elements ### Block root classes Following classes are present on the root element. #### `.web-stories-list` Default class added to the block’s main container. #### `.align{$alignment_option}` If the block has alignment set it will add this class with the one of the `[ ‘none’, ‘wide’, ‘full’, ‘left’, ‘center’, ‘right’ ]` values, i.e., if alignment is set to `wide` the class will be `alignwide`. Defaults to `alignnone`. Also adds any extra classes passed to the renderer with a story attribute named ‘class’. #### `.has-archive-link` If the block is showing an archive link. #### View specific classes added to the root ##### `.is-view-type-{$view_type}` `$view_type` will be one of the four view types i.e., for Circles Carousel the class name will be `is-view-type-circles`. ##### `.columns-{$column_number}` `$column_number` will range from 1-4, i.e., if the block is set to have 2 columns the resulting class will be `columns-2`. ##### `.is-style-squared` If showing sharp corners for the story elements. ##### `.is-style-default` If showing curved corners for the story elements, default for the block. ##### `.has-title` This class is added for the circles carousel view, if it is showing the story titles. ##### `.is-carousel` Added for the Carousel view types. ### `Inner wrapper` #### `.web-stories-list__inner-wrapper` Added to the inner container which holds the story elements. For the generic view types, this will have story elements as direct children. This element also adds CSS variables as inline styles based on the view type as mentioned here: * `--ws-circle-size`: Circle size value when using the circle carousel. * `--ws-story-max-width`: For the box carousel this adds the max-width for the story items. ### Story element This element encompasses the story elements like poster, title, author name etc. #### `.web-stories-list__story` Story element root. #### `.image-align-right` For list view show image on right. ### Story poster #### `.web-stories-list__story-poster` Story poster elements encapsulate either `` or `` based on the type of request, whether it is AMP or non-AMP. ### Story content overlay #### `.web-stories-list__story-content-overlay` Content overlay container. #### `.story-content-overlay__title` Story title element. #### `.story-content-overlay__excerpt` Story excerpt. #### `.story-content-overlay__author` Story author name. #### `.story-content-overlay__date` Story publish date. ### Story Lightbox A story opens in a lightbox upon clicking it. This element contains the `` which embeds the story. There will be one lightbox wrapper per instance of Web Stories rendered on the page. The lightbox renders a bit differently on AMP vs non-AMP pages. On non-AMP pages it uses a single lightbox container and renders all the stories as links in ``. This enables some of the extra features on non-AMP pages like skip through stories, auto-play next story. While on AMP pages it uses one `` component for each story in the block. #### `.web-stories-list__lightbox-wrapper` The lightbox wrapper will have an extra class to identify which instance it relates to. I.e., `ws-lightbox-{$i}` where `$i` is an integer, number of the instance. #### `.web-stories-list__lightbox` Main lightbox container. #### `.story-lightbox__close-button` Only present on AMP pages, this is the lightbox close button element. ================================================ FILE: docs/workflows.md ================================================ # Workflows ## Create plugin bundle To create a build of the plugin for installing in WordPress as a ZIP package, run: ```bash npm run build # Build JS npm run workflow:version [version] # Bump version number npm run workflow:build-plugin -- [--composer] [--cdn [url]] [--zip [filename] [--clean] # Creates a full build. ``` ## Update list of available fonts The project bundles an up-to-date list of available fonts from Google Fonts (plus some system fonts) that can be used in the editor. A workflow exists that automatically updates the list every time Google Fonts updates its catalogue. However, it can also be done manually if needed. A [Google Fonts API key](https://developers.google.com/fonts/docs/developer_api) is required to update said list. Check out the [Google fonts docs](https://developers.google.com/fonts/docs/developer_api) to learn how to obtain one. Once obtained, follow these steps to configure the project appropriately: ```bash export GOOGLE_FONTS_API_KEY=your-api-key npm run workflow:fonts ``` This script does the following: 1. Downloads all available fonts from Google Fonts 1. Merges font list with a set of system fonts and prepares them for usage in the editor. 1. Saves changes to `packages/fonts/src/fonts.json`. ================================================ FILE: includes/AMP/Canonical_Sanitizer.php ================================================ args['canonical_url']; $query = $this->dom->xpath->query( '//link[@rel="canonical"]', $this->dom->head ); // Remove any duplicate items first. if ( $query instanceof DOMNodeList && $query->length > 1 ) { for ( $i = 1; $i < $query->length; $i++ ) { $node = $query->item( $i ); if ( $node ) { // @phpstan-ignore argument.type $this->dom->head->removeChild( $node ); } } } /** * DOMElement * * @var DOMElement|DOMNode $rel_canonical */ $rel_canonical = $query instanceof DOMNodeList ? $query->item( 0 ) : null; if ( ! $rel_canonical instanceof DOMElement ) { $rel_canonical = $this->dom->createElement( Tag::LINK ); if ( $rel_canonical instanceof DOMElement ) { $rel_canonical->setAttribute( Attribute::REL, Attribute::REL_CANONICAL ); $this->dom->head->appendChild( $rel_canonical ); } } if ( $rel_canonical instanceof DOMElement ) { // Ensure link[rel=canonical] has a non-empty href attribute. if ( empty( $rel_canonical->getAttribute( Attribute::HREF ) ) ) { $rel_canonical->setAttribute( Attribute::HREF, (string) $canonical_url ); } } } } ================================================ FILE: includes/AMP/Integration/AMP_Story_Sanitizer.php ================================================ transform_html_start_tag( $this->dom ); $this->transform_a_tags( $this->dom ); $this->use_semantic_heading_tags( $this->dom ); $this->add_publisher_logo( $this->dom, $this->args['publisher_logo'] ); $this->add_publisher( $this->dom, $this->args['publisher'] ); $this->add_poster_images( $this->dom, $this->args['poster_images'] ); // This needs to be called before use_semantic_heading_tags() because it relies on the style attribute. $this->deduplicate_inline_styles( $this->dom ); $this->disable_first_page_animations( $this->dom ); $this->add_video_cache( $this->dom, $this->args['video_cache'] ); $this->remove_blob_urls( $this->dom ); $this->sanitize_srcset( $this->dom ); $this->sanitize_amp_story_page_outlink( $this->dom ); $this->remove_page_template_placeholder_images( $this->dom ); $this->sanitize_title_and_meta_description( $this->dom, $this->args['title_tag'], $this->args['description'] ); } } ================================================ FILE: includes/AMP/Meta_Sanitizer.php ================================================ style[amp-boilerplate]. * * The AMP boilerplate ({@link https://amp.dev/documentation/guides-and-tutorials/stories/learn/spec/amp-boilerplate}) styles should appear at the end of the head: * "Finally, specify the AMP boilerplate code. By putting the boilerplate code last, it prevents custom styles from * accidentally overriding the boilerplate css rules." * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.1.0 * * @link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/#optimize-the-amp-runtime-loading */ protected function ensure_boilerplate_is_present(): void { $style = null; $styles = $this->dom->xpath->query( './style[ @amp-boilerplate ]', $this->dom->head ); if ( $styles ) { $style = $styles->item( 0 ); } if ( ! $style ) { $style = $this->dom->createElement( Tag::STYLE ); if ( $style ) { $style->setAttribute( Attribute::AMP_BOILERPLATE, '' ); $style->appendChild( $this->dom->createTextNode( $this->get_boilerplate_stylesheets()[0] ) ); } } elseif ( $style->parentNode ) { $style->parentNode->removeChild( $style ); // So we can move it. } if ( $style ) { // @phpstan-ignore argument.type $this->dom->head->appendChild( $style ); } $noscript = null; $noscripts = $this->dom->xpath->query( './noscript[ style[ @amp-boilerplate ] ]', $this->dom->head ); if ( $noscripts ) { $noscript = $noscripts->item( 0 ); } if ( ! $noscript ) { $noscript = $this->dom->createElement( Tag::NOSCRIPT ); $style = $this->dom->createElement( Tag::STYLE ); if ( $style && $noscript ) { $style->setAttribute( Attribute::AMP_BOILERPLATE, '' ); $style->appendChild( $this->dom->createTextNode( $this->get_boilerplate_stylesheets()[1] ) ); $noscript->appendChild( $style ); } } elseif ( $noscript->parentNode ) { $noscript->parentNode->removeChild( $noscript ); // So we can move it. } if ( $noscript ) { // @phpstan-ignore argument.type $this->dom->head->appendChild( $noscript ); } } /** * Get AMP boilerplate stylesheets. * * Clone of amp_get_boilerplate_stylesheets(). * * @since 1.1.0 * * @link https://www.ampproject.org/docs/reference/spec#boilerplate * @see amp_get_boilerplate_stylesheets() * * @return string[] Stylesheets, where first is contained in style[amp-boilerplate] and the second in noscript>style[amp-boilerplate]. */ protected function get_boilerplate_stylesheets(): array { return [ 'body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}', 'body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}', ]; } } ================================================ FILE: includes/AMP/Optimization.php ================================================ get_optimizer()->optimizeDom( $document, $errors ); if ( \count( $errors ) > 0 ) { /** * Error list. * * @var Error[] $errors_array Error list. */ $errors_array = iterator_to_array( $errors ); $error_messages = array_filter( array_map( static function ( Error $error ) { // Hidden because amp-story is a render-delaying extension. if ( 'CannotRemoveBoilerplate' === $error->getCode() ) { return ''; } return ' - ' . $error->getCode() . ': ' . $error->getMessage(); }, $errors_array ) ); if ( ! empty( $error_messages ) ) { $document->head->appendChild( $document->createComment( "\n" . __( 'AMP optimization could not be completed due to the following:', 'web-stories' ) . "\n" . implode( "\n", $error_messages ) . "\n" ) ); } } } /** * Optimizer instance to use. * * @since 1.1.0 * * @link https://github.com/ampproject/amp-wp/blob/8856284d90fc8558c30acc029becd352ae26e4e1/includes/class-amp-theme-support.php#L2235-L2255 * @see AMP_Theme_Support::get_optimizer * * @return TransformationEngine Optimizer transformation engine to use. */ private function get_optimizer(): TransformationEngine { $configuration = self::get_optimizer_configuration(); $fallback_remote_request_pipeline = new FallbackRemoteGetRequest( new WpHttpRemoteGetRequest(), new FilesystemRemoteGetRequest( LocalFallback::getMappings() ) ); $cached_remote_request = new CachedRemoteGetRequest( $fallback_remote_request_pipeline, WEEK_IN_SECONDS ); return new TransformationEngine( $configuration, $cached_remote_request ); } /** * Get the AmpProject\Optimizer configuration object to use. * * @since 1.1.0 * * @link https://github.com/ampproject/amp-wp/blob/5405daa38e65f0ec16ffc920014d0110b03ee773/src/Optimizer/AmpWPConfiguration.php#L43-L78 * @see AmpWPConfiguration::apply_filters() * * @return Configuration Optimizer configuration to use. */ private static function get_optimizer_configuration(): Configuration { $transformers = Configuration::DEFAULT_TRANSFORMERS; $transformers[] = AmpStoryCssOptimizer::class; /** * Filter whether the AMP Optimizer should use server-side rendering or not. * * @since 1.1.0 * * @param bool $enable_ssr Whether the AMP Optimizer should use server-side rendering or not. */ $enable_ssr = apply_filters( 'web_stories_enable_ssr', true ); // In debugging mode, we don't use server-side rendering, as it further obfuscates the HTML markup. if ( ! $enable_ssr ) { $transformers = array_diff( $transformers, [ AmpRuntimeCss::class, OptimizeHeroImages::class, OptimizeAmpBind::class, RewriteAmpUrls::class, ServerSideRendering::class, TransformedIdentifier::class, AmpStoryCssOptimizer::class, ] ); } $configuration = [ Configuration::KEY_TRANSFORMERS => $transformers, AmpStoryCssOptimizer::class => [ AmpStoryCssOptimizerConfiguration::OPTIMIZE_AMP_STORY => true, ], MinifyHtml::class => [ // Prevents issues with rounding floats, relevant for things like shopping (product prices). Configuration\MinifyHtmlConfiguration::MINIFY_JSON => false, ], ]; /** * Filter the configuration to be used for the AMP Optimizer. * * @since 1.1.0 * * @param array $configuration Associative array of configuration data. */ $configuration = apply_filters( 'web_stories_amp_optimizer_config', $configuration ); return new DefaultConfiguration( $configuration ); } } ================================================ FILE: includes/AMP/Output_Buffer.php ================================================ sanitization = $sanitization; $this->optimization = $optimization; $this->context = $context; } /** * Runs on instantiation. * * @since 1.10.0 */ public function register(): void { /* * Start output buffering at very low priority for sake of plugins and themes that use template_redirect * instead of template_include. */ $this->start_output_buffering(); } /** * Get the action to use for registering the service. * * @since 1.10.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'template_redirect'; } /** * Get the action priority to use for registering the service. * * @since 1.10.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return PHP_INT_MIN; } /** * Check whether the conditional object is currently needed. * * If the AMP plugin is installed and available in a version >= than ours, * all sanitization and optimization should be delegated to the AMP plugin. * But ONLY if AMP logic has not been disabled through any of its available filters. * * @since 1.10.0 * * @return bool Whether the conditional object is needed. */ public static function is_needed(): bool { $current_post = get_post(); $has_old_amp_version = ! \defined( '\AMP__VERSION' ) || ( \defined( '\AMP__VERSION' ) && version_compare( \AMP__VERSION, WEBSTORIES_AMP_VERSION, '<' ) ); $amp_available = \function_exists( 'amp_is_available' ) && amp_is_available(); // @phpstan-ignore function.internal $amp_enabled = \function_exists( 'amp_is_enabled' ) && amp_is_enabled(); $amp_initialized = did_action( 'amp_init' ) > 0; $amp_supported_post = \function_exists( 'amp_is_post_supported' ) && amp_is_post_supported( $current_post->ID ?? 0 ); return $has_old_amp_version || ! $amp_available || ! $amp_enabled || ! $amp_initialized || ! $amp_supported_post; } /** * Start output buffering. * * @since 1.10.0 * * @see Output_Buffer::finish_output_buffering() */ public function start_output_buffering(): void { if ( ! $this->context->is_web_story() ) { return; } ob_start( [ $this, 'finish_output_buffering' ] ); $this->is_output_buffering = true; } /** * Determine whether output buffering has started. * * @since 1.10.0 * * @see Output_Buffer::start_output_buffering() * @see Output_Buffer::finish_output_buffering() * * @return bool Whether output buffering has started. */ public function is_output_buffering(): bool { return $this->is_output_buffering; } /** * Finish output buffering. * * @since 1.10.0 * * @see Output_Buffer::start_output_buffering() * * @param string $response Buffered Response. * @return string Processed Response. */ public function finish_output_buffering( string $response ): string { $this->is_output_buffering = false; try { $response = $this->prepare_response( $response ); } catch ( \Error $error ) { // Only PHP 7+. $response = $this->render_error_page( $error ); } catch ( \Exception $exception ) { $response = $this->render_error_page( $exception ); } return $response; } /** * Process response to ensure AMP validity. * * @since 1.10.0 * * @param string $response HTML document response. By default it expects a complete document. * @return string AMP document response. */ public function prepare_response( string $response ): string { // Enforce UTF-8 encoding as it is a requirement for AMP. if ( ! headers_sent() ) { header( 'Content-Type: text/html; charset=utf-8' ); } $dom = Document::fromHtml( $response ); if ( ! $dom instanceof Document ) { return $this->render_error_page( SanitizationException::from_document_parse_error() ); } $this->sanitization->sanitize_document( $dom ); $this->optimization->optimize_document( $dom ); return $dom->saveHTML(); } /** * Render error page. * * @since 1.10.0 * * @param Throwable $throwable Exception or (as of PHP7) Error. * @return string Error page. */ private function render_error_page( Throwable $throwable ): string { return esc_html__( 'There was an error generating the web story, probably because of a server misconfiguration. Try contacting your hosting provider or open a new support request.', 'web-stories' ) . "\n" . "\n" . // translators: 1: error message. 2: location. \sprintf( esc_html__( 'Error message: %1$s (%2$s)', 'web-stories' ), $throwable->getMessage(), $throwable->getFile() . ':' . $throwable->getLine() ); } } ================================================ FILE: includes/AMP/Sanitization.php ================================================ settings = $settings; } /** * Sanitizes a document. * * @since 1.1.0 * * @param Document $document Document instance. */ public function sanitize_document( Document $document ): void { $sanitizers = $this->get_sanitizers(); $result = AMP_Content_Sanitizer::sanitize_document( $document, $sanitizers, [] ); $this->ensure_required_markup( $document, $result['scripts'] ); } /** * Validation error callback. * * @since 1.1.0 * * @see AMP_Validation_Error_Taxonomy::get_validation_error_sanitization * * @param array{code: string} $error Error info, especially code. * @param array{node?: DOMElement|DOMNode} $data Additional data, including the node. * @return bool Whether the validation error should be sanitized. */ public function validation_error_callback( array $error, array $data = [] ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed /** * Filters whether the validation error should be sanitized. * * Returning true this indicates that the validation error is acceptable * and should not be considered a blocker to render AMP. Returning null * means that the default status should be used. * * Note that the $node is not passed here to ensure that the filter can be * applied on validation errors that have been stored. Likewise, the $sources * are also omitted because these are only available during an explicit * validation request and so they are not suitable for plugins to vary * sanitization by. * * @since 1.1.0 * * @see AMP_Validation_Manager::is_sanitization_auto_accepted() Which controls whether an error is initially accepted or rejected for sanitization. * * @param bool $sanitized Whether the validation error should be sanitized. * @param array $error Validation error being sanitized. */ return apply_filters( 'web_stories_amp_validation_error_sanitized', true, $error ); } /** * Adds missing scripts. * * @SuppressWarnings("PHPMD") * * @since 1.1.0 * * @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/class-amp-theme-support.php#L1381-L1594 * @see \AMP_Theme_Support::ensure_required_markup * * @param Document $document Document instance. * @param array $scripts List of found scripts. */ protected function ensure_required_markup( Document $document, array $scripts ): void { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh /** * Link elements. * * @var array{preconnect: \DOMElement[]|null,dns-prefetch: \DOMElement[]|null,preload: \DOMElement[]|null, prerender: \DOMElement[]|null, prefetch: \DOMElement[]|null } */ $links = [ Attribute::REL_PRECONNECT => [ // Include preconnect link for AMP CDN for browsers that don't support preload. AMP_DOM_Utils::create_node( $document, Tag::LINK, [ Attribute::REL => Attribute::REL_PRECONNECT, Attribute::HREF => 'https://cdn.ampproject.org', ] ), ], ]; // Obtain the existing AMP scripts. $amp_scripts = []; $ordered_scripts = []; $head_scripts = []; $runtime_src = 'https://cdn.ampproject.org/v0.js'; /** * Script element. * * @var DOMElement $script */ foreach ( $document->head->getElementsByTagName( Tag::SCRIPT ) as $script ) { $head_scripts[] = $script; } foreach ( $head_scripts as $script ) { $src = $script->getAttribute( Attribute::SRC ); if ( ! $src || ! str_starts_with( $src, 'https://cdn.ampproject.org/' ) ) { continue; } if ( 'v0.js' === substr( $src, - \strlen( 'v0.js' ) ) ) { $amp_scripts[ Amp::RUNTIME ] = $script; } elseif ( $script->hasAttribute( Attribute::CUSTOM_ELEMENT ) ) { $amp_scripts[ $script->getAttribute( Attribute::CUSTOM_ELEMENT ) ] = $script; } elseif ( $script->hasAttribute( Attribute::CUSTOM_TEMPLATE ) ) { $amp_scripts[ $script->getAttribute( Attribute::CUSTOM_TEMPLATE ) ] = $script; } // It will be added back further down. $document->head->removeChild( $script ); } $specs = $this->get_extension_sources(); // Create scripts for any components discovered from output buffering that are missing. foreach ( array_diff( array_keys( $scripts ), array_keys( $amp_scripts ) ) as $missing_script_handle ) { $attrs = [ Attribute::SRC => $specs[ $missing_script_handle ], Attribute::ASYNC => '', ]; if ( Extension::MUSTACHE === $missing_script_handle ) { $attrs[ Attribute::CUSTOM_TEMPLATE ] = (string) $missing_script_handle; } else { $attrs[ Attribute::CUSTOM_ELEMENT ] = (string) $missing_script_handle; } $amp_scripts[ $missing_script_handle ] = AMP_DOM_Utils::create_node( $document, Tag::SCRIPT, $attrs ); } // Remove scripts that had already been added but couldn't be detected from output buffering. $extension_specs = AMP_Allowed_Tags_Generated::get_extension_specs(); $superfluous_script_handles = array_diff( array_keys( $amp_scripts ), [ ...array_keys( $scripts ), Amp::RUNTIME ] ); foreach ( $superfluous_script_handles as $superfluous_script_handle ) { if ( ! empty( $extension_specs[ $superfluous_script_handle ]['requires_usage'] ) ) { unset( $amp_scripts[ $superfluous_script_handle ] ); } } /* phpcs:ignore Squiz.PHP.CommentedOutCode.Found * * "2. Next, preload the AMP runtime v0.js settings->get_setting( $this->settings::SETTING_NAME_ADSENSE_PUBLISHER_ID ); return $publisher_id; } /** * Returns the Google AdSense slot ID. * * @since 1.3.0 * * @return string Slot ID. */ private function get_slot_id(): string { /** * Slot ID. * * @var string */ return $this->settings->get_setting( $this->settings::SETTING_NAME_ADSENSE_SLOT_ID ); } /** * Returns if Google AdSense is enabled. * * @since 1.3.0 */ private function is_enabled(): bool { return ( 'adsense' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) ); } } ================================================ FILE: includes/Ad_Manager.php ================================================ settings = $settings; } /** * Initializes all hooks. * * @since 1.3.0 */ public function register(): void { add_action( 'web_stories_print_analytics', [ $this, 'print_ad_manager_tag' ] ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Prints the tag for single stories. * * @since 1.3.0 */ public function print_ad_manager_tag(): void { $slot = $this->get_slot_id(); $enabled = $this->is_enabled(); if ( ! $enabled || ! $slot ) { return; } $configuration = [ 'ad-attributes' => [ 'type' => 'doubleclick', 'data-slot' => $slot, ], ]; /** * Filters Google Ad Manager configuration passed to ``. * * @since 1.10.0 * * @param array $settings Ad Manager configuration. * @param string $slot Google Ad_Manager slot ID. */ $configuration = apply_filters( 'web_stories_ad_manager_configuration', $configuration, $slot ); ?> settings->get_setting( $this->settings::SETTING_NAME_AD_MANAGER_SLOT_ID ); } /** * Returns if Google manager is enabled. * * @since 1.3.0 */ private function is_enabled(): bool { return ( 'admanager' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) ); } } ================================================ FILE: includes/Admin/Activation_Notice.php ================================================ assets = $assets; } /** * Initializes the plugin activation notice. * * @since 1.0.0 */ public function register(): void { add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); add_action( 'admin_notices', [ $this, 'render_notice' ] ); add_action( 'network_admin_notices', [ $this, 'render_notice' ] ); } /** * Act on plugin activation. * * @since 1.13.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->set_activation_flag( $network_wide ); } /** * Act on plugin deactivation. * * @since 1.13.0 * * @param bool $network_wide Whether the deactivation was done network-wide. */ public function on_plugin_deactivation( bool $network_wide ): void { $this->delete_activation_flag( $network_wide ); } /** * Enqueues assets for the plugin activation notice. * * @since 1.0.0 * * @param string $hook_suffix The current admin page. */ public function enqueue_assets( string $hook_suffix ): void { if ( ! $this->is_plugins_page( $hook_suffix ) || ! $this->get_activation_flag( is_network_admin() ) ) { return; } /** * Prevent the default WordPress "Plugin Activated" notice from rendering. * * @link https://github.com/WordPress/WordPress/blob/e1996633228749cdc2d92bc04cc535d45367bfa4/wp-admin/plugins.php#L569-L570 */ unset( $_GET['activate'] ); // phpcs:ignore WordPress.Security.NonceVerification, WordPress.VIP.SuperGlobalInputUsage $this->assets->enqueue_style( Google_Fonts::SCRIPT_HANDLE ); $this->assets->enqueue_script_asset( self::SCRIPT_HANDLE, [ Tracking::SCRIPT_HANDLE ] ); wp_localize_script( self::SCRIPT_HANDLE, 'webStoriesActivationSettings', $this->get_script_settings() ); } /** * Renders the plugin activation notice. * * @since 1.0.0 */ public function render_notice(): void { global $hook_suffix; if ( ! $this->is_plugins_page( $hook_suffix ) ) { return; } $network_wide = is_network_admin(); $flag = $this->get_activation_flag( $network_wide ); if ( ! $flag ) { return; } // Unset the flag so that the notice only shows once. $this->delete_activation_flag( $network_wide ); require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/activation-notice.php'; } /** * Deletes the flag that the plugin has just been uninstalled. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { if ( is_multisite() ) { delete_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE ); } delete_option( self::OPTION_SHOW_ACTIVATION_NOTICE ); } /** * Returns script settings as an array. * * @since 1.0.0 * * @return array{id: string, config: array,publicPath: string} Script settings. */ protected function get_script_settings(): array { $new_story_url = admin_url( add_query_arg( [ 'post_type' => Story_Post_Type::POST_TYPE_SLUG, ], 'post-new.php' ) ); $dashboard_url = admin_url( add_query_arg( [ 'post_type' => Story_Post_Type::POST_TYPE_SLUG, 'page' => 'stories-dashboard', ], 'edit.php' ) ); $demo_story_url = admin_url( add_query_arg( [ 'post_type' => Story_Post_Type::POST_TYPE_SLUG, 'web-stories-demo' => 1, ], 'post-new.php' ) ); return [ 'id' => 'web-stories-plugin-activation-notice', 'config' => [ 'isRTL' => is_rtl(), 'cdnURL' => trailingslashit( WEBSTORIES_CDN_URL ), 'demoStoryURL' => $demo_story_url, 'newStoryURL' => $new_story_url, 'dashboardURL' => $dashboard_url, ], 'publicPath' => $this->assets->get_base_url( 'assets/js/' ), ]; } /** * Determines whether we're currently on the Plugins page or not. * * @since 1.0.0 * * @param mixed $hook_suffix Current hook_suffix. * @return bool Whether we're on the Plugins page. */ protected function is_plugins_page( $hook_suffix ): bool { return ( ! empty( $hook_suffix ) && 'plugins.php' === $hook_suffix ); } /** * Sets the flag that the plugin has just been activated. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.13.0 * * @param bool $network_wide Whether the plugin is being activated network-wide. */ protected function set_activation_flag( bool $network_wide = false ): bool { if ( $network_wide ) { return update_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE, '1' ); } return update_option( self::OPTION_SHOW_ACTIVATION_NOTICE, '1', false ); } /** * Gets the flag that the plugin has just been activated. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.13.0 * * @param bool $network_wide Whether to check the flag network-wide. * @return bool True if just activated, false otherwise. */ protected function get_activation_flag( bool $network_wide = false ): bool { if ( $network_wide ) { return (bool) get_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE, false ); } return (bool) get_option( self::OPTION_SHOW_ACTIVATION_NOTICE, false ); } /** * Deletes the flag that the plugin has just been activated. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.13.0 * * @param bool $network_wide Whether the plugin is being deactivated network-wide. * @return bool True if flag deletion is successful, false otherwise. */ protected function delete_activation_flag( bool $network_wide = false ): bool { if ( $network_wide ) { return delete_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE ); } return delete_option( self::OPTION_SHOW_ACTIVATION_NOTICE ); } } ================================================ FILE: includes/Admin/Admin.php ================================================ settings = $settings; $this->context = $context; } /** * Initialize admin-related functionality. * * @since 1.0.0 */ public function register(): void { add_filter( 'admin_body_class', [ $this, 'admin_body_class' ], 99 ); add_filter( 'default_content', [ $this, 'prefill_post_content' ], 10, 2 ); add_filter( 'default_title', [ $this, 'prefill_post_title' ] ); add_filter( 'display_media_states', [ $this, 'media_states' ], 10, 2 ); } /** * Get the action to use for registering the service. * * @since 1.6.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'admin_init'; } /** * Filter the list of admin classes. * * Makes sure the admin menu is collapsed when accessing * the dashboard and the editor. * * @since 1.0.0 * * @param string|mixed $class_name Current classes. * @return string|mixed List of Classes. */ public function admin_body_class( $class_name ) { if ( ! \is_string( $class_name ) ) { return $class_name; } if ( ! $this->context->is_story_editor() ) { return $class_name; } // Default WordPress posts list table screen and dashboard. if ( 'post' !== $this->context->get_screen_base() ) { return $class_name; } $class_name .= ' edit-story'; // Overrides regular WordPress behavior by collapsing the admin menu by default. if ( ! str_contains( $class_name, 'folded' ) ) { $class_name .= ' folded'; } return $class_name; } /** * Pre-fills post content with a web-story/embed block. * * @since 1.0.0 * * @param string|mixed $content Default post content. * @param WP_Post|null $post Post object. * @return string|mixed Pre-filled post content if applicable, or the default content otherwise. */ public function prefill_post_content( $content, ?WP_Post $post ) { if ( ! $post ) { return $content; } if ( ! isset( $_GET['from-web-story'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $content; } /** * Story ID. * * @var string $from_web_story */ $from_web_story = $_GET['from-web-story']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $post_id = absint( sanitize_text_field( (string) wp_unslash( $from_web_story ) ) ); if ( ! $post_id || Story_Post_Type::POST_TYPE_SLUG !== get_post_type( $post_id ) ) { return $content; } if ( ! current_user_can( 'read_post', $post_id ) ) { return $content; } $story = new Story(); if ( ! $story->load_from_post( $post_id ) ) { return $content; } if ( ! $story->get_title() ) { $story->set_title( __( 'Web Story', 'web-stories' ) ); } $args = [ 'align' => 'none', 'height' => 600, 'width' => 360, ]; if ( ! use_block_editor_for_post( $post ) ) { $content = '[web_stories_embed url="%1$s" title="%2$s" poster="%3$s" width="%4$s" height="%5$s" align="%6$s"]'; return \sprintf( $content, esc_url( $story->get_url() ), esc_attr( $story->get_title() ), esc_url( $story->get_poster_portrait() ), absint( $args['width'] ), absint( $args['height'] ), esc_attr( $args['align'] ) ); } $story->set_poster_sizes( '' ); $story->set_poster_srcset( '' ); $renderer = new Image( $story ); $html = $renderer->render( $args ); $content = '%8$s'; // note $story->get_url should not be escaped here (esc_url()) see https://github.com/GoogleForCreators/web-stories-wp/issues/11371. return \sprintf( $content, $story->get_url(), esc_js( $story->get_title() ), esc_url( $story->get_poster_portrait() ), absint( $args['width'] ), absint( $args['height'] ), esc_js( $args['align'] ), absint( $post_id ), $html ); } /** * Pre-fills post title with the story title. * * @since 1.0.0 * * @param string|mixed $title Default post title. * @return string|mixed Pre-filled post title if applicable, or the default title otherwise. */ public function prefill_post_title( $title ) { if ( ! isset( $_GET['from-web-story'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $title; } /** * Story ID. * * @var string $from_web_story */ $from_web_story = $_GET['from-web-story']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $post_id = absint( sanitize_text_field( (string) wp_unslash( $from_web_story ) ) ); if ( ! $post_id ) { return $title; } if ( ! current_user_can( 'read_post', $post_id ) ) { return $title; } $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || Story_Post_Type::POST_TYPE_SLUG !== $post->post_type ) { return $title; } // Not using get_the_title() because we need the raw title. // Otherwise it runs through wptexturize() and the like, which we want to avoid. return $post->post_title; } /** * Adds active publisher logo to media state output. * * @since 1.23.0 * * @param mixed $media_states Array of media states. * @param WP_Post $post Post object. * @return mixed Filtered media states. */ public function media_states( $media_states, WP_Post $post ) { if ( ! \is_array( $media_states ) ) { return $media_states; } $active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) ); if ( $post->ID === $active_publisher_logo_id ) { $media_states[] = __( 'Web Stories Publisher Logo', 'web-stories' ); } return $media_states; } } ================================================ FILE: includes/Admin/Cross_Origin_Isolation.php ================================================ preferences = $preferences; $this->context = $context; } /** * Init */ public function register(): void { if ( ! $this->context->is_story_editor() ) { return; } add_action( 'load-post.php', [ $this, 'admin_header' ] ); add_action( 'load-post-new.php', [ $this, 'admin_header' ] ); add_filter( 'style_loader_tag', [ $this, 'style_loader_tag' ], 10, 3 ); add_filter( 'script_loader_tag', [ $this, 'script_loader_tag' ], 10, 3 ); add_filter( 'get_avatar', [ $this, 'get_avatar' ], 10, 6 ); add_action( 'wp_enqueue_media', [ $this, 'override_media_templates' ] ); } /** * Get the action to use for registering the service. * * @since 1.6.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'current_screen'; } /** * Get the action priority to use for registering the service. * * @since 1.6.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 11; } /** * Get the list of service IDs required for this service to be registered. * * @since 1.12.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'user_preferences' ]; } /** * Start output buffer to add headers and `crossorigin` attribute everywhere. * * @since 1.6.0 */ public function admin_header(): void { if ( $this->needs_isolation() ) { header( 'Cross-Origin-Opener-Policy: same-origin' ); header( 'Cross-Origin-Embedder-Policy: require-corp' ); } ob_start( [ $this, 'replace_in_dom' ] ); } /** * Filters the HTML link tag of an enqueued style. * * @since 1.6.0 * * @param mixed $tag The link tag for the enqueued style. * @param string $handle The style's registered handle. * @param string $href The stylesheet's source URL. * @return string|mixed */ public function style_loader_tag( $tag, string $handle, string $href ) { return $this->add_attribute( $tag, 'href', $href ); } /** * Filters the HTML script tag of an enqueued script. * * @since 1.6.0 * * @param mixed $tag The `#is', '', $tag ); } return $tag; } /** * Enqueue related scripts. * * @since 1.5.0 */ public function enqueue_assets(): void { $this->assets->enqueue_style( 'wp-components' ); $this->assets->enqueue_script_asset( self::SCRIPT_HANDLE ); } /** * Root element for tinymce editor. * This is useful for performing some react operations. * * @since 1.5.0 */ public function web_stories_tinymce_root_element(): void { ?>
settings = $settings; } /** * Initializes all hooks. * * @since 1.0.0 */ public function register(): void { add_action( 'web_stories_print_analytics', [ $this, 'print_analytics_tag' ] ); } /** * Returns the Google Analytics tracking ID. * * @since 1.0.0 * * @return string Tracking ID. */ public function get_tracking_id(): string { /** * Tracking ID. * * @var string $tracking_id */ $tracking_id = $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_ID ); // For some reasons, some sites use the plugin's own tracking ID (as used in the admin) // for their stories. Prevent accidental erroneous tracking in such a case. if ( Tracking::TRACKING_ID === $tracking_id ) { return ''; } return $tracking_id; } /** * Returns the default analytics configuration. * * Note: variables in single quotes will be substituted by . * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @see https://github.com/ampproject/amphtml/blob/main/docs/spec/amp-var-substitutions.md * * @param string $tracking_id Tracking ID. * @return array> configuration. */ public function get_default_configuration( string $tracking_id ): array { $config = [ 'vars' => [ 'gtag_id' => $tracking_id, 'config' => [ $tracking_id => [ 'groups' => 'default' ], ], ], 'triggers' => [ // Fired when a story page becomes visible. 'storyProgress' => [ 'on' => 'story-page-visible', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_progress', 'event_category' => '${title}', 'event_label' => '${storyPageIndex}', 'event_value' => '${storyProgress}', 'send_to' => $tracking_id, ], ], // Fired when the last page in the story is shown to the user. // This can be used to measure completion rate. 'storyEnd' => [ 'on' => 'story-last-page-visible', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_complete', 'event_category' => '${title}', 'event_label' => '${storyPageCount}', 'send_to' => $tracking_id, ], ], // Fired when clicking an element that opens a tooltip ( or ). 'trackFocusState' => [ 'on' => 'story-focus', 'tagName' => 'a', 'request' => 'click ', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_focus', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when clicking on a tooltip. 'trackClickThrough' => [ 'on' => 'story-click-through', 'tagName' => 'a', 'request' => 'click ', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_click_through', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when opening a drawer or dialog inside a story (e.g. page attachment). 'storyOpen' => [ 'on' => 'story-open', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_open', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when closing a drawer or dialog inside a story (e.g. page attachment). 'storyClose' => [ 'on' => 'story-close', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_close', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when the user initiates an interaction to mute the audio for the current story. 'audioMuted' => [ 'on' => 'story-audio-muted', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_audio_muted', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when the user initiates an interaction to unmute the audio for the current story. 'audioUnmuted' => [ 'on' => 'story-audio-unmuted', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_audio_unmuted', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when a page attachment is opened by the user. 'pageAttachmentEnter' => [ 'on' => 'story-page-attachment-enter', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_page_attachment_enter', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], // Fired when a page attachment is dismissed by the user. 'pageAttachmentExit' => [ 'on' => 'story-page-attachment-exit', 'request' => 'event', 'vars' => [ 'event_name' => 'custom', 'event_action' => 'story_page_attachment_exit', 'event_category' => '${title}', 'send_to' => $tracking_id, ], ], ], ]; /** * Filters the Web Stories Google Analytics configuration. * * Only used when not using , which is the default. * * @param array $config Analytics configuration. */ return apply_filters( 'web_stories_analytics_configuration', $config ); } /** * Prints the analytics tag for single stories. * * @since 1.0.0 */ public function print_analytics_tag(): void { $tracking_id = $this->get_tracking_id(); if ( ! $tracking_id ) { return; } if ( $this->settings->get_setting( $this->settings::SETTING_NAME_USING_LEGACY_ANALYTICS ) ) { $this->print_amp_analytics_tag( $tracking_id ); } else { $this->print_amp_story_auto_analytics_tag( $tracking_id ); } } /** * Prints the legacy tag for single stories. * * @since 1.12.0 * * @param string $tracking_id Tracking ID. */ private function print_amp_analytics_tag( string $tracking_id ): void { ?> tag for single stories. * * @since 1.12.0 * * @param string $tracking_id Tracking ID. */ private function print_amp_story_auto_analytics_tag( string $tracking_id ): void { ?> */ protected array $register_styles = []; /** * An array of registered scripts. * * @var array */ protected array $register_scripts = []; /** * Get path to file and directory. * * @since 1.8.0 * * @param string $path Path. */ public function get_base_path( string $path ): string { return WEBSTORIES_PLUGIN_DIR_PATH . $path; } /** * Get url of file and directory. * * @since 1.8.0 * * @param string $path Path. */ public function get_base_url( string $path ): string { return WEBSTORIES_PLUGIN_DIR_URL . $path; } /** * Get asset metadata. * * @since 1.8.0 * * @param string $handle Script handle. * @return array Array containing combined contents of "<$handle>.asset.php" and "<$handle>.chunks.php". * * @phpstan-return AssetMetadata */ public function get_asset_metadata( string $handle ): array { $base_path = $this->get_base_path( 'assets/js/' ); // *.asset.php is generated by DependencyExtractionWebpackPlugin. // *.chunks.php is generated by HtmlWebpackPlugin with a custom template. $asset_file = $base_path . $handle . '.asset.php'; $chunks_file = $base_path . $handle . '.chunks.php'; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable $asset = is_readable( $asset_file ) ? require $asset_file : []; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable $chunks = is_readable( $chunks_file ) ? require $chunks_file : []; // A hash calculated based on the file content of the entry point bundle at <$handle>.js. $asset['version'] ??= WEBSTORIES_VERSION; $asset['dependencies'] ??= []; $asset['js'] = $chunks['js'] ?? []; $asset['css'] = $chunks['css'] ?? []; $asset['chunks'] = $chunks['chunks'] ?? []; return $asset; } /** * Register script using handle. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $script_handle Handle of script. * @param string[] $script_dependencies Array of extra dependencies. * @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true. */ public function register_script_asset( string $script_handle, array $script_dependencies = [], bool $with_i18n = true ): void { if ( isset( $this->register_scripts[ $script_handle ] ) ) { return; } $base_script_path = $this->get_base_url( 'assets/js/' ); $in_footer = true; $asset = $this->get_asset_metadata( $script_handle ); $entry_version = $asset['version']; // Register any chunks of $script_handle first. // `$asset['js']` are preloaded chunks, `$asset['chunks']` dynamically imported ones. foreach ( $asset['js'] as $chunk ) { $this->register_script( $chunk, $base_script_path . $chunk . '.js', [], $entry_version, $in_footer, $with_i18n ); } // Dynamically imported chunks MUST NOT be added as dependencies here. $dependencies = [ ...$asset['dependencies'], ...$script_dependencies, ...$asset['js'] ]; $this->register_script( $script_handle, $base_script_path . $script_handle . '.js', $dependencies, $entry_version, $in_footer, $with_i18n ); // "Save" all the script's chunks so we can later manually fetch them and their translations if needed. wp_script_add_data( $script_handle, 'chunks', $asset['chunks'] ); // Register every dynamically imported chunk as a script, just so // that we can print their translations whenever the main script is enqueued. // The actual enqueueing of these chunks is done by the main script via dynamic imports. foreach ( $asset['chunks'] as $dynamic_chunk ) { $this->register_script( $dynamic_chunk, $base_script_path . $dynamic_chunk . '.js', [], $entry_version, // Not actually used / relevant, since enqueueing is done by webpack. $in_footer, // Ditto. $with_i18n ); if ( $with_i18n ) { wp_add_inline_script( $script_handle, (string) wp_scripts()->print_translations( $dynamic_chunk, false ) ); } } } /** * Enqueue script using handle. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $script_handle Handle of script. * @param string[] $script_dependencies Array of extra dependencies. * @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true. */ public function enqueue_script_asset( string $script_handle, array $script_dependencies = [], bool $with_i18n = true ): void { $this->register_script_asset( $script_handle, $script_dependencies, $with_i18n ); $this->enqueue_script( $script_handle ); } /** * Register style using handle. * * @since 1.8.0 * * @param string $style_handle Handle of style. * @param string[] $style_dependencies Array of extra dependencies. */ public function register_style_asset( string $style_handle, array $style_dependencies = [] ): void { if ( isset( $this->register_styles[ $style_handle ] ) ) { return; } $base_style_url = $this->get_base_url( 'assets/css/' ); $base_style_path = $this->get_base_path( 'assets/css/' ); $ext = is_rtl() ? '-rtl.css' : '.css'; // Register any chunks of $style_handle first. $asset = $this->get_asset_metadata( $style_handle ); // Webpack appends "-[contenthash]" to filenames of chunks, so omit the `?ver=` query param. $chunk_version = null; foreach ( $asset['css'] as $style_chunk ) { $this->register_style( $style_chunk, $base_style_url . $style_chunk . '.css', [], $chunk_version ); wp_style_add_data( $style_chunk, 'path', $base_style_path . $style_chunk . $ext ); } $style_dependencies = [ ...$style_dependencies, ...$asset['css'] ]; $entry_version = $asset['version']; $this->register_style( $style_handle, $base_style_url . $style_handle . '.css', $style_dependencies, $entry_version ); wp_style_add_data( $style_handle, 'rtl', 'replace' ); wp_style_add_data( $style_handle, 'path', $base_style_path . $style_handle . $ext ); } /** * Enqueue style using handle. * * @since 1.8.0 * * @param string $style_handle Handle of style. * @param string[] $style_dependencies Array of extra dependencies. */ public function enqueue_style_asset( string $style_handle, array $style_dependencies = [] ): void { $this->register_style_asset( $style_handle, $style_dependencies ); $this->enqueue_style( $style_handle ); } /** * Register a CSS stylesheet. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $style_handle Name of the stylesheet. Should be unique. * @param string|false $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory. * If source is set to false, stylesheet is an alias of other stylesheets it depends on. * @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array. * @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL * as a query string for cache busting purposes. If version is set to false, a version * number is automatically added equal to current installed WordPress version. * If set to null, no version is added. * @param string $media Optional. The media for which this stylesheet has been defined. * Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like * '(orientation: portrait)' and '(max-width: 640px)'. * @return bool Whether the style has been registered. True on success, false on failure. */ public function register_style( string $style_handle, $src, array $deps = [], $ver = false, string $media = 'all' ): bool { if ( ! isset( $this->register_styles[ $style_handle ] ) ) { $this->register_styles[ $style_handle ] = wp_register_style( $style_handle, $src, $deps, $ver, $media ); } return $this->register_styles[ $style_handle ]; } /** * Register a new script. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $script_handle Name of the script. Should be unique. * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. * If source is set to false, script is an alias of other scripts it depends on. * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL * as a query string for cache busting purposes. If version is set to false, a version * number is automatically added equal to current installed WordPress version. * If set to null, no version is added. * @param bool $in_footer Optional. Whether to enqueue the script before instead of in the . * Default 'false'. * @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true. * @return bool Whether the script has been registered. True on success, false on failure. */ public function register_script( string $script_handle, $src, array $deps = [], $ver = false, bool $in_footer = false, bool $with_i18n = true ): bool { if ( ! isset( $this->register_scripts[ $script_handle ] ) ) { $this->register_scripts[ $script_handle ] = wp_register_script( $script_handle, $src, $deps, $ver, [ 'in_footer' => $in_footer, ] ); if ( $src && $with_i18n ) { wp_set_script_translations( $script_handle, 'web-stories' ); } } return $this->register_scripts[ $script_handle ]; } /** * Enqueue a style. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $style_handle Name of the stylesheet. Should be unique. * @param string $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory. * Default empty. * @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array. * @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL * as a query string for cache busting purposes. If version is set to false, a version * number is automatically added equal to current installed WordPress version. * If set to null, no version is added. * @param string $media Optional. The media for which this stylesheet has been defined. * Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like * '(orientation: portrait)' and '(max-width: 640px)'. */ public function enqueue_style( string $style_handle, string $src = '', array $deps = [], $ver = false, string $media = 'all' ): void { $this->register_style( $style_handle, $src, $deps, $ver, $media ); wp_enqueue_style( $style_handle, $src, $deps, $ver, $media ); } /** * Enqueue a script. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.8.0 * * @param string $script_handle Name of the script. Should be unique. * @param string $src Full URL of the script, or path of the script relative to the WordPress root directory. * Default empty. * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL * as a query string for cache busting purposes. If version is set to false, a version * number is automatically added equal to current installed WordPress version. * If set to null, no version is added. * @param bool $in_footer Optional. Whether to enqueue the script before instead of in the . * Default 'false'. * @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true. */ public function enqueue_script( string $script_handle, string $src = '', array $deps = [], $ver = false, bool $in_footer = false, bool $with_i18n = false ): void { $this->register_script( $script_handle, $src, $deps, $ver, $in_footer, $with_i18n ); wp_enqueue_script( $script_handle, $src, $deps, $ver, $in_footer ); } /** * Register a new script module. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.40.1 * * @param string $script_handle Name of the script module. Should be unique. * @param string $src Full URL of the script module. */ public function enqueue_script_module( string $script_handle, string $src ): void { $asset = $this->get_asset_metadata( $script_handle ); wp_enqueue_script_module( $script_handle, $src, $asset['dependencies'], // @phpstan-ignore argument.type $asset['version'], ); } /** * Remove admin styles. * * @since 1.8.0 * * @param string[] $styles Array of styles to be removed. */ public function remove_admin_style( array $styles ): void { wp_styles()->registered['wp-admin']->deps = array_diff( wp_styles()->registered['wp-admin']->deps, $styles ); } /** * Returns the translations for a script and all of its chunks. * * @since 1.14.0 * * @param string $script_handle Name of the script. Should be unique. * @return array Script translations. */ public function get_translations( string $script_handle ): array { /** * List of script chunks. * * @var false|string[] */ $chunks = wp_scripts()->get_data( $script_handle, 'chunks' ); if ( ! \is_array( $chunks ) ) { return []; } $translations = [ (string) load_script_textdomain( $script_handle, 'web-stories' ), ]; /** * Dynamic chunk name. * * @var string $dynamic_chunk */ foreach ( $chunks as $dynamic_chunk ) { $translations[] = (string) load_script_textdomain( $dynamic_chunk, 'web-stories' ); } return array_values( array_map( static fn( $translations ) => json_decode( $translations, true ), array_filter( $translations ) ) ); } } ================================================ FILE: includes/Block/Web_Stories_Block.php ================================================ , * previewOnly?: bool, * taxQuery?: array * } * @phpstan-type BlockAttributesWithDefaults array{ * blockType?: string, * url?: string, * title?: string, * poster?: string, * width?: int, * height?: int, * align: string, * stories?: int[], * viewType?: string, * numOfStories?: int, * numOfColumns?: int, * circleSize?: int, * imageAlignment?: string, * orderby?: string, * order?: string, * archiveLinkLabel?: string, * authors?: int[], * fieldState?: array, * previewOnly?: bool * } */ class Web_Stories_Block extends Embed_Base { /** * Script handle. */ public const SCRIPT_HANDLE = 'web-stories-block'; /** * Maximum number of stories users can select */ public const MAX_NUM_OF_STORIES = 20; /** * Current block's block attributes. * * @var array Block Attributes. * @phpstan-var BlockAttributes */ protected array $block_attributes; /** * Story_Post_Type instance. * * @var Story_Post_Type Story_Post_Type instance. */ protected Story_Post_Type $story_post_type; /** * Stories_Script_Data instance. * * @var Stories_Script_Data Stories_Script_Data instance. */ protected Stories_Script_Data $stories_script_data; /** * Embed Base constructor. * * @since 1.14.0 * * @param Assets $assets Assets instance. * @param Story_Post_Type $story_post_type Story_Post_Type instance. * @param Stories_Script_Data $stories_script_data Stories_Script_Data instance. * @param Context $context Context instance. */ public function __construct( Assets $assets, Story_Post_Type $story_post_type, Stories_Script_Data $stories_script_data, Context $context ) { parent::__construct( $assets, $context ); $this->story_post_type = $story_post_type; $this->stories_script_data = $stories_script_data; } /** * Initializes the Web Stories embed block. * * @since 1.5.0 */ public function register(): void { parent::register(); $this->assets->register_script_asset( self::SCRIPT_HANDLE, [ AMP_Story_Player_Assets::SCRIPT_HANDLE, Tracking::SCRIPT_HANDLE ] ); $this->assets->register_style_asset( self::SCRIPT_HANDLE, [ AMP_Story_Player_Assets::SCRIPT_HANDLE, parent::SCRIPT_HANDLE ] ); wp_localize_script( self::SCRIPT_HANDLE, 'webStoriesBlockSettings', $this->get_script_settings() ); $this->register_block_type(); } /** * Renders the block type output for given attributes. * * @since 1.5.0 * * @param array $attributes Block attributes. * @param string $content Block content. * @param WP_Block $block Block instance. * @return string Rendered block type output. * * @phpstan-param BlockAttributesWithDefaults $attributes */ public function render_block( array $attributes, string $content, WP_Block $block ): string { if ( false === $this->initialize_block_attributes( $attributes ) ) { return ''; } if ( isset( $block->context['postType'], $block->context['postId'] ) && 'web-story' === $block->context['postType'] && ! empty( $block->context['postId'] ) ) { $attributes = wp_parse_args( $attributes, $this->default_attrs() ); $attributes['class'] = 'wp-block-web-stories-embed'; $story = new Story(); $story->load_from_post( get_post( $block->context['postId'] ) ); return $this->render_story( $story, $attributes ); } if ( ! empty( $attributes['blockType'] ) && ( 'latest-stories' === $attributes['blockType'] || 'selected-stories' === $attributes['blockType'] ) ) { $story_attributes = [ 'align' => $attributes['align'], 'view_type' => $attributes['viewType'] ??= '', 'archive_link_label' => $attributes['archiveLinkLabel'] ??= __( 'View all stories', 'web-stories' ), 'circle_size' => $attributes['circleSize'] ??= 96, 'image_alignment' => $attributes['imageAlignment'] ??= 96, 'number_of_columns' => $attributes['numOfColumns'] ??= 2, ]; /** * Story Attributes. * * @phpstan-var StoryAttributes $story_attributes */ $story_attributes = array_merge( $story_attributes, $this->get_mapped_field_states() ); return ( new Story_Query( $story_attributes, $this->get_query_args() ) )->render(); } // Embedding a single story by URL. $attributes = wp_parse_args( $attributes, $this->default_attrs() ); $attributes['class'] = 'wp-block-web-stories-embed'; return $this->render( $attributes ); } /** * Maps fields to the story params. * * @since 1.5.0 * * @return array */ public function get_mapped_field_states(): array { $controls = [ 'show_title' => 'title', 'show_author' => 'author', 'show_excerpt' => 'excerpt', 'show_date' => 'date', 'show_archive_link' => 'archive_link', 'sharp_corners' => 'sharp_corners', ]; $controls_state = []; foreach ( $controls as $control => $field ) { $key = 'show_' . $field; $controls_state[ $control ] = $this->block_attributes['fieldState'][ $key ] ?? false; } return $controls_state; } /** * Registers a block type from metadata stored in the `block.json` file. * * @since 1.9.0 */ protected function register_block_type(): void { $base_path = $this->assets->get_base_path( 'blocks/embed/block.json' ); // Note: does not use 'script' and 'style' args, and instead uses 'render_callback' // to enqueue these assets only when needed. register_block_type_from_metadata( $base_path, [ 'attributes' => [ 'blockType' => [ 'type' => 'string', ], 'url' => [ 'type' => 'string', ], 'title' => [ 'type' => 'string', 'default' => __( 'Web Story', 'web-stories' ), ], 'poster' => [ 'type' => 'string', ], 'width' => [ 'type' => 'number', 'default' => 360, ], 'height' => [ 'type' => 'number', 'default' => 600, ], 'align' => [ 'type' => 'string', 'default' => 'none', ], 'stories' => [ 'type' => 'array', 'default' => [], ], 'viewType' => [ 'type' => 'string', 'default' => '', ], 'numOfStories' => [ 'type' => 'number', 'default' => 5, ], 'numOfColumns' => [ 'type' => 'number', 'default' => 2, ], 'circleSize' => [ 'type' => 'number', 'default' => 96, ], 'imageAlignment' => [ 'type' => 'number', 'default' => 96, ], 'orderby' => [ 'type' => 'string', 'default' => '', ], 'order' => [ 'type' => 'string', 'default' => '', ], 'archiveLinkLabel' => [ 'type' => 'string', 'default' => __( 'View all stories', 'web-stories' ), ], 'authors' => [ 'type' => 'array', 'default' => [], ], 'fieldState' => [ 'type' => 'object', 'default' => [], ], ], 'render_callback' => [ $this, 'render_block' ], 'editor_script' => self::SCRIPT_HANDLE, 'editor_style' => self::SCRIPT_HANDLE, ] ); } /** * Returns script settings. * * @since 1.5.0 * * @return array Script settings. */ private function get_script_settings(): array { $settings = [ 'publicPath' => $this->assets->get_base_url( 'assets/js/' ), 'config' => [ 'maxNumOfStories' => self::MAX_NUM_OF_STORIES, 'archiveURL' => get_post_type_archive_link( $this->story_post_type->get_slug() ), 'api' => [ 'stories' => trailingslashit( $this->story_post_type->get_rest_url() ), 'users' => '/web-stories/v1/users/', ], 'fieldStates' => $this->stories_script_data->fields_states(), ], ]; /** * Filters settings passed to the web stories block. * * @param array $settings Array of settings passed to web stories block. */ return apply_filters( 'web_stories_block_settings', $settings ); } /** * Initializes class variable $block_attributes. * * @since 1.5.0 * * @param array $block_attributes Array containing block attributes. * @return bool Whether or not block attributes have been initialized with given value. * * @phpstan-param BlockAttributes $block_attributes */ protected function initialize_block_attributes( array $block_attributes = [] ): bool { if ( ! empty( $block_attributes ) ) { $this->block_attributes = $block_attributes; return true; } return false; } /** * Returns arguments to be passed to the WP_Query object initialization. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.5.0 * * @return array Query arguments. */ protected function get_query_args(): array { $attributes = $this->block_attributes; $query_args = [ 'post_type' => $this->story_post_type->get_slug(), 'post_status' => 'publish', 'suppress_filters' => false, 'no_found_rows' => true, ]; if ( ! empty( $attributes['taxQuery'] ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query $query_args['tax_query'] = []; foreach ( $attributes['taxQuery'] as $taxonomy => $terms ) { if ( is_taxonomy_viewable( $taxonomy ) && ! empty( $terms ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query $query_args['tax_query'][] = [ 'taxonomy' => $taxonomy, 'terms' => array_filter( array_map( '\intval', $terms ) ), 'include_children' => false, ]; } } } // if block type is 'selected-tories'. if ( ! empty( $attributes['blockType'] ) && 'selected-stories' === $attributes['blockType'] && ! empty( $attributes['stories'] ) ) { $query_args['post__in'] = $attributes['stories']; $query_args['orderby'] = 'post__in'; return $query_args; } if ( ! empty( $attributes['numOfStories'] ) ) { $query_args['posts_per_page'] = $attributes['numOfStories']; } if ( ! empty( $attributes['order'] ) ) { $query_args['order'] = strtoupper( $attributes['order'] ); } if ( ! empty( $attributes['orderby'] ) ) { $query_args['orderby'] = 'title' === $attributes['orderby'] ? 'post_title' : 'post_date'; } if ( ! empty( $attributes['authors'] ) ) { $query_args['author__in'] = $attributes['authors']; } return $query_args; } } ================================================ FILE: includes/Context.php ================================================ story_post_type = $story_post_type; } /** * Determine whether the current response is a single web story. * * @since 1.15.0 * * @return bool Whether it is singular story post (and thus an AMP endpoint). */ public function is_web_story(): bool { return is_singular( $this->story_post_type->get_slug() ) && ! is_embed() && ! post_password_required(); } /** * Determine whether the current response being served as AMP. * * @since 1.15.0 * * @return bool Whether it is singular story post (and thus an AMP endpoint). */ public function is_amp(): bool { if ( $this->is_web_story() ) { return true; } // Check for `amp_is_request()` first since `is_amp_endpoint()` is deprecated. if ( \function_exists( '\amp_is_request' ) ) { return amp_is_request(); } if ( \function_exists( '\is_amp_endpoint' ) ) { return is_amp_endpoint(); } return false; } /** * Determines whether we're currently on the story editor screen. * * @since 1.15.0 * * @return bool Whether we're currently on the story editor screen. */ public function is_story_editor(): bool { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && $this->story_post_type->get_slug() === $screen->post_type && 'post' === $screen->base; } /** * Determines whether we're currently on the media upload screen. * * @since 1.15.0 * * @return bool Whether we're currently on the media upload screen */ public function is_upload_screen(): bool { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && 'upload' === $screen->id; } /** * Whether we're currently on a block editor screen. * * @since 1.15.0 */ public function is_block_editor(): bool { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && $screen->is_block_editor(); } /** * Returns the current screen base if available. * * @since 1.15.0 * * @return string|null Current screen base if available. */ public function get_screen_base(): ?string { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen->base ?? null; } /** * Returns the current screen post type if available. * * @since 1.15.0 * * @return string|null Current screen post type if available. */ public function get_screen_post_type(): ?string { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen->post_type ?? null; } } ================================================ FILE: includes/Database_Upgrader.php ================================================ Migrations\Update_1::class, '2.0.0' => Migrations\Replace_Conic_Style_Presets::class, '2.0.1' => Migrations\Add_Media_Source_Editor::class, '2.0.2' => Migrations\Remove_Broken_Text_Styles::class, '2.0.3' => Migrations\Unify_Color_Presets::class, '2.0.4' => Migrations\Update_Publisher_Logos::class, '3.0.0' => Migrations\Add_Stories_Caps::class, '3.0.1' => Migrations\Rewrite_Flush::class, '3.0.2' => Migrations\Rewrite_Flush::class, '3.0.4' => Migrations\Add_Poster_Generation_Media_Source::class, '3.0.5' => Migrations\Remove_Unneeded_Attachment_Meta::class, '3.0.6' => Migrations\Add_Media_Source_Video_Optimization::class, '3.0.7' => Migrations\Add_Media_Source_Source_Video::class, '3.0.8' => Migrations\Rewrite_Flush::class, '3.0.9' => Migrations\Add_VideoPress_Poster_Generation_Media_Source::class, '3.0.10' => Migrations\Add_Media_Source_Gif_Conversion::class, '3.0.11' => Migrations\Add_Media_Source_Source_Image::class, '3.0.12' => Migrations\Set_Legacy_Analytics_Usage_Flag::class, '3.0.13' => Migrations\Add_Stories_Caps::class, '3.0.14' => Migrations\Add_Media_Source_Page_Template::class, '3.0.15' => Migrations\Add_Media_Source_Recording::class, '3.0.16' => Migrations\Remove_Incorrect_Tracking_Id::class, ]; /** * Injector instance. * * @var Injector Injector instance. */ private Injector $injector; /** * Database_Upgrader constructor. * * @param Injector $injector Injector instance. */ public function __construct( Injector $injector ) { $this->injector = $injector; } /** * Hooked into admin_init and walks through an array of upgrade methods. * * @since 1.0.0 */ public function register(): void { add_action( 'admin_init', [ $this, 'run_upgrades' ], 5 ); } /** * Act on plugin activation. * * @since 1.6.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->run_upgrades(); } /** * Act on site initialization. * * @since 1.11.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { $this->run_upgrades(); } /** * Run all upgrade routines in order. * * @since 1.11.0 */ public function run_upgrades(): void { /** * Current database version. * * @var string $version */ $version = get_option( self::OPTION, '0.0.0' ); if ( '0.0.0' === $version ) { $this->finish_up( $version ); return; } if ( version_compare( WEBSTORIES_DB_VERSION, $version, '=' ) ) { return; } $routines = self::ROUTINES; array_walk( $routines, [ $this, 'run_upgrade_routine' ], $version ); $this->finish_up( $version ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_option( self::PREVIOUS_OPTION ); delete_option( self::OPTION ); } /** * Runs the upgrade routine. * * @since 1.0.0 * * @param class-string $class_name The Class to call. * @param string $version The new version. * @param string $current_version The current set version. */ protected function run_upgrade_routine( string $class_name, string $version, string $current_version ): void { if ( version_compare( $current_version, $version, '<' ) ) { /** * Instance of a migration class. * * @var Migration $routine */ $routine = $this->injector->make( $class_name ); $routine->migrate(); } } /** * Runs the needed cleanup after an update, setting the DB version to latest version, flushing caches etc. * * @since 1.0.0 * * @param string $previous_version The previous version. */ protected function finish_up( string $previous_version ): void { update_option( self::PREVIOUS_OPTION, $previous_version ); update_option( self::OPTION, WEBSTORIES_DB_VERSION ); } } ================================================ FILE: includes/Decoder.php ================================================ load_demo_content_from_file(); $content = $this->localize_texts( $content ); $content = $this->update_assets_urls( $content ); // Quick sanity check to see if the JSON is still valid. if ( null === json_decode( $content, true ) ) { return ''; } return $content; } /** * Updates URLs to media assets in demo content. * * @param string $content Original content. * @return string Modified content. */ private function update_assets_urls( string $content ): string { return str_replace( 'https://replaceme.com/', trailingslashit( WEBSTORIES_CDN_URL ), $content ); } /** * Localizes demo content. * * @param string $content Original content. * @return string Localized text. */ private function localize_texts( string $content ): string { $replacements = [ // Page 1. 'L10N_PLACEHOLDER_1_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Tips to make the most of the Web Stories Editor', 'demo content', 'web-stories' ), // Page 2. 'L10N_PLACEHOLDER_2_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Set a page background', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_2_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Drag your image or video to the edge of the page to set as page background.', 'demo content', 'web-stories' ), // Page 3. 'L10N_PLACEHOLDER_3_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Media Edit Mode', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_3_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Double-click the image/video to resize, re-center or crop. Note: media set as page background cannot be cropped.', 'demo content', 'web-stories' ), // Page 4. 'L10N_PLACEHOLDER_4_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Background Overlay', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_4_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Once you\'ve set a page bg, add a solid, linear or radial gradient overlay to increase text contrast or add visual styling.', 'demo content', 'web-stories' ), // Page 5. 'L10N_PLACEHOLDER_5_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Safe Zone', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_5_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Add your designs to the page, keeping crucial elements inside the safe zone to ensure they are visible across most devices.', 'demo content', 'web-stories' ), // Page 6. 'L10N_PLACEHOLDER_6_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Story System Layer', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_6_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'The system layer is docked at the top. Preview your story to ensure system layer icons are not blocking crucial elements.', 'demo content', 'web-stories' ), // Page 7. 'L10N_PLACEHOLDER_7_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Shapes and Masks', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_7_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Our shapes are quite basic for now but they act as masks. Drag an image or video into the mask.', 'demo content', 'web-stories' ), // Page 8. 'L10N_PLACEHOLDER_8_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Embed Visual Stories', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_8_2' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Embed stories into your blog post. Open the block menu & select the Web Stories block. Insert the story link to embed your story. That\'s it!', 'demo content', 'web-stories' ), // Page 9. 'L10N_PLACEHOLDER_9_1' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Read about best practices for creating successful Web Stories', 'demo content', 'web-stories' ), 'L10N_PLACEHOLDER_9_2' => /* translators: demo content used in the "Get Started" story */ esc_url( _x( 'https://amp.dev/documentation/guides-and-tutorials/start/create_successful_stories/', 'demo content', 'web-stories' ) ), 'L10N_PLACEHOLDER_9_3' => /* translators: demo content used in the "Get Started" story */ esc_html_x( 'Best Practices', 'demo content', 'web-stories' ), ]; foreach ( $replacements as $search => $replacement ) { $content = str_replace( $search, $replacement, $content ); } return $content; } /** * Loads demo content from JSON file. */ private function load_demo_content_from_file(): string { $file = WEBSTORIES_PLUGIN_DIR_PATH . 'includes/data/stories/demo.json'; if ( ! is_readable( $file ) ) { return ''; } $content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown if ( ! $content ) { return ''; } return $content; } } ================================================ FILE: includes/Discovery.php ================================================ story_post_type = $story_post_type; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type', 'product_meta' ]; } /** * Initialize discovery functionality. * * @since 1.0.0 */ public function register(): void { add_action( 'web_stories_story_head', [ $this, 'print_document_title' ] ); add_action( 'web_stories_story_head', [ $this, 'print_metadata' ] ); add_action( 'web_stories_story_head', [ $this, 'print_schemaorg_metadata' ] ); add_action( 'web_stories_story_head', [ $this, 'print_open_graph_metadata' ] ); add_action( 'web_stories_story_head', [ $this, 'print_twitter_metadata' ] ); add_action( 'web_stories_story_head', [ $this, 'print_feed_link' ], 4 ); add_action( 'wp_head', [ $this, 'print_feed_link' ], 4 ); // @todo Check if there's something to skip in the new version. add_action( 'web_stories_story_head', 'rest_output_link_wp_head', 10, 0 ); add_action( 'web_stories_story_head', 'wp_resource_hints', 2 ); add_action( 'web_stories_story_head', 'feed_links', 2 ); add_action( 'web_stories_story_head', 'feed_links_extra', 3 ); add_action( 'web_stories_story_head', 'rsd_link' ); add_action( 'web_stories_story_head', 'adjacent_posts_rel_link_wp_head', 10, 0 ); add_action( 'web_stories_story_head', 'wp_generator' ); add_action( 'web_stories_story_head', 'rel_canonical' ); add_action( 'web_stories_story_head', 'wp_shortlink_wp_head', 10, 0 ); add_action( 'web_stories_story_head', 'wp_site_icon', 99 ); add_action( 'web_stories_story_head', 'wp_oembed_add_discovery_links' ); add_action( 'web_stories_story_head', 'wp_robots', 1 ); } /** * Prints document title for stories. * * Adds the title regardless of theme support. * * AMP sanitization will ensure there is always just exactly one title tag present. * * @since 1.25.0 * * @link https://github.com/GoogleForCreators/web-stories-wp/issues/12139 * @link https://github.com/GoogleForCreators/web-stories-wp/issues/12487 * @link https://github.com/GoogleForCreators/web-stories-wp/issues/12655 */ public function print_document_title(): void { /** * Filters whether to print the document title. * * @since 1.25.0 * * @param bool $enable_open_graph Whether to print the document title. Default to true. */ $enable_metadata = apply_filters( 'web_stories_enable_document_title', true ); if ( ! $enable_metadata ) { return; } ?> <?php echo esc_html( wp_get_document_title() ); ?> get_schemaorg_metadata(); ?> get_open_graph_metadata(); foreach ( $metadata as $name => $value ) { printf( '', esc_attr( $name ), esc_attr( (string) $value ) ); } } /** * Prints Twitter card metadata. * * @since 1.0.0 */ public function print_twitter_metadata(): void { /** * Filters filter to enable / disable twitter metadata. * * @since 1.2.0 * * @param bool $enable_twitter_metadata Enable / disable twitter metadata. Default to true. */ $enable_twitter_metadata = apply_filters( 'web_stories_enable_twitter_metadata', true ); if ( ! $enable_twitter_metadata ) { return; } $metadata = $this->get_twitter_metadata(); foreach ( $metadata as $name => $value ) { printf( '', esc_attr( $name ), esc_attr( $value ) ); } } /** * Add RSS feed link for stories, if theme supports automatic-feed-links. * * @since 1.0.0 */ public function print_feed_link(): void { $enable_print_feed_link = current_theme_supports( 'automatic-feed-links' ) && ! is_post_type_archive( $this->story_post_type->get_slug() ); /** * Filters filter to enable / disable printing feed links. * * @since 1.29.0 * * @param bool $enable_print_feed_link Enable / disable printing feed links. Default to if automatic-feed-links is enabled. */ $enable_print_feed_link = apply_filters( 'web_stories_enable_print_feed_link', $enable_print_feed_link ); if ( ! $enable_print_feed_link ) { return; } $name = $this->story_post_type->get_label( 'name' ); if ( ! $name ) { return; } $feed = get_post_type_archive_feed_link( $this->story_post_type->get_slug() ); if ( ! $feed ) { return; } /* translators: Separator between blog name and feed type in feed links. */ $separator = _x( '»', 'feed link', 'web-stories' ); /* translators: 1: Blog name, 2: Separator (raquo), 3: Post type name. */ $post_type_title = esc_html__( '%1$s %2$s %3$s Feed', 'web-stories' ); $title = \sprintf( $post_type_title, get_bloginfo( 'name' ), $separator, $name ); printf( '', esc_attr( feed_content_type() ), esc_attr( $title ), esc_url( $feed ) ); } /** * Get schema.org metadata for the current query. * * @since 1.0.0 * * @see https://developers.google.com/search/docs/guides/enable-web-stories * * @return array $metadata All schema.org metadata for the post. */ protected function get_schemaorg_metadata(): array { /** * We're expecting a post object. * * @var WP_Post|null $post */ $post = get_queried_object(); $story = new Story(); $story->load_from_post( $post ); $metadata = [ '@context' => 'http://schema.org', 'publisher' => [ '@type' => 'Organization', 'name' => $story->get_publisher_name(), ], ]; if ( $post instanceof WP_Post ) { $url = $story->get_publisher_logo_url(); $size = $story->get_publisher_logo_size(); if ( ! empty( $url ) && ! empty( $size ) ) { $metadata['publisher']['logo'] = [ '@type' => 'ImageObject', 'url' => $url, 'width' => $size[0], 'height' => $size[1], ]; } $poster = $story->get_poster_portrait(); $poster_size = $story->get_poster_portrait_size(); if ( $poster && $poster_size ) { $metadata['image'] = [ '@type' => 'ImageObject', 'url' => $poster, 'width' => $poster_size[0], 'height' => $poster_size[1], ]; } $metadata = array_merge( $metadata, [ '@type' => 'Article', 'mainEntityOfPage' => $story->get_url(), 'headline' => $story->get_title(), 'datePublished' => mysql2date( 'c', $post->post_date_gmt, false ), 'dateModified' => mysql2date( 'c', $post->post_modified_gmt, false ), ] ); $post_author = get_userdata( (int) $post->post_author ); if ( $post_author ) { $metadata['author'] = [ '@type' => 'Person', 'name' => html_entity_decode( $post_author->display_name, ENT_QUOTES, get_bloginfo( 'charset' ) ), ]; } /** * List of products. * * @phpstan-var Product[] $products */ $products = $story->get_products(); $product_metadata = $this->get_product_data( $products ); if ( $product_metadata ) { $metadata = array_merge( $product_metadata, $metadata ); } } /** * Filters the schema.org metadata for a given story. * * @since 1.0.0 * * @param array $metadata The structured data. * @param WP_Post|null $post The current post object if available. */ return apply_filters( 'web_stories_story_schema_metadata', $metadata, $post ); } /** * Get product schema data. * * @since 1.22.0 * * @param Product[] $products Array of products. * @return array>|string>> * * @phpstan-param Product[] $products */ protected function get_product_data( array $products ): array { if ( ! $products ) { return []; } $product_data = []; foreach ( $products as $product ) { $data = [ '@type' => 'Product', 'brand' => $product->get_brand(), 'productID' => $product->get_id(), 'url' => $product->get_url(), 'name' => $product->get_title(), 'description' => $product->get_details(), 'offers' => [ [ '@type' => 'Offer', 'price' => $product->get_price(), 'priceCurrency' => $product->get_price_currency(), ], ], ]; if ( $product->get_images() ) { $data['image'] = $product->get_images()[0]['url']; } $aggregate_rating = $product->get_aggregate_rating(); if ( ! empty( $aggregate_rating['review_count'] ) ) { $data['aggregateRating'] = [ '@type' => 'AggregateRating', 'ratingValue' => $aggregate_rating['rating_value'] ??= 0, 'reviewCount' => $aggregate_rating['review_count'], 'url' => $aggregate_rating['review_url'] ??= '', ]; } $product_data[] = $data; } return [ 'mainEntity' => [ '@type' => 'ItemList', 'numberOfItems' => (string) \count( $products ), 'itemListElement' => $product_data, ], ]; } /** * Get Open Graph metadata. * * @since 1.3.0 * * @return array */ protected function get_open_graph_metadata(): array { $metadata = [ 'og:locale' => get_bloginfo( 'language' ), 'og:site_name' => get_bloginfo( 'name' ), ]; /** * We're expecting a post object. * * @var WP_Post|null $post */ $post = get_queried_object(); if ( $post instanceof WP_Post ) { $story = new Story(); $story->load_from_post( $post ); $metadata['og:type'] = 'article'; $metadata['og:title'] = $story->get_title(); $metadata['og:url'] = $story->get_url(); $metadata['og:description'] = wp_strip_all_tags( get_the_excerpt( $post ) ); $metadata['article:published_time'] = (string) get_the_date( 'c', $post ); $metadata['article:modified_time'] = (string) get_the_modified_date( 'c', $post ); $poster_url = $story->get_poster_portrait(); $poster_sizes = $story->get_poster_portrait_size(); if ( $poster_url && $poster_sizes ) { $metadata['og:image'] = esc_url( $poster_url ); $metadata['og:image:width'] = $poster_sizes[0]; $metadata['og:image:height'] = $poster_sizes[1]; } } /** * Filters the open graph metadata for a given story. * * @since 1.3.0 * * @param array $metadata The structured data. * @param WP_Post|null $post The current post object if available. */ return apply_filters( 'web_stories_story_open_graph_metadata', $metadata, $post ); } /** * Get Twitter card metadata. * * @since 1.3.0 * * @return array Twitter card metadata. */ protected function get_twitter_metadata(): array { $metadata = [ 'twitter:card' => 'summary_large_image', ]; /** * We're expecting a post object. * * @var WP_Post|null $post */ $post = get_queried_object(); if ( $post instanceof WP_Post ) { $story = new Story(); $story->load_from_post( $post ); $poster = $story->get_poster_portrait(); if ( $poster ) { $metadata['twitter:image'] = esc_url( $poster ); $metadata['twitter:image:alt'] = $story->get_title(); } } /** * Filters the twitter metadata for a given story. * * @since 1.3.0 * * @param array $metadata The structured data. * @param WP_Post|null $post The current post object if available. */ return apply_filters( 'web_stories_story_twitter_metadata', $metadata, $post ); } } ================================================ FILE: includes/Embed_Base.php ================================================ assets = $assets; $this->context = $context; } /** * Initializes the Web Stories embed block. * * @since 1.1.0 */ public function register(): void { if ( wp_style_is( self::SCRIPT_HANDLE, 'registered' ) ) { return; } $this->assets->register_style_asset( self::SCRIPT_HANDLE ); if ( \defined( 'AMPFORWP_VERSION' ) ) { add_action( 'amp_post_template_css', [ $this, 'add_amp_post_template_css' ] ); } add_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] ); } /** * Get the action priority to use for registering the service. * * @since 1.6.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 11; } /** * Prints required inline CSS when using the AMP for WP plugin. * * @since 1.13.0 */ public function add_amp_post_template_css(): void { $path = $this->assets->get_base_path( \sprintf( 'assets/css/%s%s.css', self::SCRIPT_HANDLE, is_rtl() ? '-rtl' : '' ) ); if ( is_readable( $path ) ) { $css = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown echo $css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** * Filter the allowed tags for KSES to allow for amp-story children. * * @since 1.0.0 * * @param array>|mixed $allowed_tags Allowed tags. * @return array>|mixed Allowed tags. * * @template T * * @phpstan-return ($allowed_tags is array ? array : mixed) */ public function filter_kses_allowed_html( $allowed_tags ) { if ( ! \is_array( $allowed_tags ) ) { return $allowed_tags; } $story_player_components = [ 'amp-story-player' => [], ]; $allowed_tags = array_merge( $allowed_tags, $story_player_components ); return $allowed_tags; } /** * Renders a story with given attributes. * * @since 1.30.0 * * @param Story $story Story instance. * @param array $attributes Embed render attributes. * @return string Rendered embed output. */ public function render_story( Story $story, array $attributes ): string { if ( is_feed() ) { $renderer = new Image( $story ); } elseif ( ! empty( $attributes['previewOnly'] ) ) { $renderer = new Singleton( $story, $this->assets ); } else { $renderer = new Embed( $story, $this->assets, $this->context ); } return $renderer->render( $attributes ); } /** * Renders an embed with given attributes. * * @since 1.1.0 * * @param array $attributes Embed render attributes. * @return string Rendered embed output. */ public function render( array $attributes ): string { // The only mandatory attribute. if ( empty( $attributes['url'] ) && empty( $attributes['previewOnly'] ) ) { return ''; } if ( empty( $attributes['title'] ) ) { $attributes['title'] = __( 'Web Story', 'web-stories' ); } $data = [ 'title' => $attributes['title'], 'url' => $attributes['url'], 'poster_portrait' => $attributes['poster'], ]; $story = new Story( $data ); return $this->render_story( $story, $attributes ); } /** * Return an array of default attributes. * * @since 1.1.0 * * @return array Default attributes. */ protected function default_attrs(): array { $attrs = [ 'align' => 'none', 'height' => 600, 'poster' => '', 'url' => '', 'title' => '', 'width' => 360, 'previewOnly' => false, ]; /** * Filters settings passed to the web stories embed. * * @since 1.1.0 * * @param array $attrs Array of settings passed to web stories embed. */ return apply_filters( 'web_stories_embed_default_attributes', $attrs ); } } ================================================ FILE: includes/Exception/FailedToMakeInstance.php ================================================ get_chain() as $link ) { $message .= "{$link}\n"; } return new self( $message, self::CIRCULAR_REFERENCE ); } /** * Create a new instance of the exception for an interface that could not * be resolved to an instantiable class. * * @since 1.6.0 * * @param string $interface_name Interface that was left unresolved. */ public static function for_unresolved_interface( string $interface_name ): self { $message = \sprintf( 'Could not resolve the interface "%s" to an instantiable class, probably forgot to bind an implementation.', $interface_name ); return new self( $message, self::UNRESOLVED_INTERFACE ); } /** * Create a new instance of the exception for an interface or class that * could not be reflected upon. * * @since 1.6.0 * * @param string $interface_or_class Interface or class that could not be * reflected upon. */ public static function for_unreflectable_class( string $interface_or_class ): self { $message = \sprintf( 'Could not reflect on the interface or class "%s", probably not a valid FQCN.', $interface_or_class ); return new self( $message, self::UNREFLECTABLE_CLASS ); } /** * Create a new instance of the exception for an argument that could not be * resolved. * * @since 1.6.0 * * @param string $argument_name Name of the argument that could not be * resolved. * @param string $class_name Class that had the argument in its * constructor. */ public static function for_unresolved_argument( string $argument_name, string $class_name ): self { $message = \sprintf( 'Could not resolve the argument "%s" while trying to instantiate the class "%s".', $argument_name, $class_name ); return new self( $message, self::UNRESOLVED_ARGUMENT ); } /** * Create a new instance of the exception for a class that was meant to be * reused but was not yet instantiated. * * @since 1.6.0 * * @param string $class_name Class that was not yet instantiated. */ public static function for_uninstantiated_shared_instance( string $class_name ): self { $message = \sprintf( 'Could not retrieve the shared instance for "%s" as it was not instantiated yet.', $class_name ); return new self( $message, self::UNINSTANTIATED_SHARED_INSTANCE ); } /** * Create a new instance of the exception for a delegate that was requested * for a class that doesn't have one. * * @since 1.6.0 * * @param string $class_name Class for which there is no delegate. */ public static function for_invalid_delegate( string $class_name ): self { $message = \sprintf( 'Could not retrieve a delegate for "%s", none was defined.', $class_name ); return new self( $message, self::INVALID_DELEGATE ); } } ================================================ FILE: includes/Exception/InvalidEventProperties.php ================================================ settings = $settings; } /** * Initializes experiments */ public function register(): void { if ( WEBSTORIES_DEV_MODE ) { add_action( 'admin_menu', [ $this, 'add_menu_page' ], 25 ); add_action( 'admin_init', [ $this, 'initialize_settings' ] ); } } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Registers the experiments admin menu page. * * @since 1.0.0 */ public function add_menu_page(): void { add_submenu_page( 'edit.php?post_type=' . Story_Post_Type::POST_TYPE_SLUG, __( 'Experiments', 'web-stories' ), __( 'Experiments', 'web-stories' ), 'manage_options', 'web-stories-experiments', [ $this, 'render' ], 25 ); } /** * Renders the experiments page. * * @codeCoverageIgnore */ public function render(): void { require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/experiments.php'; } /** * Initializes the experiments settings page. * * @since 1.0.0 */ public function initialize_settings(): void { add_settings_section( 'web_stories_experiments_section', // The empty string ensures the render function won't output a h2. '', [ $this, 'display_experiment_section' ], self::PAGE_NAME ); foreach ( $this->get_experiment_groups() as $group => $label ) { add_settings_section( $group, $label, '__return_empty_string', self::PAGE_NAME ); } $experiments = $this->get_experiments(); foreach ( $experiments as $experiment ) { add_settings_field( $experiment['name'], $experiment['label'], [ $this, 'display_experiment_field' ], self::PAGE_NAME, $experiment['group'], [ 'label' => $experiment['description'], 'id' => $experiment['name'], 'default' => \array_key_exists( 'default', $experiment ) && $experiment['default'], ] ); } } /** * Display a checkbox field for a single experiment. * * @since 1.0.0 * * @param array $args { * Array of arguments for displaying a single field. * * @type string $id Experiment ID. * @type string $label Experiment label. * @type bool $default Whether the experiment is enabled by default. * } * * @phpstan-param array{id: string, label: string, default: bool} $args */ public function display_experiment_field( array $args ): void { $is_enabled_by_default = ! empty( $args['default'] ); $checked = $is_enabled_by_default || $this->is_experiment_enabled( $args['id'] ); $disabled = $is_enabled_by_default ? 'disabled' : ''; ?>

List of experiment groups */ public function get_experiment_groups(): array { return [ 'general' => __( 'General', 'web-stories' ), 'dashboard' => __( 'Dashboard', 'web-stories' ), 'editor' => __( 'Editor', 'web-stories' ), ]; } /** * Returns a list of all experiments. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.0.0 * * @return array List of experiments by group. * * @phpstan-return Experiment[] */ public function get_experiments(): array { return [ /** * Author: @brittanyirl * Issue: 2381 * Creation date: 2020-06-11 */ [ 'name' => 'enableInProgressTemplateActions', 'label' => __( 'Template actions', 'web-stories' ), 'description' => __( 'Enable in-progress template actions', 'web-stories' ), 'group' => 'dashboard', ], /** * Author: @spacedmonkey * Issue: #798 * Creation date: 2020-11-02 */ [ 'name' => 'enableSVG', 'label' => __( 'SVG upload', 'web-stories' ), 'description' => __( 'Enable SVG upload', 'web-stories' ), 'group' => 'general', ], /** * Author: @timarney * Issue: #12093 * Creation date: 2022-08-18 */ [ 'name' => 'offScreenVideoCropping', 'label' => __( 'Crop off-screen video parts', 'web-stories' ), 'description' => __( 'Enable support for cropping cut off-screen parts of videos', 'web-stories' ), 'group' => 'editor', ], /** * Author: @spacedmonkey * Issue: #12211 * Creation date: 2022-09-07 */ [ 'name' => 'videoVolume', 'label' => __( 'Video Volume', 'web-stories' ), 'description' => __( 'Enable setting video volume', 'web-stories' ), 'group' => 'editor', ], /** * Author: @timarney * Issue: #12164 * Creation date: 2022-09-19 */ [ 'name' => 'segmentVideo', 'label' => __( 'Segment video', 'web-stories' ), 'description' => __( 'Enable support for segmenting video files', 'web-stories' ), 'group' => 'editor', ], ]; } /** * Returns the experiment statuses for a given group. * * @since 1.0.0 * * @param string $group Experiments group name. * @return array Experiment statuses with name as key and status as value. */ public function get_experiment_statuses( string $group ): array { /** * List of experiments. * * @phpstan-var Experiment[] */ $experiments = wp_list_filter( $this->get_experiments(), [ 'group' => $group ] ); if ( empty( $experiments ) ) { return []; } $result = []; foreach ( $experiments as $experiment ) { $result[ $experiment['name'] ] = $this->is_experiment_enabled( $experiment['name'] ); } return $result; } /** * Checks whether an experiment is enabled. * * @since 1.0.0 * * @param string $name Experiment name. * @return bool Whether the experiment is enabled. */ public function is_experiment_enabled( string $name ): bool { $experiment = $this->get_experiment( $name ); if ( ! $experiment ) { return false; } if ( \array_key_exists( 'default', $experiment ) ) { return (bool) $experiment['default']; } /** * List of enabled experiments. * * @var array $experiments * @phpstan-var Experiment[] */ $experiments = $this->settings->get_setting( $this->settings::SETTING_NAME_EXPERIMENTS, [] ); return ! empty( $experiments[ $name ] ); } /** * Returns the names of all enabled experiments. * * @since 1.4.0 * * @return string[] List of all enabled experiments. */ public function get_enabled_experiments(): array { return array_filter( wp_list_pluck( $this->get_experiments(), 'name' ), [ $this, 'is_experiment_enabled' ] ); } /** * Returns an experiment by name. * * @since 1.3.0 * * @param string $name Experiment name. * @return array|null Experiment if found, null otherwise. * * @phpstan-return Experiment|null */ protected function get_experiment( string $name ): ?array { $experiment = wp_list_filter( $this->get_experiments(), [ 'name' => $name ] ); return ! empty( $experiment ) ? array_shift( $experiment ) : null; } } ================================================ FILE: includes/Font_Post_Type.php ================================================ story_post_type = $story_post_type; } /** * Get post type slug. * * @since 1.16.0 */ public function get_slug(): string { return self::POST_TYPE_SLUG; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.16.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers the post type for fonts. * * @since 1.16.0 * * @return array Post type args. * * @phpstan-return PostTypeArgs */ protected function get_args(): array { /** * The edit_posts capability. * * @var string $edit_posts */ $edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' ); // Only admins are allowed to modify custom fonts, // but anyone who can create stories should be able to use them. $capabilities = [ 'edit_post' => 'manage_options', 'read_post' => $edit_posts, 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'publish_posts' => 'manage_options', 'read_private_posts' => 'manage_options', 'delete_private_posts' => 'manage_options', 'delete_published_posts' => 'manage_options', 'delete_others_posts' => 'manage_options', 'edit_private_posts' => 'manage_options', 'edit_published_posts' => 'manage_options', 'create_posts' => 'manage_options', ]; return [ 'supports' => [ 'title', ], 'capabilities' => $capabilities, 'rewrite' => false, 'public' => false, 'show_ui' => false, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'rest_base' => 'fonts', 'rest_controller_class' => Font_Controller::class, ]; } } ================================================ FILE: includes/Infrastructure/Conditional.php ================================================ $dependencies Optional. Dependencies of the class. * @return T Instantiated object. * * @template T * * @phpstan-param class-string $class_name Class to make an object instance out of. */ public function instantiate( string $class_name, array $dependencies = [] ) { return new $class_name( ...$dependencies ); } } ================================================ FILE: includes/Infrastructure/Injector/InjectionChain.php ================================================ [] */ private array $chain = []; /** * Resolutions. * * @var array * @phpstan-var array */ private array $resolutions = []; /** * Add class to injection chain. * * @since 1.6.0 * * @param string $class_name Class to add to injection chain. * @return self Modified injection chain. * * @phpstan-param class-string $class_name */ public function add_to_chain( string $class_name ): self { $new_chain = clone $this; $new_chain->chain[] = $class_name; return $new_chain; } /** * Add resolution for circular reference detection. * * @since 1.6.0 * * @param string $resolution Resolution to add. * @return self Modified injection chain. * * @phpstan-param class-string $resolution */ public function add_resolution( string $resolution ): self { $new_chain = clone $this; $new_chain->resolutions[ $resolution ] = true; return $new_chain; } /** * Get the last class that was pushed to the injection chain. * * @since 1.6.0 * * @throws \LogicException If the injection chain is accessed too early. * * @return string Last class pushed to the injection chain. * * @phpstan-return class-string */ public function get_class(): string { if ( empty( $this->chain ) ) { throw new \LogicException( 'Access to injection chain before any resolution was made.' ); } return \end( $this->chain ) ?: ''; } /** * Get the injection chain. * * @since 1.6.0 * * @return string[] Chain of injections. * * @phpstan-return class-string[] */ public function get_chain(): array { return \array_reverse( $this->chain ); } /** * Check whether the injection chain already has a given resolution. * * @since 1.6.0 * * @param string $resolution Resolution to check for. * @return bool Whether the resolution was found. * * @phpstan-param class-string $resolution */ public function has_resolution( string $resolution ): bool { return \array_key_exists( $resolution, $this->resolutions ); } /** * Check whether the injection chain already encountered a class. * * @since 1.6.0 * * @param string $class_name Class to check. * @return bool Whether the given class is already part of the chain. */ public function is_in_chain( string $class_name ): bool { return \in_array( $class_name, $this->chain, true ); } } ================================================ FILE: includes/Infrastructure/Injector/SimpleInjector.php ================================================ [] */ private array $mappings = []; /** * Shared instances * * @var array */ private array $shared_instances = []; /** * Delegates. * * @var callable[] */ private array $delegates = []; /** * Argument mappings. * * @var array> */ private array $argument_mappings = []; /** * Instantiator. * * @var Instantiator */ private Instantiator $instantiator; /** * Instantiate a SimpleInjector object. * * @since 1.6.0 * * @param Instantiator|null $instantiator Optional. Instantiator to use. */ public function __construct( ?Instantiator $instantiator = null ) { $this->instantiator = $instantiator ?? new FallbackInstantiator(); } /** * Make an object instance out of an interface or class. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to make an object instance out of. * @param array $arguments Optional. Additional arguments to pass to the constructor. * Defaults to an empty array. * @return T Instantiated object. * * @phpstan-param class-string $interface_or_class Interface or class to make an object instance out of. * @phpstan-param array $arguments Optional. Additional arguments to pass to the constructor. */ public function make( string $interface_or_class, array $arguments = [] ) { $injection_chain = $this->resolve( new InjectionChain(), $interface_or_class ); $class = $injection_chain->get_class(); if ( $this->has_shared_instance( $class ) ) { return $this->get_shared_instance( $class ); } if ( $this->has_delegate( $class ) ) { $delegate = $this->get_delegate( $class ); $object = $delegate( $class ); } else { $reflection = $this->get_class_reflection( $class ); $this->ensure_is_instantiable( $reflection ); $dependencies = $this->get_dependencies_for( $injection_chain, $reflection, $arguments ); $object = $this->instantiator->instantiate( $class, $dependencies ); } if ( \array_key_exists( $class, $this->shared_instances ) ) { $this->shared_instances[ $class ] = $object; } return $object; } /** * Bind a given interface or class to an implementation. * * Note: The implementation can be an interface as well, as long as it can * be resolved to an instantiatable class at runtime. * * @since 1.6.0 * * @param string $from Interface or class to bind an implementation to. * @param string $to Interface or class that provides the implementation. * * @phpstan-param class-string $from Interface or class to bind an implementation to. * @phpstan-param class-string $to Interface or class that provides the implementation. */ public function bind( string $from, string $to ): Injector { $this->mappings[ $from ] = $to; return $this; } /** * Bind an argument for a class to a specific value. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to bind an argument for. * @param string $argument_name Argument name to bind a value to. * @param mixed $value Value to bind the argument to. */ public function bind_argument( string $interface_or_class, string $argument_name, $value ): Injector { $this->argument_mappings[ $interface_or_class ][ $argument_name ] = $value; return $this; } /** * Always reuse and share the same instance for the provided interface or * class. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to reuse. * * @phpstan-param class-string $interface_or_class Interface or class to reuse. */ public function share( string $interface_or_class ): Injector { $this->shared_instances[ $interface_or_class ] = null; return $this; } /** * Delegate instantiation of an interface or class to a callable. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to delegate the instantiation of. * @param callable $callback Callable to use for instantiation. * * @phpstan-param class-string $interface_or_class Interface or class to delegate the instantiation of. */ public function delegate( string $interface_or_class, callable $callback ): Injector { $this->delegates[ $interface_or_class ] = $callback; return $this; } /** * Make an object instance out of an interface or class. * * @since 1.6.0 * * @param InjectionChain $injection_chain Injection chain to track resolutions. * @param string $interface_or_class Interface or class to make an object instance out of. * @return T Instantiated object. * * @phpstan-param class-string $interface_or_class Interface or class to make an object instance out of. */ private function make_dependency( InjectionChain $injection_chain, string $interface_or_class ) { $injection_chain = $this->resolve( $injection_chain, $interface_or_class ); $class = $injection_chain->get_class(); if ( $this->has_shared_instance( $class ) ) { return $this->get_shared_instance( $class ); } if ( $this->has_delegate( $class ) ) { $delegate = $this->get_delegate( $class ); return $delegate( $class ); } $reflection = $this->get_class_reflection( $class ); $this->ensure_is_instantiable( $reflection ); $dependencies = $this->get_dependencies_for( $injection_chain, $reflection ); $object = $this->instantiator->instantiate( $class, $dependencies ); if ( \array_key_exists( $class, $this->shared_instances ) ) { $this->shared_instances[ $class ] = $object; } return $object; } /** * Recursively resolve an interface to the class it should be bound to. * * @since 1.6.0 * * @throws FailedToMakeInstance If a circular reference was detected. * * @param InjectionChain $injection_chain Injection chain to track resolutions. * @param string $interface_or_class Interface or class to resolve. * @return InjectionChain Modified Injection chain * * @phpstan-param class-string $interface_or_class Interface or class to resolve. */ private function resolve( InjectionChain $injection_chain, string $interface_or_class ): InjectionChain { if ( $injection_chain->is_in_chain( $interface_or_class ) ) { // Circular reference detected, aborting. throw FailedToMakeInstance::for_circular_reference( $interface_or_class, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped $injection_chain // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped ); } $injection_chain = $injection_chain->add_resolution( $interface_or_class ); if ( \array_key_exists( $interface_or_class, $this->mappings ) ) { return $this->resolve( $injection_chain, $this->mappings[ $interface_or_class ] ); } return $injection_chain->add_to_chain( $interface_or_class ); } /** * Get the array of constructor dependencies for a given reflected class. * * @since 1.6.0 * * @param InjectionChain $injection_chain Injection chain to track resolutions. * @param ReflectionClass $reflection Reflected class to get the dependencies for. * @param array $arguments Associative array of directly provided arguments. * @return array Array of dependencies that represent the arguments for the class' constructor. */ private function get_dependencies_for( InjectionChain $injection_chain, ReflectionClass $reflection, array $arguments = [] ): array { $constructor = $reflection->getConstructor(); $class = $reflection->getName(); if ( null === $constructor ) { return []; } return array_map( fn( ReflectionParameter $parameter ) => $this->resolve_argument( $injection_chain, $class, $parameter, $arguments ), $constructor->getParameters() ); } /** * Ensure that a given reflected class is instantiable. * * @since 1.6.0 * * @throws FailedToMakeInstance If the interface could not be resolved. * * @param ReflectionClass $reflection Reflected class to check. */ private function ensure_is_instantiable( ReflectionClass $reflection ): void { if ( ! $reflection->isInstantiable() ) { throw FailedToMakeInstance::for_unresolved_interface( $reflection->getName() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Resolve a given reflected argument. * * @since 1.6.0 * * @param InjectionChain $injection_chain Injection chain to track resolutions. * @param string $class_name Name of the class to resolve the arguments for. * @param ReflectionParameter $parameter Parameter to resolve. * @param array $arguments Associative array of directly provided arguments. * @return mixed Resolved value of the argument. */ private function resolve_argument( InjectionChain $injection_chain, string $class_name, ReflectionParameter $parameter, array $arguments ) { if ( ! $parameter->hasType() ) { return $this->resolve_argument_by_name( $class_name, $parameter, $arguments ); } $type = $parameter->getType(); // In PHP 8.0, the isBuiltin method was removed from the parent {@see ReflectionType} class. // @phpstan-ignore identical.alwaysFalse if ( null === $type || ( $type instanceof ReflectionNamedType && $type->isBuiltin() ) ) { return $this->resolve_argument_by_name( $class_name, $parameter, $arguments ); } /** * Interface or class. * * @var class-string $type */ $type = $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; return $this->make_dependency( $injection_chain, $type ); } /** * Resolve a given reflected argument by its name. * * @since 1.6.0 * * @throws FailedToMakeInstance If the argument could not be resolved. * * @param string $class_name Class to resolve the argument for. * @param ReflectionParameter $parameter Argument to resolve by name. * @param array $arguments Associative array of directly provided arguments. * @return mixed Resolved value of the argument. */ private function resolve_argument_by_name( string $class_name, ReflectionParameter $parameter, array $arguments ) { $name = $parameter->getName(); // The argument was directly provided to the make() call. if ( \array_key_exists( $name, $arguments ) ) { return $arguments[ $name ]; } // Check if we have mapped this argument for the specific class. if ( \array_key_exists( $class_name, $this->argument_mappings ) && \array_key_exists( $name, $this->argument_mappings[ $class_name ] ) ) { $value = $this->argument_mappings[ $class_name ][ $name ]; // Closures are immediately resolved, to provide lazy resolution. if ( \is_callable( $value ) ) { $value = $value( $class_name, $parameter, $arguments ); } return $value; } // No provided argument found, check if it has a default value. try { if ( $parameter->isDefaultValueAvailable() ) { return $parameter->getDefaultValue(); } } catch ( \Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Just fall through into the FailedToMakeInstance exception. } // Out of options, fail with an exception. throw FailedToMakeInstance::for_unresolved_argument( $name, $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Check whether a shared instance exists for a given class. * * @since 1.6.0 * * @param string $class_name Class to check for a shared instance. * @return bool Whether a shared instance exists. */ private function has_shared_instance( string $class_name ): bool { return \array_key_exists( $class_name, $this->shared_instances ) && null !== $this->shared_instances[ $class_name ]; } /** * Get the shared instance for a given class. * * @since 1.6.0 * * @throws FailedToMakeInstance If an uninstantiated shared instance is requested. * * @param string $class_name Class to get the shared instance for. * @return T Shared instance. * * @phpstan-param class-string $class_name Class to get the shared instance for. * @phpstan-return T Shared instance. */ private function get_shared_instance( string $class_name ) { if ( ! $this->has_shared_instance( $class_name ) ) { throw FailedToMakeInstance::for_uninstantiated_shared_instance( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Shared instance. * * @var T $instance */ $instance = $this->shared_instances[ $class_name ]; return $instance; } /** * Check whether a delegate exists for a given class. * * @since 1.6.0 * * @param string $class_name Class to check for a delegate. * @return bool Whether a delegate exists. */ private function has_delegate( string $class_name ): bool { return \array_key_exists( $class_name, $this->delegates ); } /** * Get the delegate for a given class. * * @since 1.6.0 * * @throws FailedToMakeInstance If an invalid delegate is requested. * * @param string $class_name Class to get the delegate for. * @return callable Delegate. */ private function get_delegate( string $class_name ): callable { if ( ! $this->has_delegate( $class_name ) ) { throw FailedToMakeInstance::for_invalid_delegate( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $this->delegates[ $class_name ]; } /** * Get the reflection for a class or throw an exception. * * @since 1.6.0 * * @throws FailedToMakeInstance If the class could not be reflected. * * @param string|class-string $class_name Class to get the reflection for. * @return ReflectionClass Class reflection. */ private function get_class_reflection( string $class_name ): ReflectionClass { if ( ! class_exists( $class_name ) ) { throw FailedToMakeInstance::for_unreflectable_class( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } // There should be no ReflectionException happening because of the class existence check above. return new ReflectionClass( $class_name ); } } ================================================ FILE: includes/Infrastructure/Injector.php ================================================ $interface_or_class Interface or class to make an object instance out of. * @phpstan-param class-string[] $arguments Optional. Additional arguments to pass to the constructor. * Defaults to an empty array. * * @phpstan-return C * * @template C */ public function make( string $interface_or_class, array $arguments = [] ); /** * Bind a given interface or class to an implementation. * * Note: The implementation can be an interface as well, as long as it can * be resolved to an instantiatable class at runtime. * * @since 1.6.0 * * @param string $from Interface or class to bind an implementation to. * @param string $to Interface or class that provides the implementation. * * @phpstan-param class-string $from Interface or class to bind an implementation to. * @phpstan-param class-string $to Interface or class that provides the implementation. */ public function bind( string $from, string $to ): Injector; /** * Bind an argument for a class to a specific value. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to bind an argument * for. * @param string $argument_name Argument name to bind a value to. * @param mixed $value Value to bind the argument to. * * @phpstan-param class-string $interface_or_class Interface or class to bind an argument */ public function bind_argument( string $interface_or_class, string $argument_name, $value ): Injector; /** * Always reuse and share the same instance for the provided interface or * class. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to reuse. * * @phpstan-param class-string $interface_or_class Interface or class to reuse. */ public function share( string $interface_or_class ): Injector; /** * Delegate instantiation of an interface or class to a callable. * * @since 1.6.0 * * @param string $interface_or_class Interface or class to delegate the * instantiation of. * @param callable $callback Callable to use for instantiation. * * @phpstan-param class-string $interface_or_class Interface or class to delegate the * instantiation of. */ public function delegate( string $interface_or_class, callable $callback ): Injector; } ================================================ FILE: includes/Infrastructure/Instantiator.php ================================================ $dependencies Optional. Dependencies of the class. * @return T Instantiated object. * * @phpstan-param class-string $class_name Class to make an object instance out of. */ public function instantiate( string $class_name, array $dependencies = [] ); } ================================================ FILE: includes/Infrastructure/Plugin.php ================================================ Service container of the plugin. */ public function get_container(): ServiceContainer; } ================================================ FILE: includes/Infrastructure/PluginActivationAware.php ================================================ */ protected ServiceContainer $service_container; /** * Instantiate a Theme object. * * @since 1.6.0 * * @param bool|null $enable_filters Optional. Whether to enable filtering of the injector configuration. * @param Injector|null $injector Optional. Injector instance to use. * @param ServiceContainer|null $service_container Optional. Service container instance to use. */ public function __construct( ?bool $enable_filters = null, ?Injector $injector = null, ?ServiceContainer $service_container = null ) { /* * We use what is commonly referred to as a "poka-yoke" here. * * We need an injector and a container. We make them injectable so that * we can easily provide overrides for testing, but we also make them * optional and provide default implementations for easy regular usage. */ $this->enable_filters = $enable_filters ?? static::ENABLE_FILTERS_DEFAULT; $this->injector = $injector ?? new Injector\SimpleInjector(); $this->injector = $this->configure_injector( $this->injector ); $this->service_container = $service_container ?? new ServiceContainer\SimpleServiceContainer(); } /** * Act on plugin activation. * * @since 1.6.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->register_services(); /** * Service ID. * * @var string $id */ foreach ( $this->service_container as $id => $service ) { // Using ->get() here to instantiate LazilyInstantiatedService too. $service = $this->service_container->get( $id ); if ( $service instanceof PluginActivationAware ) { $service->on_plugin_activation( $network_wide ); } } if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) { flush_rewrite_rules( false ); } } /** * Act on plugin deactivation. * * @since 1.6.0 * * @param bool $network_wide Whether the deactivation was done network-wide. */ public function on_plugin_deactivation( bool $network_wide ): void { $this->register_services(); /** * Service ID. * * @var string $id */ foreach ( $this->service_container as $id => $service ) { // Using ->get() here to instantiate LazilyInstantiatedService too. $service = $this->service_container->get( $id ); if ( $service instanceof PluginDeactivationAware ) { $service->on_plugin_deactivation( $network_wide ); } } if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) { flush_rewrite_rules( false ); } } /** * Act on site initialization on Multisite. * * @since 1.11.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { $this->register_services(); $site_id = (int) $site->blog_id; // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog switch_to_blog( $site_id ); /** * Service ID. * * @var string $id */ foreach ( $this->service_container as $id => $service ) { // Using ->get() here to instantiate LazilyInstantiatedService too. $service = $this->service_container->get( $id ); if ( $service instanceof SiteInitializationAware ) { $service->on_site_initialization( $site ); } } if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) { flush_rewrite_rules( false ); } restore_current_blog(); } /** * Act on site removal on Multisite. * * @since 1.11.0 * * @param WP_Site $site The site being removed. */ public function on_site_removal( WP_Site $site ): void { $this->register_services(); $site_id = (int) $site->blog_id; // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog switch_to_blog( $site_id ); /** * Service ID. * * @var string $id */ foreach ( $this->service_container as $id => $service ) { // Using ->get() here to instantiate LazilyInstantiatedService too. $service = $this->service_container->get( $id ); if ( $service instanceof SiteRemovalAware ) { $service->on_site_removal( $site ); } } restore_current_blog(); } /** * Act on site is uninstalled. * * @since 1.26.0 */ public function on_site_uninstall(): void { $this->register_services(); /** * Service ID. * * @var string $id */ foreach ( $this->service_container as $id => $service ) { // Using ->get() here to instantiate LazilyInstantiatedService too. $service = $this->service_container->get( $id ); if ( $service instanceof PluginUninstallAware ) { $service->on_plugin_uninstall(); } } } /** * Register the plugin with the WordPress system. * * @since 1.6.0 * * @throws InvalidService If a service is not valid. */ public function register(): void { if ( false !== static::REGISTRATION_ACTION ) { add_action( static::REGISTRATION_ACTION, [ $this, 'register_services' ] ); } else { $this->register_services(); } } /** * Register the individual services of this plugin. * * @since 1.6.0 * * @throws InvalidService If a service is not valid. */ public function register_services(): void { // Bail early so we don't instantiate services twice. if ( \count( $this->service_container ) > 0 ) { return; } // Add the injector as the very first service. $this->service_container->put( static::SERVICE_PREFIX . static::INJECTOR_ID, $this->injector ); $services = $this->get_service_classes(); if ( $this->enable_filters ) { /** * Filter the default services that make up this plugin. * * This can be used to add services to the service container for * this plugin. * * @param array $services Associative array of identifier => * class mappings. The provided * classes need to implement the * Service interface. */ $filtered_services = apply_filters( static::HOOK_PREFIX . static::SERVICES_FILTER, $services ); $services = $this->validate_services( $filtered_services ); } while ( null !== key( $services ) ) { $id = $this->maybe_resolve( key( $services ) ); $curr = current( $services ); if ( ! $curr ) { continue; } $class = $this->maybe_resolve( $curr ); // Delay registering the service until all requirements are met. if ( // @phpstan-ignore function.alreadyNarrowedType is_a( $class, HasRequirements::class, true ) ) { if ( ! $this->requirements_are_met( $id, $class, $services ) ) { continue; } } $this->schedule_potential_service_registration( $id, $class ); next( $services ); } } /** * Get the service container that contains the services that make up the * plugin. * * @since 1.6.0 * * @return ServiceContainer Service container of the plugin. */ public function get_container(): ServiceContainer { return $this->service_container; } /** * Returns the priority for a given service based on its requirements. * * @since 1.13.0 * * @throws InvalidService If the required service is not recognized. * * @param class-string $class_name Service FQCN of the service with requirements. * @param array> $services List of services to be registered. * @return int The registration action priority for the service. * * @phpstan-param class-string $class_name Service FQCN of the service with requirements. */ protected function get_registration_action_priority( string $class_name, array &$services ): int { $priority = 10; if ( is_a( $class_name, Delayed::class, true ) ) { $priority = $class_name::get_registration_action_priority(); } if ( ! is_a( $class_name, HasRequirements::class, true ) ) { return $priority; } /** * Service class. * * @phpstan-var class-string $class_name */ $missing_requirements = $this->collect_missing_requirements( $class_name, $services ); foreach ( $missing_requirements as $missing_requirement ) { if ( is_a( $missing_requirement, Delayed::class, true ) ) { $action = $missing_requirement::get_registration_action(); if ( did_action( $action ) ) { continue; } /** * Missing requirement. * * @phpstan-var class-string $missing_requirement */ $requirement_priority = $this->get_registration_action_priority( $missing_requirement, $services ); $priority = max( $priority, $requirement_priority + 1 ); } } return $priority; } /** * Determine if the requirements for a service to be registered are met. * * This also hooks up another check in the future to the registration action(s) of its requirements. * * @since 1.10.0 * * @throws InvalidService If the required service is not recognized. * * @param string $id Service ID of the service with requirements. * @param class-string $class_name Service FQCN of the service with requirements. * @param array> $services List of services to be registered. * @return bool Whether the requirements for the service has been met. * * @phpstan-param class-string $class_name Service FQCN of the service with requirements. */ protected function requirements_are_met( string $id, string $class_name, array &$services ): bool { $missing_requirements = $this->collect_missing_requirements( $class_name, $services ); if ( empty( $missing_requirements ) ) { return true; } foreach ( $missing_requirements as $missing_requirement ) { if ( is_a( $missing_requirement, Delayed::class, true ) ) { $action = $missing_requirement::get_registration_action(); if ( did_action( $action ) ) { continue; } /* * If this service (A) has priority 10 but depends on another service (B) with same priority, * which itself depends on service (C) also with priority 10, this will ensure correct * order of registration by increasing priority for each step. * * The result will be: * * C: priority 10 * B: priority 11 * A: priority 12 */ $priority = $this->get_registration_action_priority( $class_name, $services ); /* * The current service depends on another service that is Delayed and hasn't been registered yet * and for which the registration action has not yet passed. * * Therefore, we postpone the registration of the current service up until the requirement's * action has passed. * * We don't register the service right away, though, we will first check whether at that point, * the requirements have been met. * * Note that badly configured requirements can lead to services that will never register at all. */ add_action( $action, function () use ( $id, $class_name, $services ): void { if ( ! $this->requirements_are_met( $id, $class_name, $services ) ) { return; } $this->schedule_potential_service_registration( $id, $class_name ); }, $priority ); next( $services ); return false; } } /* * The registration actions from all of the requirements were already processed. This means that the missing * requirement(s) are about to be registered, they just weren't encountered yet while traversing the services * map. Therefore, we skip registration for now and move this particular service to the end of the service map. * * Note: Moving the service to the end of the service map advances the internal array pointer to the next service. */ unset( $services[ $id ] ); $services[ $id ] = $class_name; return false; } /** * Collect the list of missing requirements for a service which has requirements. * * @since 1.10.0 * * @throws InvalidService If the required service is not recognized. * * @param class-string $class_name Service FQCN of the service with requirements. * @param array> $services List of services to register. * @return array> List of missing requirements as a $service_id => $service_class mapping. * * @phpstan-param class-string $class_name Service FQCN of the service with requirements. */ protected function collect_missing_requirements( string $class_name, array $services ): array { /** * Requirements. * * @var string[] $requirements */ $requirements = $class_name::get_requirements(); /** * Missing requirements. * * @var array> */ $missing = []; foreach ( $requirements as $requirement ) { // Bail if it requires a service that is not recognized. if ( ! \array_key_exists( $requirement, $services ) ) { throw InvalidService::from_service_id( $requirement ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( $this->get_container()->has( $requirement ) ) { continue; } $missing[ $requirement ] = $services[ $requirement ]; } return $missing; } /** * Validates the services array to make sure it is in a usable shape. * * As the array of services could be filtered, we need to ensure it is * always in a state where it doesn't throw PHP warnings or errors. * * @since 1.6.0 * * @param array $services Services to validate. * @return array> Validated array of service mappings. */ protected function validate_services( array $services ): array { // Make a copy so we can safely mutate while iterating. $services_to_check = $services; foreach ( $services_to_check as $identifier => $fqcn ) { // Ensure we have valid identifiers we can refer to. // If not, generate them from the FQCN. if ( empty( $identifier ) || ! \is_string( $identifier ) ) { unset( $services[ $identifier ] ); $identifier = $this->get_identifier_from_fqcn( $fqcn ); $services[ $identifier ] = $fqcn; } // Verify that the FQCN is valid and points to an existing class. // If not, skip this service. if ( empty( $fqcn ) || ! class_exists( $fqcn ) ) { unset( $services[ $identifier ] ); } } /** * Validated services. * * @phpstan-var array> $services */ return $services; } /** * Generate a valid identifier for a provided FQCN. * * @since 1.6.0 * * @param string $fqcn FQCN to use as base to generate an identifier. * @return string Identifier to use for the provided FQCN. */ protected function get_identifier_from_fqcn( string $fqcn ): string { // Retrieve the short name from the FQCN first. $short_name = substr( $fqcn, strrpos( $fqcn, '\\' ) + 1 ); // Turn camelCase or PascalCase into snake_case. return strtolower( trim( (string) preg_replace( self::DETECT_CAPITALS_REGEX_PATTERN, '_$0', $short_name ), '_' ) ); } /** * Schedule the potential registration of a single service. * * This takes into account whether the service registration needs to be delayed or not. * * @since 1.12.0 * * @param string $id ID of the service to register. * @param class-string $class_name Class of the service to register. * * @phpstan-param class-string<(D&S)|S> $class_name Class of the service to register. */ protected function schedule_potential_service_registration( string $id, string $class_name ): void { if ( is_a( $class_name, Delayed::class, true ) ) { $action = $class_name::get_registration_action(); $priority = $class_name::get_registration_action_priority(); if ( did_action( $action ) ) { $this->maybe_register_service( $id, $class_name ); } else { add_action( $action, function () use ( $id, $class_name ): void { $this->maybe_register_service( $id, $class_name ); }, $priority ); } } else { $this->maybe_register_service( $id, $class_name ); } } /** * Register a single service, provided its conditions are met. * * @since 1.6.0 * * @param string $id ID of the service to register. * @param string $class_name Class of the service to register. * * @phpstan-param class-string $class_name Class of the service to register. */ protected function maybe_register_service( string $id, string $class_name ): void { // Ensure we don't register the same service more than once. if ( $this->service_container->has( $id ) ) { return; } // Only instantiate services that are actually needed. if ( is_a( $class_name, Conditional::class, true ) && ! $class_name::is_needed() ) { return; } $service = $this->instantiate_service( $class_name ); $this->service_container->put( $id, $service ); if ( $service instanceof Registerable ) { $service->register(); } } /** * Instantiate a single service. * * @since 1.6.0 * * @throws InvalidService If the service could not be properly instantiated. * * @param class-string|object $class_name Service class to instantiate. * @return Service Instantiated service. * * @phpstan-param class-string $class_name Service class to instantiate. */ protected function instantiate_service( $class_name ): Service { /* * If the service is not registerable, we default to lazily instantiated * services here for some basic optimization. * * The services will be properly instantiated once they are retrieved * from the service container. */ if ( ! is_a( $class_name, Registerable::class, true ) ) { return new LazilyInstantiatedService( fn() => $this->injector->make( $class_name ) ); } // The service needs to be registered, so instantiate right away. $service = $this->injector->make( $class_name ); // @phpstan-ignore instanceof.alwaysTrue if ( ! $service instanceof Service ) { throw InvalidService::from_service( $service ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $service; } /** * Configure the provided injector. * * This method defines the mappings that the injector knows about, and the * logic it requires to make more complex instantiations work. * * For more complex plugins, this should be extracted into a separate * object * or into configuration files. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.6.0 * * @param Injector $injector Injector instance to configure. * @return Injector Configured injector instance. */ protected function configure_injector( Injector $injector ): Injector { $bindings = $this->get_bindings(); $shared_instances = $this->get_shared_instances(); $arguments = $this->get_arguments(); $delegations = $this->get_delegations(); if ( $this->enable_filters ) { /** * Filter the default bindings that are provided by the plugin. * * This can be used to swap implementations out for alternatives. * * @param array $bindings Associative array of interface => * implementation bindings. Both * should be FQCNs. */ $bindings = apply_filters( static::HOOK_PREFIX . static::BINDINGS_FILTER, $bindings ); /** * Filter the default argument bindings that are provided by the * plugin. * * This can be used to override scalar values. * * @param array $arguments Associative array of class => * arguments mappings. The arguments * array maps argument names to * values. */ $arguments = apply_filters( static::HOOK_PREFIX . static::ARGUMENTS_FILTER, $arguments ); /** * Filter the instances that are shared by default by the plugin. * * This can be used to turn objects that were added externally into * shared instances. * * @param array $shared_instances Array of FQCNs to turn * into shared objects. */ $shared_instances = apply_filters( static::HOOK_PREFIX . static::SHARED_INSTANCES_FILTER, $shared_instances ); /** * Filter the instances that are shared by default by the plugin. * * This can be used to turn objects that were added externally into * shared instances. * * @param array $delegations Associative array of class => * callable mappings. */ $delegations = apply_filters( static::HOOK_PREFIX . static::DELEGATIONS_FILTER, $delegations ); } foreach ( $bindings as $from => $to ) { $from = $this->maybe_resolve( $from ); $to = $this->maybe_resolve( $to ); $injector = $injector->bind( $from, $to ); } /** * Argument map. * * @var array> $arguments */ foreach ( $arguments as $class => $argument_map ) { $class = $this->maybe_resolve( $class ); foreach ( $argument_map as $name => $value ) { // We don't try to resolve the $value here, as we might want to // pass a callable as-is. $name = $this->maybe_resolve( $name ); $injector = $injector->bind_argument( $class, $name, $value ); } } foreach ( $shared_instances as $shared_instance ) { $shared_instance = $this->maybe_resolve( $shared_instance ); $injector = $injector->share( $shared_instance ); } /** * Callable. * * @var callable $callable */ foreach ( $delegations as $class => $callable ) { // We don't try to resolve the $callable here, as we want to pass it // on as-is. $class = $this->maybe_resolve( $class ); $injector = $injector->delegate( $class, $callable ); } return $injector; } /** * Get the list of services to register. * * @since 1.6.0 * * @return array> Associative array of identifiers mapped to fully * qualified class names. */ protected function get_service_classes(): array { return []; } /** * Get the bindings for the dependency injector. * * The bindings let you map interfaces (or classes) to the classes that * should be used to implement them. * * @since 1.6.0 * * @return array> Associative array of fully qualified class names. */ protected function get_bindings(): array { return []; } /** * Get the argument bindings for the dependency injector. * * The argument bindings let you map specific argument values for specific * classes. * * @since 1.6.0 * * @return array Associative array of arrays mapping argument names * to argument values. */ protected function get_arguments(): array { return []; } /** * Get the shared instances for the dependency injector. * * These classes will only be instantiated once by the injector and then * reused on subsequent requests. * * This effectively turns them into singletons, without any of the * drawbacks of the actual Singleton anti-pattern. * * @since 1.6.0 * * @return array Array of fully qualified class names. */ protected function get_shared_instances(): array { return []; } /** * Get the delegations for the dependency injector. * * These are basically factories to provide custom instantiation logic for * classes. * * @since 1.6.0 * * @return array Associative array of callables. * * @phpstan-return array, callable> Associative array of callables. */ protected function get_delegations(): array { return []; } /** * Maybe resolve a value that is a callable instead of a scalar. * * Values that are passed through this method can optionally be provided as * callables instead of direct values and will be evaluated when needed. * * @since 1.6.0 * * @param string|callable|class-string $value Value to potentially resolve. * @return string|class-string Resolved or unchanged value. * * @phpstan-return class-string Resolved or unchanged value. */ protected function maybe_resolve( $value ): string { if ( ! ( \is_string( $value ) && \function_exists( $value ) ) && \is_callable( $value ) ) { $value = $value( $this->injector, $this->service_container ); } return $value; } } ================================================ FILE: includes/Infrastructure/ServiceContainer/LazilyInstantiatedService.php ================================================ instantiation = $instantiation; } /** * Do the actual service instantiation and return the real service. * * @since 1.6.0 * * @throws InvalidService If the service could not be properly instantiated. * * @return Service Properly instantiated service. */ public function instantiate(): Service { $instantiation = $this->instantiation; // Because uniform variable syntax not supported in PHP 5.6. $service = $instantiation(); if ( ! $service instanceof Service ) { throw InvalidService::from_service( $service ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $service; } } ================================================ FILE: includes/Infrastructure/ServiceContainer/SimpleServiceContainer.php ================================================ has( $id ) ) { throw InvalidService::from_service_id( $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Service. * * @var Service $service Service. */ $service = $this->offsetGet( $id ); // Instantiate actual services if they were stored lazily. if ( $service instanceof LazilyInstantiatedService ) { $service = $service->instantiate(); $this->put( $id, $service ); } return $service; } /** * Check whether the container can return a service for the given * identifier. * * @since 1.6.0 * * @param string $id Identifier of the service to look for. */ public function has( string $id ): bool { return $this->offsetExists( $id ); } /** * Put a service into the container for later retrieval. * * @since 1.6.0 * * @param string $id Identifier of the service to put into the * container. * @param Service $service Service to put into the container. */ public function put( string $id, Service $service ): void { $this->offsetSet( $id, $service ); } } ================================================ FILE: includes/Infrastructure/ServiceContainer.php ================================================ settings = $settings; $this->story_post_type = $story_post_type; $this->context = $context; } /** * Initializes all hooks. * * @since 1.2.0 */ public function register(): void { add_filter( 'option_amp-options', [ $this, 'filter_amp_options' ] ); add_filter( 'amp_supportable_post_types', [ $this, 'filter_supportable_post_types' ] ); add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 10, 4 ); add_filter( 'amp_content_sanitizers', [ $this, 'add_amp_content_sanitizers' ] ); add_filter( 'amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 ); add_filter( 'amp_skip_post', [ $this, 'filter_amp_skip_post' ], 10, 2 ); // This filter is actually used in this plugin's `Sanitization` class. add_filter( 'web_stories_amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Filter AMP options to force Standard mode (AMP-first) when a web story is being requested. * * @since 1.2.0 * * @param array|false $options Options. * @return array|mixed Filtered options. * * @phpstan-param AMPOptions|false $options * * @template T * * @phpstan-return ($options is array ? array : mixed) */ public function filter_amp_options( $options ) { if ( ! \is_array( $options ) ) { return $options; } if ( $this->get_request_post_type() === $this->story_post_type->get_slug() ) { $options['theme_support'] = 'standard'; $options['supported_post_types'][] = $this->story_post_type->get_slug(); $options['supported_templates'][] = 'is_singular'; } return $options; } /** * Filter the post types which are supportable. * * Remove web-stories from the list unless the currently requested post type is for a web-story. This is done in * order to hide stories from the list of supportable post types on the AMP Settings screen. * * @since 1.2.0 * * @param string[]|false $post_types Supportable post types. * @return string[]|false Supportable post types. * * @phpstan-return ($post_types is string[] ? string[] : false) */ public function filter_supportable_post_types( $post_types ) { if ( ! \is_array( $post_types ) ) { return $post_types; } $story_post_type = $this->story_post_type->get_slug(); $post_types = array_diff( $post_types, [ $story_post_type ] ); if ( $this->get_request_post_type() === $story_post_type ) { $post_types = [ ...$post_types, $story_post_type ]; } return array_unique( array_values( $post_types ) ); } /** * Filters the AMP plugin's sanitizers. * * @since 1.2.0 * * @param array|mixed $sanitizers Sanitizers. * @return array|mixed Sanitizers. * * @phpstan-param AMPSanitizers|mixed $sanitizers * @phpstan-return AMPSanitizers|mixed */ public function add_amp_content_sanitizers( $sanitizers ) { if ( ! $this->context->is_web_story() ) { return $sanitizers; } $post = get_queried_object(); if ( ! ( $post instanceof WP_Post ) ) { return $sanitizers; } if ( ! \is_array( $sanitizers ) ) { return $sanitizers; } /** * AMP sanitizer configuration. * * @phpstan-var AMPSanitizers $sanitizers */ $video_cache_enabled = (bool) $this->settings->get_setting( $this->settings::SETTING_NAME_VIDEO_CACHE ); $story = new Story(); $story->load_from_post( $post ); $poster_images = [ 'poster-portrait-src' => esc_url_raw( $story->get_poster_portrait() ), ]; if ( isset( $sanitizers['AMP_Style_Sanitizer'] ) ) { if ( ! isset( $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] ) ) { $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] = []; } $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'][] = 'amp-story-captions'; } $sanitizers[ AMP_Story_Sanitizer::class ] = [ 'publisher_logo' => (string) $story->get_publisher_logo_url(), 'publisher' => $story->get_publisher_name(), 'poster_images' => array_filter( $poster_images ), 'video_cache' => $video_cache_enabled, 'title_tag' => wp_get_document_title(), 'description' => wp_strip_all_tags( get_the_excerpt() ), ]; return $sanitizers; } /** * Filter amp_validation_error_sanitized to prevent invalid markup removal for Web Stories. * * Since the amp-story element requires the poster-portrait-src attribute to be valid, when this attribute is absent * the AMP plugin will try to remove the amp-story element altogether. This is not the preferred resolution! So * instead, this will force the invalid markup to be kept. When this is done, the AMP plugin in Standard mode * (which Web Stories enforces while serving singular web-story posts) will remove the amp attribute from the html * element so that the page will not be advertised as AMP. This prevents GSC from complaining about a validation * issue which we already know about. * * The same is done for elements, for example when they have missing poster images. * * @since 1.1.1 * * @link https://github.com/ampproject/amp-wp/blob/c6aed8f/includes/validation/class-amp-validation-manager.php#L1777-L1809 * * @param null|bool $sanitized Whether sanitized. Null means sanitization is not overridden. * @param array{node_type?: int, node_name?: string, parent_name?: string} $error Validation error being sanitized. * @return null|bool Whether sanitized. */ public function filter_amp_validation_error_sanitized( ?bool $sanitized, array $error ): ?bool { // Skip sanitization for missing publisher logos and poster portrait images. if ( isset( $error['node_type'], $error['node_name'], $error['parent_name'] ) && ( ( XML_ELEMENT_NODE === $error['node_type'] && 'amp-story' === $error['node_name'] && 'body' === $error['parent_name'] ) || ( XML_ATTRIBUTE_NODE === $error['node_type'] && 'poster-portrait-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] ) || ( XML_ATTRIBUTE_NODE === $error['node_type'] && 'publisher-logo-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] ) ) ) { return false; } // Skip sanitization for missing video posters. if ( isset( $error['node_name'] ) && 'amp-video' === $error['node_name'] ) { return false; } // Skip sanitization for amp-video > source with invalid src. if ( isset( $error['parent_name'] ) && 'source' === $error['parent_name'] ) { return false; } return $sanitized; } /** * Filters whether AMP-to-AMP is excluded for an element. * * The element may be either a link (`a` or `area`) or a `form`. * * @since 1.2.0 * * @param bool|mixed $excluded Excluded. Default value is whether element already has a `noamphtml` link relation or the URL is among `excluded_urls`. * @param string $url URL considered for exclusion. * @param string[] $rel Link relations. * @param DOMElement|null $element The element considered for excluding from AMP-to-AMP linking. May be instance of `a`, `area`, or `form`. * @return bool|mixed Whether AMP-to-AMP is excluded. */ public function filter_amp_to_amp_linking_element_excluded( $excluded, string $url, array $rel, ?DOMElement $element ) { if ( $element instanceof DOMElement && $element->parentNode instanceof DOMElement && 'amp-story-player' === $element->parentNode->tagName ) { return true; } return $excluded; } /** * Filters whether to skip the post from AMP. * * Skips the post if the AMP plugin's version is lower than what is bundled in this plugin. * Prevents issues where this plugin uses newer features that the plugin doesn't know about yet, * causing false positives with validation. * * @since 1.6.0 * * @link https://github.com/googleforcreators/web-stories-wp/issues/7131 * * @param bool|mixed $skipped Whether the post should be skipped from AMP. * @param int $post Post ID. * @return bool|mixed Whether post should be skipped from AMP. */ public function filter_amp_skip_post( $skipped, int $post ) { // This is the opposite to the `AMP__VERSION >= WEBSTORIES_AMP_VERSION` check in the HTML renderer. if ( $this->story_post_type->get_slug() === get_post_type( $post ) && \defined( '\AMP__VERSION' ) && version_compare( WEBSTORIES_AMP_VERSION, AMP__VERSION, '>' ) ) { return true; } return $skipped; } /** * Get the post type for the current request. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.2.0 */ protected function get_request_post_type(): ?string { // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( did_action( 'wp' ) && is_singular() ) { $post_type = get_post_type( get_queried_object_id() ); return $post_type ?: null; } if ( isset( $_GET['action'], $_GET['post'] ) && 'amp_validate' === $_GET['action'] && is_admin() ) { /** * Post ID. * * @var string|int $post_id */ $post_id = $_GET['post']; if ( get_post_type( (int) $post_id ) === self::AMP_VALIDATED_URL_POST_TYPE ) { return $this->get_validated_url_post_type( (int) $post_id ); } } $current_screen_post_type = $this->context->get_screen_post_type(); if ( $current_screen_post_type ) { $current_post = get_post(); if ( self::AMP_VALIDATED_URL_POST_TYPE === $current_screen_post_type && $current_post instanceof WP_Post && $current_post->post_type === $current_screen_post_type ) { $validated_url_post_type = $this->get_validated_url_post_type( $current_post->ID ); if ( $validated_url_post_type ) { return $validated_url_post_type; } } return $current_screen_post_type; } if ( isset( $_SERVER['REQUEST_URI'] ) ) { /** * Request URI. * * @var string $request_uri */ $request_uri = $_SERVER['REQUEST_URI']; if ( str_contains( (string) wp_unslash( $request_uri ), $this->story_post_type->get_rest_url() ) ) { return $this->story_post_type->get_slug(); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return null; } /** * Get the singular post type which is the queried object for the given validated URL post. * * @since 1.0.0 * * @param int $post_id Post ID for Validated URL Post. * @return string|null Post type or null if validated URL is not for a singular post. */ protected function get_validated_url_post_type( int $post_id ): ?string { if ( empty( $post_id ) ) { return null; } $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return null; } if ( self::AMP_VALIDATED_URL_POST_TYPE !== $post->post_type ) { return null; } /** * AMP queried object. * * @var array{type?: string, id?: int|string}|string $queried_object */ $queried_object = get_post_meta( $post->ID, '_amp_queried_object', true ); if ( ! \is_array( $queried_object ) ) { return null; } if ( isset( $queried_object['id'], $queried_object['type'] ) && 'post' === $queried_object['type'] ) { /** * Post ID. * * @var int|string $post_id */ $post_id = $queried_object['id']; $post_type = get_post_type( (int) $post_id ); if ( $post_type ) { return $post_type; } } return null; } } ================================================ FILE: includes/Integrations/Conditional_Featured_Image.php ================================================ story_post_type = $story_post_type; } /** * Initializes all hooks. * * @since 1.16.0 */ public function register(): void { add_filter( 'cybocfi_enabled_for_post_type', [ $this, 'cybocfi_enabled_for_post_type' ], 99, 2 ); } /** * Filter the conditional-featured-image plugin. * * @since 1.16.0 * * @param mixed $enabled If enabled or not. * @param string $post_type Post type slug. * @return mixed Filter value. */ public function cybocfi_enabled_for_post_type( $enabled, string $post_type ) { if ( $this->story_post_type->get_slug() === $post_type ) { return false; } return $enabled; } } ================================================ FILE: includes/Integrations/Core_Themes_Support.php ================================================ assets = $assets; } /** * Adds theme support for Web Stories. * * This will enable add_theme_support with predefined * options supported themes. * * @since 1.5.0 */ public function extend_theme_support(): void { add_theme_support( 'web-stories' ); } /** * Embed Webstories. * * Embeds web stories with default customizer settings. * * @since 1.5.0 */ public function embed_web_stories(): void { $stylesheet = get_stylesheet(); if ( is_readable( \sprintf( '%sassets/css/web-stories-theme-style-%s.css', WEBSTORIES_PLUGIN_DIR_PATH, $stylesheet ) ) ) { $this->assets->enqueue_style_asset( 'web-stories-theme-style-' . $stylesheet, [] ); } ?>
? array : mixed) */ public function add_core_theme_classes( $classes ) { if ( ! \is_array( $classes ) ) { return $classes; } $classes[] = 'has-web-stories'; return $classes; } /** * Adds theme support and hook to embed the web stories. * * @since 1.5.0 */ public function register(): void { if ( ! \in_array( get_stylesheet(), self::$supported_themes, true ) ) { return; } $this->extend_theme_support(); // Not using Settings::get_setting() to avoid calling rest_sanitize_value_from_schema(). /** * Customizer options. * * @var array $options */ $options = get_option( Customizer::STORY_OPTION, [] ); // Load theme specific styles and render function only if selected to show stories. if ( empty( $options['show_stories'] ) ) { return; } add_filter( 'body_class', [ $this, 'add_core_theme_classes' ] ); add_action( 'wp_body_open', [ $this, 'embed_web_stories' ] ); } /** * Get the action to use for registering the service. * * @since 1.6.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'wp_head'; } } ================================================ FILE: includes/Integrations/Ezoic.php ================================================ sanitization = $sanitization; $this->optimization = $optimization; $this->context = $context; } /** * Initializes all hooks. * * @since 1.33.0 */ public function register(): void { add_filter( 'ez_buffered_final_content', [ $this, 'process_ez_buffered_final_content' ] ); } /** * Check whether the Ezoic integration is currently needed. * * @since 1.33.0 * * @return bool Whether Ezoic integration is currently needed. */ public static function is_needed(): bool { return \defined( 'EZOIC_INTEGRATION_VERSION' ); } /** * Optimizes and Sanitizes Ezoic's final prepared content. * * @since 1.33.0 * * @param string $content HTML document response collected by Ezoic Output Buffer. * @return string AMP document response. */ public function process_ez_buffered_final_content( string $content ): string { if ( $this->context->is_web_story() ) { // Enforce UTF-8 encoding as it is a requirement for AMP. if ( ! headers_sent() ) { header( 'Content-Type: text/html; charset=utf-8' ); } $dom = Document::fromHtml( $content ); if ( ! $dom instanceof Document ) { return $this->render_error_page( SanitizationException::from_document_parse_error() ); } $this->sanitization->sanitize_document( $dom ); $this->optimization->optimize_document( $dom ); return $dom->saveHTML(); } return $content; } /** * Render error page. * * @since 1.33.0 * * @param Throwable $throwable Exception or (as of PHP7) Error. * @return string Error page. */ private function render_error_page( Throwable $throwable ): string { return esc_html__( 'There was an error generating the web story, probably because of a server misconfiguration. Try contacting your hosting provider or open a new support request.', 'web-stories' ) . "\n" . "\n" . // translators: 1: error message. 2: location. \sprintf( esc_html__( 'Error message: %1$s (%2$s)', 'web-stories' ), $throwable->getMessage(), $throwable->getFile() . ':' . $throwable->getLine() ); } } ================================================ FILE: includes/Integrations/Jetpack.php ================================================ media_source_taxonomy = $media_source_taxonomy; $this->context = $context; } /** * Initializes all hooks. * * @since 1.2.0 */ public function register(): void { // See https://github.com/Automattic/jetpack/blob/4b85be883b3c584c64eeb2fb0f3fcc15dabe2d30/modules/custom-post-types/portfolios.php#L80. if ( \defined( 'IS_WPCOM' ) && IS_WPCOM ) { add_filter( 'wpcom_sitemap_post_types', [ $this, 'add_to_jetpack_sitemap' ] ); } else { add_filter( 'jetpack_sitemap_post_types', [ $this, 'add_to_jetpack_sitemap' ] ); } add_filter( 'jetpack_is_amp_request', [ $this, 'force_amp_request' ] ); add_filter( 'web_stories_allowed_mime_types', [ $this, 'add_videopress' ] ); add_filter( 'web_stories_rest_prepare_attachment', [ $this, 'filter_rest_api_response' ], 10, 2 ); add_filter( 'ajax_query_attachments_args', [ $this, 'filter_ajax_query_attachments_args' ] ); add_action( 'added_post_meta', [ $this, 'add_term' ], 10, 3 ); } /** * Adds the web-story post type to Jetpack / WordPress.com sitemaps. * * @since 1.2.0 * * @see https://github.com/Automattic/jetpack/blob/4b85be883b3c584c64eeb2fb0f3fcc15dabe2d30/modules/custom-post-types/portfolios.php#L80 * * @param array|mixed $post_types Array of post types. * @return array|mixed Modified list of post types. * * @template T * * @phpstan-return ($post_types is array ? array : mixed) */ public function add_to_jetpack_sitemap( $post_types ) { if ( ! \is_array( $post_types ) ) { return $post_types; } $post_types[] = Story_Post_Type::POST_TYPE_SLUG; return $post_types; } /** * Add VideoPress to allowed mime types. * * If the site does not support VideoPress, this will be filtered out. * * @since 1.7.2 * * @param array{video?: string[]}|mixed $mime_types Associative array of allowed mime types per media type (image, audio, video). * @return array{video?: string[]}|mixed * * @template T * * @phpstan-return ($mime_types is array ? array : mixed) */ public function add_videopress( $mime_types ) { if ( ! \is_array( $mime_types ) ) { return $mime_types; } /** * Mime types config. * * @var array{video?: string[]} $mime_types */ $mime_types['video'][] = self::VIDEOPRESS_MIME_TYPE; return $mime_types; } /** * Filter ajax query attachments args when accessed from the Web Stories editor. * * Only filters the response if the mime type matches exactly what Web Stories is looking for. * * @since 1.7.2 * * @param array|mixed $args Query args. * @return array|mixed Filtered query args. * * @template T * * @phpstan-return ($args is array ? array : mixed) */ public function filter_ajax_query_attachments_args( $args ) { if ( ! \is_array( $args ) || ! isset( $args['post_mime_type'] ) || ! \is_array( $args['post_mime_type'] ) ) { return $args; } if ( \in_array( self::VIDEOPRESS_MIME_TYPE, $args['post_mime_type'], true ) ) { add_filter( 'wp_prepare_attachment_for_js', [ $this, 'filter_admin_ajax_response' ], 15, 2 ); } return $args; } /** * Filter admin ajax responses for VideoPress videos. * * Changes the video/videopress type back to mp4 * and ensures MP4 source URLs are returned. * * @since 1.7.2 * * @param array|mixed $data Array of prepared attachment data. @see wp_prepare_attachment_for_js(). * @param WP_Post $attachment Attachment object. * @return array|mixed * * @phpstan-param AttachmentData|mixed $data * @phpstan-return AttachmentData|mixed * * @template T * * @phpstan-return ($data is array ? array : mixed) */ public function filter_admin_ajax_response( $data, WP_Post $attachment ) { if ( self::VIDEOPRESS_MIME_TYPE !== $attachment->post_mime_type ) { return $data; } if ( ! \is_array( $data ) ) { return $data; } // Reset mime type back to mp4, as this is the correct value. $data['mime'] = 'video/mp4'; $data['subtype'] = 'mp4'; // Mark video as optimized. $data[ $this->media_source_taxonomy::MEDIA_SOURCE_KEY ] = 'video-optimization'; /** * Jetpack adds an additional field to regular attachment metadata. * * @var array $metadata * @phpstan-var EnhancedAttachmentMetadata|false $metadata */ $metadata = wp_get_attachment_metadata( $attachment->ID ); if ( $metadata && isset( $metadata['videopress']['duration'], $data['media_details'] ) && \is_array( $data['media_details'] ) ) { $data['media_details']['length_formatted'] = $this->format_milliseconds( $metadata['videopress']['duration'] ); $data['media_details']['length'] = (int) floor( $metadata['videopress']['duration'] / 1000 ); } if ( $metadata && isset( $data['url'], $metadata['videopress']['file_url_base']['https'], $metadata['videopress']['files']['hd']['mp4'] ) ) { $data['url'] = $metadata['videopress']['file_url_base']['https'] . $metadata['videopress']['files']['hd']['mp4']; } // Get the correct poster with matching dimensions from VideoPress. if ( $metadata && isset( $data['featured_media_src'], $metadata['videopress']['poster'], $metadata['videopress']['width'], $metadata['videopress']['height'] ) ) { $data['featured_media_src'] = [ 'src' => $metadata['videopress']['poster'], 'width' => $metadata['videopress']['width'], 'height' => $metadata['videopress']['height'], 'generated' => true, ]; } return $data; } /** * Filter REST API responses for VideoPress videos. * * Changes the video/videopress type back to mp4 * and ensures MP4 source URLs are returned. * * @since 1.7.2 * * @param WP_REST_Response $response The response object. * @param WP_Post $post The original attachment post. */ public function filter_rest_api_response( WP_REST_Response $response, WP_Post $post ): WP_REST_Response { if ( self::VIDEOPRESS_MIME_TYPE !== $post->post_mime_type ) { return $response; } /** * Response data. * * @var array|bool> $data */ $data = $response->get_data(); // Reset mime type back to mp4, as this is the correct value. $data['mime_type'] = 'video/mp4'; // Mark video as optimized. $data[ $this->media_source_taxonomy::MEDIA_SOURCE_KEY ] = 'video-optimization'; /** * Jetpack adds an additional field to regular attachment metadata. * * @var EnhancedAttachmentMetadata|false $metadata */ $metadata = wp_get_attachment_metadata( $post->ID ); if ( $metadata && isset( $metadata['videopress']['duration'], $data['media_details'] ) && \is_array( $data['media_details'] ) ) { $data['media_details']['length_formatted'] = $this->format_milliseconds( $metadata['videopress']['duration'] ); $data['media_details']['length'] = (int) floor( $metadata['videopress']['duration'] / 1000 ); } if ( $metadata && isset( $data['source_url'], $metadata['videopress']['file_url_base']['https'], $metadata['videopress']['files']['hd']['mp4'] ) ) { $data['source_url'] = $metadata['videopress']['file_url_base']['https'] . $metadata['videopress']['files']['hd']['mp4']; } // Get the correct poster with matching dimensions from VideoPress. if ( $metadata && isset( $data['featured_media_src'], $metadata['videopress']['poster'], $metadata['videopress']['width'], $metadata['videopress']['height'] ) ) { $data['featured_media_src'] = [ 'src' => $metadata['videopress']['poster'], 'width' => $metadata['videopress']['width'], 'height' => $metadata['videopress']['height'], 'generated' => true, ]; } $response->set_data( $data ); return $response; } /** * Hook into added_post_meta. * * @since 1.7.2 * * @param int $mid The meta ID after successful update. * @param int $object_id ID of the object metadata is for. * @param string $meta_key Metadata key. */ public function add_term( int $mid, int $object_id, string $meta_key ): void { if ( self::VIDEOPRESS_POSTER_META_KEY !== $meta_key ) { return; } if ( 'attachment' !== get_post_type( $object_id ) ) { return; } wp_set_object_terms( (int) $object_id, $this->media_source_taxonomy::TERM_POSTER_GENERATION, $this->media_source_taxonomy->get_taxonomy_slug() ); } /** * Force Jetpack to see Web Stories as AMP. * * @since 1.2.0 * * @param bool $is_amp_request Is the request supposed to return valid AMP content. * @return bool Whether the current request is an AMP request. */ public function force_amp_request( bool $is_amp_request ): bool { if ( ! $this->context->is_web_story() ) { return (bool) $is_amp_request; } return true; } /** * Format milliseconds into seconds. * * @since 1.7.2 * * @param int $milliseconds Milliseconds to converted to minutes and seconds. */ protected function format_milliseconds( int $milliseconds ): string { $seconds = floor( $milliseconds / 1000 ); if ( $seconds >= 1 ) { $minutes = floor( $seconds / 60 ); $seconds %= 60; } else { $seconds = 0; $minutes = 0; } return \sprintf( '%d:%02u', $minutes, $seconds ); } } ================================================ FILE: includes/Integrations/New_Relic.php ================================================ context = $context; } /** * Runs on instantiation. * * @since 1.10.0 */ public function register(): void { $this->disable_autorum(); } /** * Get the action to use for registering the service. * * @since 1.10.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'template_redirect'; } /** * Get the action priority to use for registering the service. * * @since 1.10.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { // Run at the same time as the output buffering. return PHP_INT_MIN; } /** * Check whether the conditional object is currently needed. * * @since 1.10.0 * * @return bool Whether the conditional object is needed. */ public static function is_needed(): bool { return \function_exists( '\newrelic_disable_autorum' ); } /** * Disable the New Relic Browser agent on AMP responses. * * This prevents the New Relic from causing invalid AMP responses due the NREUM script it injects after the meta charset: * * https://docs.newrelic.com/docs/browser/new-relic-browser/troubleshooting/google-amp-validator-fails-due-3rd-party-script * * Sites with New Relic will need to specially configure New Relic for AMP: * https://docs.newrelic.com/docs/browser/new-relic-browser/installation/monitor-amp-pages-new-relic-browser * * @since 1.10.0 */ public function disable_autorum(): void { if ( ! $this->context->is_web_story() ) { return; } \newrelic_disable_autorum(); } } ================================================ FILE: includes/Integrations/NextGen_Gallery.php ================================================ get_request_uri_path(); if ( // Plain permalinks. // phpcs:ignore WordPress.Security.NonceVerification.Recommended ! empty( $_GET[ Story_Post_Type::POST_TYPE_SLUG ] ) || // Pretty permalinks. ( $request_uri_path && preg_match( '#/' . preg_quote( Story_Post_Type::REWRITE_SLUG, '#' ) . '/.*?$#', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $request_uri_path ) ) ) { return false; } return $valid_request; } /** * Returns the current request path. * * @since 1.15.0 * * @return string|null Request URI path on success, null on failure. */ private function get_request_uri_path(): ?string { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { return null; } if ( ! \is_string( $_SERVER['REQUEST_URI'] ) ) { return null; } /** * Request URI. * * @var string $request_uri */ $request_uri = $_SERVER['REQUEST_URI']; // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized /** * Request URI path. * * @var string|null|false $path */ $path = wp_parse_url( $request_uri, PHP_URL_PATH ); if ( ! $path ) { return null; } return $path; } } ================================================ FILE: includes/Integrations/Plugin_Status.php ================================================ > */ protected array $plugins = []; /** * Constructor. */ public function __construct() { if ( ! \function_exists( '\get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $this->plugins = get_plugins(); } /** * Retrieves all plugin files with plugin data. * * @since 1.30.0 * * @return array> */ public function get_plugins(): array { return $this->plugins; } } ================================================ FILE: includes/Integrations/ShortPixel.php ================================================ ! str_contains( $url, 'web-stories-page-template' ) ); } } ================================================ FILE: includes/Integrations/Site_Kit.php ================================================ * } */ class Site_Kit extends Service_Base { /** * Analytics instance. * * @var Analytics Analytics instance. */ protected Analytics $analytics; /** * Context instance. * * @var Context Context instance. */ private Context $context; /** * Plugin_Status instance. * * @var Plugin_Status Plugin_Status instance. */ private Plugin_Status $plugin_status; /** * Settings instance. * * @var Settings Settings instance. */ private Settings $settings; /** * Constructor. * * @param Analytics $analytics Analytics instance. * @param Context $context Context instance. * @param Plugin_Status $plugin_status Plugin_Status instance. * @param Settings $settings Settings instance. */ public function __construct( Analytics $analytics, Context $context, Plugin_Status $plugin_status, Settings $settings ) { $this->analytics = $analytics; $this->context = $context; $this->plugin_status = $plugin_status; $this->settings = $settings; } /** * Initializes all hooks. * * @since 1.2.0 */ public function register(): void { add_filter( 'googlesitekit_amp_gtag_opt', [ $this, 'filter_site_kit_gtag_opt' ] ); add_filter( 'googlesitekit_analytics-4_tag_amp_blocked', function ( $blocked ) { $handler = $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_HANDLER ); if ( 'web-stories' === $handler && $this->context->is_web_story() ) { return true; } return $blocked; } ); add_action( 'web_stories_print_analytics', function (): void { $handler = $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_HANDLER ); if ( 'site-kit' === $handler && $this->is_analytics_module_active() ) { remove_action( 'web_stories_print_analytics', [ $this->analytics, 'print_analytics_tag' ] ); } }, 5 ); } /** * Filters Site Kit's Google Analytics configuration. * * @since 1.2.0 * * @param array|mixed $gtag_opt Array of gtag configuration options. * @return array|mixed Modified configuration options. * * @phpstan-param GtagOpt|mixed $gtag_opt */ public function filter_site_kit_gtag_opt( $gtag_opt ) { if ( ! \is_array( $gtag_opt ) || ! \is_array( $gtag_opt['vars'] ) || ! isset( $gtag_opt['vars']['gtag_id'] ) || ! \is_string( $gtag_opt['vars']['gtag_id'] ) || ! $this->context->is_web_story() ) { return $gtag_opt; } $default_config = $this->analytics->get_default_configuration( $gtag_opt['vars']['gtag_id'] ); $default_config['triggers'] = $default_config['triggers'] ?? []; $gtag_opt['triggers'] ??= []; // @phpstan-ignore argument.type $gtag_opt['triggers'] = array_merge( $default_config['triggers'], $gtag_opt['triggers'] ); return $gtag_opt; } /** * Returns the Site Kit plugin status. * * @since 1.2.0 * * @return array{installed: bool, active: bool, analyticsActive: bool, adsenseActive: bool, analyticsLink: string, adsenseLink: string} Plugin status. */ public function get_plugin_status(): array { $is_installed = \array_key_exists( 'google-site-kit/google-site-kit.php', $this->plugin_status->get_plugins() ); $is_active = $this->is_plugin_active(); $is_analytics_active = $this->is_analytics_module_active(); $is_adsense_active = $this->is_adsense_module_active(); $analytics_link = __( 'https://wordpress.org/plugins/google-site-kit/', 'web-stories' ); $adsense_link = __( 'https://wordpress.org/plugins/google-site-kit/', 'web-stories' ); $dashboard = admin_url( 'admin.php?page=googlesitekit-dashboard' ); $settings = admin_url( 'admin.php?page=googlesitekit-settings' ); if ( $is_active ) { $dashboard_capability = current_user_can( 'googlesitekit_view_dashboard' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown $settings_capability = current_user_can( 'googlesitekit_manage_options' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown // If analytics is active and current user can view dashboard. if ( $is_analytics_active && $dashboard_capability ) { $analytics_link = $dashboard; } elseif ( $settings_capability ) { $analytics_link = $settings; } elseif ( $dashboard_capability ) { $analytics_link = $dashboard; } // If adsense is active and current user can view dashboard. if ( $is_adsense_active && $dashboard_capability ) { $adsense_link = $dashboard; } elseif ( $settings_capability ) { $adsense_link = $settings; } elseif ( $dashboard_capability ) { $adsense_link = $dashboard; } } elseif ( $is_installed ) { if ( current_user_can( 'activate_plugin', 'google-site-kit/google-site-kit.php' ) ) { $analytics_link = admin_url( 'plugins.php' ); $adsense_link = $analytics_link; } } elseif ( current_user_can( 'install_plugins' ) ) { $analytics_link = admin_url( add_query_arg( [ 's' => rawurlencode( __( 'Site Kit by Google', 'web-stories' ) ), 'tab' => 'search', ], 'plugin-install.php' ) ); $adsense_link = $analytics_link; } return [ 'installed' => $is_active || $is_installed, 'active' => $is_active, 'analyticsActive' => $is_analytics_active, 'adsenseActive' => $is_adsense_active, 'analyticsLink' => $analytics_link, 'adsenseLink' => $adsense_link, ]; } /** * Determines whether Site Kit is active. * * @since 1.2.0 * * @return bool Whether Site Kit is active. */ protected function is_plugin_active(): bool { return \defined( 'GOOGLESITEKIT_VERSION' ); } /** * Determines whether the built-in adsense module in Site Kit is active. * * @since 1.8.0 * * @return bool Whether Site Kit's analytics module is active. */ protected function is_adsense_module_active(): bool { $adsense_module_active = \in_array( 'adsense', $this->get_site_kit_active_modules_option(), true ); $adsense_options = (array) get_option( 'googlesitekit_adsense_settings' ); $adsense_options_client_id = ! empty( $adsense_options['clientID'] ); $adsense_options_use_snippet = ! empty( $adsense_options['useSnippet'] ); $adsense_web_stories_ad_unit = ! empty( $adsense_options['webStoriesAdUnit'] ); return $adsense_module_active && $adsense_options_use_snippet && $adsense_web_stories_ad_unit && $adsense_options_client_id; } /** * Determines whether the built-in Analytics module in Site Kit is active. * * @since 1.2.0 * * @return bool Whether Site Kit's analytics module is active. */ protected function is_analytics_module_active(): bool { $analytics_module_active = \in_array( 'analytics', $this->get_site_kit_active_modules_option(), true ); $analytics_options = (array) get_option( 'googlesitekit_analytics_settings' ); $analytics_use_snippet = ! empty( $analytics_options['useSnippet'] ); return $analytics_module_active && $analytics_use_snippet; } /** * Gets the option containing the active Site Kit modules. * * Checks two options as it was renamed at some point in Site Kit. * * Bails early if the Site Kit plugin itself is not active. * * @since 1.2.0 * * @see \Google\Site_Kit\Core\Modules\Modules::get_active_modules_option * * @return string[] List of active module slugs. */ protected function get_site_kit_active_modules_option(): array { if ( ! $this->is_plugin_active() ) { return []; } /** * Option value. * * @var string[]|false $option */ $option = get_option( 'googlesitekit_active_modules' ); if ( \is_array( $option ) ) { return $option; } /** * Legacy option value. * * @var string[]|false $legacy_option */ $legacy_option = get_option( 'googlesitekit-active-modules' ); if ( \is_array( $legacy_option ) ) { return $legacy_option; } return []; } } ================================================ FILE: includes/Integrations/WooCommerce.php ================================================ plugin_status = $plugin_status; } /** * Returns the WooCommerce plugin status. * * @since 1.21.0 * * @return array{installed: bool, active: bool, canManage: bool, link: string} Plugin status. */ public function get_plugin_status(): array { $is_installed = \array_key_exists( self::PLUGIN, $this->plugin_status->get_plugins() ); $is_active = $this->is_plugin_active(); $can_manage = false; $link = ''; if ( $is_active ) { $can_manage = current_user_can( 'manage_woocommerce' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown if ( $can_manage ) { $link = admin_url( 'admin.php?page=wc-admin' ); } } elseif ( $is_installed ) { if ( current_user_can( 'activate_plugin', self::PLUGIN ) ) { $link = admin_url( 'plugins.php' ); } } elseif ( current_user_can( 'install_plugins' ) ) { $link = admin_url( add_query_arg( [ 's' => rawurlencode( __( 'WooCommerce', 'web-stories' ) ), 'tab' => 'search', ], 'plugin-install.php' ) ); } else { $link = __( 'https://wordpress.org/plugins/woocommerce/', 'web-stories' ); } return [ 'installed' => $is_active || $is_installed, 'active' => $is_active, 'canManage' => $can_manage, 'link' => $link, ]; } /** * Determines whether WooCommerce is active. * * @since 1.21.0 * * @return bool Whether WooCommerce is active. */ protected function is_plugin_active(): bool { return class_exists( 'WooCommerce', false ); } } ================================================ FILE: includes/Interfaces/Field.php ================================================ , has_next_page: bool}|WP_Error */ public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' ); } ================================================ FILE: includes/Interfaces/Renderer.php ================================================ $args Array of rendering related arguments. * @return string Rendering markup. */ public function render( array $args = [] ): string; /** * Render a single story markup. * * @since 1.5.0 * * @return mixed */ public function render_single_story_content(); } ================================================ FILE: includes/KSES.php ================================================ story_post_type = $story_post_type; $this->page_template_post_type = $page_template_post_type; } /** * Initializes KSES filters for stories. * * @since 1.0.0 */ public function register(): void { add_filter( 'wp_insert_post_data', [ $this, 'filter_insert_post_data' ], 10, 3 ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type', 'page_template_post_type' ]; } /** * Filters slashed post data just before it is inserted into the database. * * Used to run story HTML markup through full AMP sanitization instead of just KSES. * * This allows storing full, valid AMP HTML documents in post_content for stories, which require * more allowed HTML tags. * * @since 1.8.0 * * @param mixed $data An array of slashed, sanitized, and processed post data. * @param mixed $postarr An array of sanitized (and slashed) but otherwise unmodified post data. * @param mixed $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as * originally passed to wp_insert_post(). * @return array|mixed Filtered post data. * * @phpstan-param PostData|mixed $data * @phpstan-param PostData|mixed $unsanitized_postarr * * @template T * * @phpstan-return ($data is array ? array : mixed) */ public function filter_insert_post_data( $data, $postarr, $unsanitized_postarr ) { if ( current_user_can( 'unfiltered_html' ) ) { return $data; } if ( ! \is_array( $data ) || ! \is_array( $postarr ) || ! \is_array( $unsanitized_postarr ) ) { return $data; } if ( ! \is_string( $data['post_type'] ) || // @phpstan-ignore argument.type ! $this->is_allowed_post_type( $data['post_type'], $data['post_parent'] ) ) { return $data; } if ( isset( $unsanitized_postarr['post_content_filtered'] ) && \is_string( $unsanitized_postarr['post_content_filtered'] ) ) { $data['post_content_filtered'] = $this->filter_story_data( $unsanitized_postarr['post_content_filtered'] ); } if ( isset( $unsanitized_postarr['post_content'] ) ) { // @phpstan-ignore argument.type $data['post_content'] = wp_slash( $this->sanitize_content( wp_unslash( $unsanitized_postarr['post_content'] ) ) ); } return $data; } /** * Sanitizes post content. * * @since 1.37.0 * * @param string $content Unsanitized post content. */ private function sanitize_content( string $content ): string { $dom = Document::fromHtml( $content ); if ( $dom instanceof Document ) { $sanitizers = $this->get_sanitizers(); AMP_Content_Sanitizer::sanitize_document( $dom, $sanitizers, [] ); return trim( $dom->saveHTML() ); } return ''; } /** * Returns a list of sanitizers to use. * * This is replica of the Sanitization class implementation * to have a minimal AMP sanitization for user-provided input. * * @since 1.37.0 * * @see Sanitization * * @return array> Sanitizers. */ private function get_sanitizers(): array { $sanitizers = [ AMP_Script_Sanitizer::class => [ 'sanitize_js_scripts' => true, ], Meta_Sanitizer::class => [], Tag_And_Attribute_Sanitizer::class => [], ]; foreach ( $sanitizers as &$sanitizer ) { $sanitizer['validation_error_callback'] = static fn( array $error, array $data = [] ): bool => apply_filters( 'web_stories_amp_validation_error_sanitized', true, $error ); // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed } unset( $sanitizer ); return $sanitizers; } /** * Checks whether the post type is correct and user has capability to edit it. * * @since 1.22.0 * * @param string $post_type Post type slug. * @param int|string|null $post_parent Parent post ID. * @return bool Whether the user can edit the provided post type. */ private function is_allowed_post_type( string $post_type, $post_parent ): bool { if ( $this->story_post_type->get_slug() === $post_type && $this->story_post_type->has_cap( 'edit_posts' ) ) { return true; } if ( $this->page_template_post_type->get_slug() === $post_type && $this->page_template_post_type->has_cap( 'edit_posts' ) ) { return true; } // For story autosaves. if ( ( 'revision' === $post_type && ! empty( $post_parent ) && get_post_type( (int) $post_parent ) === $this->story_post_type->get_slug() ) && $this->story_post_type->has_cap( 'edit_posts' ) ) { return true; } return false; } /** * Filters story data. * * Provides simple sanity check to ensure story data is valid JSON. * * @since 1.22.0 * * @param string $story_data JSON-encoded story data. * @return string Sanitized & slashed story data. */ private function filter_story_data( string $story_data ): string { $decoded = json_decode( (string) wp_unslash( $story_data ), true ); return null === $decoded ? '' : wp_slash( (string) wp_json_encode( $decoded ) ); } } ================================================ FILE: includes/Locale.php ================================================ Locale settings. */ public function get_locale_settings(): array { global $wp_locale; /* translators: Date format, see https://www.php.net/manual/en/datetime.format.php */ $default_date_format = __( 'd/m/Y', 'web-stories' ); /* translators: Date format, see https://www.php.net/manual/en/datetime.format.php */ $default_time_format = __( 'g:i a', 'web-stories' ); /** * Date format value. * * @var string $date_format */ $date_format = get_option( 'date_format', $default_date_format ); if ( empty( trim( $date_format ) ) ) { $date_format = $default_date_format; } /** * Time format value. * * @var string $time_format */ $time_format = get_option( 'time_format', $default_time_format ); if ( empty( trim( $time_format ) ) ) { $time_format = $default_time_format; } /** * Time zone string. * * @var string $timezone_string */ $timezone_string = get_option( 'timezone_string', 'UTC' ); $timezone_abbr = ''; if ( ! empty( $timezone_string ) ) { $timezone_date = new DateTime( 'now', new DateTimeZone( $timezone_string ) ); $timezone_abbr = $timezone_date->format( 'T' ); } /** * Start of week value. * * @var int|string $start_of_week */ $start_of_week = get_option( 'start_of_week', 0 ); /** * GMT Offset. * * @var int $gmt_offset */ $gmt_offset = get_option( 'gmt_offset', 0 ); return [ 'locale' => str_replace( '_', '-', get_user_locale() ), 'dateFormat' => $date_format, 'timeFormat' => $time_format, 'gmtOffset' => (float) $gmt_offset, 'timezone' => $timezone_string, 'timezoneAbbr' => $timezone_abbr, 'months' => array_values( $wp_locale->month ), 'monthsShort' => array_values( $wp_locale->month_abbrev ), 'weekdays' => array_values( $wp_locale->weekday ), 'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ), 'weekdaysInitials' => array_values( $wp_locale->weekday_initial ), 'weekStartsOn' => (int) $start_of_week, ]; } } ================================================ FILE: includes/Media/Base_Color.php ================================================ register_meta(); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] ); } /** * Register meta * * @since 1.15.0 */ public function register_meta(): void { register_meta( 'post', self::BASE_COLOR_POST_META_KEY, [ 'type' => 'string', 'description' => __( 'Attachment base color', 'web-stories' ), 'show_in_rest' => [ 'schema' => [ 'type' => 'string', 'format' => 'hex-color', ], ], 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.15.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed $response; * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } /** * Attachment ID. * * @var int $post_id */ $post_id = $response['id']; $response[ self::BASE_COLOR_POST_META_KEY ] = get_post_meta( $post_id, self::BASE_COLOR_POST_META_KEY, true ); return $response; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::BASE_COLOR_POST_META_KEY ); } } ================================================ FILE: includes/Media/Blurhash.php ================================================ register_meta(); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] ); } /** * Register meta * * @since 1.16.0 */ public function register_meta(): void { register_meta( 'post', self::BLURHASH_POST_META_KEY, [ 'type' => 'string', 'description' => __( 'Attachment BlurHash', 'web-stories' ), 'show_in_rest' => [ 'schema' => [ 'type' => 'string', ], ], 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.16.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed Response data. * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } /** * Post ID. * * @var int $post_id */ $post_id = $response['id']; $response[ self::BLURHASH_POST_META_KEY ] = get_post_meta( $post_id, self::BLURHASH_POST_META_KEY, true ); return $response; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::BLURHASH_POST_META_KEY ); } } ================================================ FILE: includes/Media/Cropping.php ================================================ register_meta(); add_action( 'delete_attachment', [ $this, 'delete_video' ] ); } /** * Register meta * * @since 1.26.0 */ public function register_meta(): void { register_meta( 'post', self::CROPPED_ID_POST_META_KEY, [ 'sanitize_callback' => 'absint', 'type' => 'integer', 'description' => __( 'Parent ID if this is a cropped attachment', 'web-stories' ), 'show_in_rest' => true, 'default' => 0, 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Deletes associated meta data when a video is deleted. * * @since 1.26.0 * * @param int $attachment_id ID of the attachment to be deleted. */ public function delete_video( int $attachment_id ): void { delete_metadata( 'post', 0, self::CROPPED_ID_POST_META_KEY, $attachment_id, true ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::CROPPED_ID_POST_META_KEY ); } } ================================================ FILE: includes/Media/Image_Sizes.php ================================================ add_image_sizes(); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ], 10, 2 ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.0.0 * * @param array|mixed $response Array of prepared attachment data. * @param WP_Post $attachment Attachment object. * @return array|mixed $response; * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response, WP_Post $attachment ) { if ( ! \is_array( $response ) ) { return $response; } // See https://github.com/WordPress/wordpress-develop/blob/d28766f8f2ecf2be02c2520cdf0cc3b51deb9e1b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php#L753-L791 . $response['media_details'] = wp_get_attachment_metadata( $attachment->ID ); // Ensure empty details is an empty object. if ( empty( $response['media_details'] ) ) { $response['media_details'] = []; } elseif ( ! empty( $response['media_details']['sizes'] ) ) { foreach ( $response['media_details']['sizes'] as $size => &$size_data ) { if ( isset( $size_data['mime-type'] ) ) { $size_data['mime_type'] = $size_data['mime-type']; unset( $size_data['mime-type'] ); } // Use the same method image_downsize() does. $image = wp_get_attachment_image_src( $attachment->ID, $size ); if ( ! $image ) { continue; } [ $image_src ] = $image; $size_data['source_url'] = $image_src; } $img_src = wp_get_attachment_image_src( $attachment->ID, 'full' ); if ( $img_src ) { [ $src, $width, $height ] = $img_src; $response['media_details']['sizes']['full'] = [ 'file' => wp_basename( $src ), 'width' => $width, 'height' => $height, 'mime_type' => $attachment->post_mime_type, 'source_url' => $src, ]; } } else { $response['media_details']['sizes'] = []; } return $response; } /** * Add image sizes. * * @since 1.10.0 * * @link https://amp.dev/documentation/components/amp-story/#poster-guidelines-for-poster-portrait-src-poster-landscape-src-and-poster-square-src. */ protected function add_image_sizes(): void { // Used for amp-story[poster-portrait-src]: The story poster in portrait format (3x4 aspect ratio). add_image_size( self::POSTER_PORTRAIT_IMAGE_SIZE, self::POSTER_PORTRAIT_IMAGE_DIMENSIONS[0], self::POSTER_PORTRAIT_IMAGE_DIMENSIONS[1], true ); // As per https://amp.dev/documentation/components/amp-story/#publisher-logo-src-guidelines. add_image_size( self::PUBLISHER_LOGO_IMAGE_SIZE, self::PUBLISHER_LOGO_IMAGE_DIMENSIONS[0], self::PUBLISHER_LOGO_IMAGE_DIMENSIONS[1], true ); // Used in the editor. add_image_size( self::STORY_THUMBNAIL_IMAGE_SIZE, self::STORY_THUMBNAIL_IMAGE_DIMENSIONS[0], self::STORY_THUMBNAIL_IMAGE_DIMENSIONS[1], false ); } } ================================================ FILE: includes/Media/Media_Source_Taxonomy.php ================================================ context = $context; $this->taxonomy_slug = 'web_story_media_source'; $this->taxonomy_post_type = 'attachment'; } /** * Init. * * @since 1.10.0 */ public function register(): void { $this->register_taxonomy(); add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] ); // Hide video posters from Media grid view. add_filter( 'ajax_query_attachments_args', [ $this, 'filter_ajax_query_attachments_args' ], PHP_INT_MAX ); // Hide video posters from Media list view. add_action( 'pre_get_posts', [ $this, 'filter_generated_media_attachments' ], PHP_INT_MAX ); // Hide video posters from web-stories/v1/media REST API requests. add_filter( 'web_stories_rest_attachment_query', [ $this, 'filter_rest_generated_media_attachments' ], PHP_INT_MAX ); } /** * Act on site initialization. * * @since 1.29.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { parent::on_site_initialization( $site ); $this->add_missing_terms(); } /** * Act on plugin activation. * * @since 1.29.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { parent::on_plugin_activation( $network_wide ); $this->add_missing_terms(); } /** * Returns all defined media source term names. * * @since 1.29.0 * * @return string[] Media sources */ public function get_all_terms(): array { $consts = ( new ReflectionClass( $this ) )->getConstants(); /** * List of terms. * * @var string[] $terms */ $terms = array_values( array_filter( $consts, static fn( $key ) => str_starts_with( $key, 'TERM_' ), ARRAY_FILTER_USE_KEY ) ); return $terms; } /** * Registers additional REST API fields upon API initialization. * * @since 1.10.0 */ public function rest_api_init(): void { // Custom field, as built in term update require term id and not slug. register_rest_field( $this->taxonomy_post_type, self::MEDIA_SOURCE_KEY, [ 'get_callback' => [ $this, 'get_callback_media_source' ], 'schema' => [ 'description' => __( 'Media source.', 'web-stories' ), 'type' => 'string', 'enum' => $this->get_all_terms(), 'context' => [ 'view', 'edit', 'embed' ], ], 'update_callback' => [ $this, 'update_callback_media_source' ], ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.0.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed $response Filtered attachment data. * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } // @phpstan-ignore argument.type (TODO: improve type) $response[ self::MEDIA_SOURCE_KEY ] = $this->get_callback_media_source( $response ); return $response; } /** * Force media attachment as string instead of the default array. * * @since 1.0.0 * * @param array $prepared Prepared data before response. */ public function get_callback_media_source( array $prepared ): string { /** * Taxonomy ID. * * @var int $id */ $id = $prepared['id']; $terms = get_the_terms( $id, $this->taxonomy_slug ); if ( \is_array( $terms ) && ! empty( $terms ) ) { return array_shift( $terms )->slug; } return ''; } /** * Update rest field callback. * * @since 1.0.0 * * @param string $value Value to update. * @param WP_Post $post Object to update on. * @return true|\WP_Error */ public function update_callback_media_source( string $value, WP_Post $post ) { $check = wp_set_object_terms( $post->ID, $value, $this->taxonomy_slug ); if ( is_wp_error( $check ) ) { return $check; } return true; } /** * Filters the attachment query args to hide generated video poster images. * * Reduces unnecessary noise in the Media grid view. * * @since 1.10.0 * * @param array|mixed $args Query args. * @return array|mixed Filtered query args. * * @template T * * @phpstan-return ($args is array ? array : mixed) */ public function filter_ajax_query_attachments_args( $args ) { if ( ! \is_array( $args ) ) { return $args; } // @phpstan-ignore argument.type (TODO: improve type) $args['tax_query'] = $this->get_exclude_tax_query( $args ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query return $args; } /** * Filters the current query to hide generated video poster images and source video. * * Reduces unnecessary noise in the Media list view. * * @since 1.10.0 * * @param WP_Query $query WP_Query instance, passed by reference. */ public function filter_generated_media_attachments( WP_Query $query ): void { if ( is_admin() && $query->is_main_query() && $this->context->is_upload_screen() ) { $tax_query = $query->get( 'tax_query' ); $query->set( 'tax_query', $this->get_exclude_tax_query( [ 'tax_query' => $tax_query ] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } } /** * Filters the current query to hide generated video poster images. * * Reduces unnecessary noise in media REST API requests. * * @since 1.10.0 * * @param array|mixed $args Query args. * @return array|mixed Filtered query args. * * @template T * * @phpstan-return ($args is array ? array : mixed) */ public function filter_rest_generated_media_attachments( $args ) { if ( ! \is_array( $args ) ) { return $args; } // @phpstan-ignore argument.type (TODO: improve type) $args['tax_query'] = $this->get_exclude_tax_query( $args ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query return $args; } /** * Adds missing terms to the taxonomy. * * @since 1.29.0 */ private function add_missing_terms(): void { $existing_terms = get_terms( [ 'taxonomy' => $this->get_taxonomy_slug(), 'hide_empty' => false, 'fields' => 'slugs', ] ); if ( is_wp_error( $existing_terms ) ) { return; } $missing_terms = array_diff( $this->get_all_terms(), $existing_terms ); foreach ( $missing_terms as $term ) { wp_insert_term( $term, $this->get_taxonomy_slug() ); } } /** * Taxonomy args. * * @since 1.12.0 * * @return array Taxonomy args. * * @phpstan-return TaxonomyArgs */ protected function taxonomy_args(): array { return [ 'label' => __( 'Source', 'web-stories' ), 'public' => false, 'rewrite' => false, 'hierarchical' => false, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'rest_controller_class' => Stories_Terms_Controller::class, ]; } /** * Returns the tax query needed to exclude generated video poster images and source videos. * * @param array $args Existing WP_Query args. * @return array Tax query arg. */ private function get_exclude_tax_query( array $args ): array { /** * Tax query. * * @var array $tax_query */ $tax_query = ! empty( $args['tax_query'] ) ? $args['tax_query'] : []; /** * Filter whether generated attachments should be hidden in the media library. * * @since 1.16.0 * * @param bool $enabled Whether the taxonomy check should be applied. * @param array $args Existing WP_Query args. */ $enabled = apply_filters( 'web_stories_hide_auto_generated_attachments', true, $args ); if ( true !== $enabled ) { return $tax_query; } /** * Merge with existing tax query if needed, * in a nested way so WordPress will run them * with an 'AND' relation. Example: * * [ * 'relation' => 'AND', // implicit. * [ this query ], * [ [ any ], [ existing ], [ tax queries] ] * ] */ $new_tax_query = [ 'relation' => 'AND', [ 'taxonomy' => $this->taxonomy_slug, 'field' => 'slug', 'terms' => [ self::TERM_POSTER_GENERATION, self::TERM_SOURCE_VIDEO, self::TERM_SOURCE_IMAGE, self::TERM_PAGE_TEMPLATE, ], 'operator' => 'NOT IN', ], ]; if ( ! empty( $tax_query ) ) { $new_tax_query[] = [ $tax_query ]; } return $new_tax_query; } } ================================================ FILE: includes/Media/SVG.php ================================================ experiments = $experiments; } /** * Register filters and actions. * * @since 1.3.0 */ public function register(): void { if ( ! $this->experiments->is_experiment_enabled( 'enableSVG' ) ) { return; } add_filter( 'web_stories_allowed_mime_types', [ $this, 'web_stories_allowed_mime_types' ] ); // Check if svg uploads, already enabled. if ( $this->svg_already_enabled() ) { add_filter( 'mime_types', [ $this, 'mime_types_add_svg' ] ); return; } add_filter( 'upload_mimes', [ $this, 'upload_mimes_add_svg' ] ); // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.upload_mimes add_filter( 'mime_types', [ $this, 'mime_types_add_svg' ] ); add_filter( 'wp_handle_upload_prefilter', [ $this, 'wp_handle_upload' ] ); add_filter( 'wp_generate_attachment_metadata', [ $this, 'wp_generate_attachment_metadata' ], 10, 3 ); add_filter( 'wp_check_filetype_and_ext', [ $this, 'wp_check_filetype_and_ext' ], 10, 5 ); add_filter( 'site_option_upload_filetypes', [ $this, 'filter_list_of_allowed_filetypes' ] ); } /** * Enable SVG upload. * * @since 1.3.0 * * @param array $mime_types Mime types keyed by the file extension regex corresponding to those types. * @return array */ public function upload_mimes_add_svg( array $mime_types ): array { // allow SVG file upload. $mime_types['svg'] = self::MIME_TYPE; $mime_types['svgz'] = self::MIME_TYPE; return $mime_types; } /** * Adds SVG to list of mime types and file extensions * * @since 1.3.0 * * @param string[] $mime_types Mime types keyed by the file extension regex * corresponding to those types. * @return array */ public function mime_types_add_svg( array $mime_types ): array { // allow SVG files. $mime_types['svg'] = self::MIME_TYPE; return array_unique( $mime_types ); } /** * Add SVG to allowed mime types. * * @since 1.3.0 * * @param array $mime_types Associative array of allowed mime types per media type (image, audio, video). * @return array */ public function web_stories_allowed_mime_types( array $mime_types ): array { $mime_types['vector'][] = self::MIME_TYPE; return $mime_types; } /** * Add svg file type to allow file in multisite. * * @since 1.3.0 * * @param string $value List of allowed file types. * @return string List of allowed file types. */ public function filter_list_of_allowed_filetypes( string $value ): string { $filetypes = explode( ' ', $value ); if ( ! \in_array( self::EXT, $filetypes, true ) ) { $filetypes[] = self::EXT; $value = implode( ' ', $filetypes ); } return $value; } /** * Hook into metadata generation and get height and width for SVG file. * * @since 1.3.0 * * @param array $metadata An array of attachment meta data. * @param int $attachment_id Current attachment ID. * @param string $context Additional context. Can be 'create' when metadata * was initially created for new attachment. * @return array Filtered metadata. */ public function wp_generate_attachment_metadata( array $metadata, int $attachment_id, string $context ): array { if ( 'create' !== $context ) { return $metadata; } $attachment = get_post( $attachment_id ); $mime_type = get_post_mime_type( $attachment ); if ( self::MIME_TYPE !== $mime_type ) { return $metadata; } $file = get_attached_file( $attachment_id ); if ( false === $file ) { return $metadata; } $size = $this->get_svg_size( $file ); // Check if image size failed to generate and return if so. if ( is_wp_error( $size ) ) { return $metadata; } return [ 'width' => (int) $size['width'], 'height' => (int) $size['height'], 'file' => _wp_relative_upload_path( $file ), 'filesize' => (int) filesize( $file ), 'sizes' => [], ]; } /** * Hook into upload and error if size could not be generated. * * @since 1.3.0 * * @param array $upload { * Array of upload data. * * @type string $file Filename of the newly-uploaded file. * @type string $url URL of the newly-uploaded file. * @type string $type Mime type of the newly-uploaded file. * @type string $tmp_name Temporary file name. * } * @return string[] * * @phpstan-param array{file: string, url: string, type: string, tmp_name: string} $upload */ public function wp_handle_upload( array $upload ): array { if ( self::MIME_TYPE !== $upload['type'] ) { return $upload; } $sanitized = $this->sanitize( $upload['tmp_name'] ); if ( is_wp_error( $sanitized ) ) { return [ 'error' => $sanitized->get_error_message() ]; } $size = $this->get_svg_size( $upload['tmp_name'] ); if ( is_wp_error( $size ) ) { return [ 'error' => $size->get_error_message() ]; } return $upload; } /** * Work around for incorrect mime type. * * @since 1.3.0 * * @param array $wp_check_filetype_and_ext { * Values for the extension, mime type, and corrected filename. * * @type string|false $ext File extension, or false if the file doesn't match a mime type. * @type string|false $type File mime type, or false if the file doesn't match a mime type. * @type string|false $proper_filename File name with its correct extension, or false if it cannot be * determined. * } * @param string $file Full path to the file. * @param string $filename The name of the file (may differ from $file due to * $file being in a tmp directory). * @param string[]|null|false $mimes Array of mime types keyed by their file extension regex. * @param string|bool $real_mime The actual mime type or false if the type cannot be determined. * @return array{ext?: string, type?: string, proper_filename?: bool} * * @phpstan-param array{ext?: string, type?: string, proper_filename?: bool} $wp_check_filetype_and_ext */ public function wp_check_filetype_and_ext( array $wp_check_filetype_and_ext, string $file, string $filename, $mimes, $real_mime ): array { if ( 'image/svg' === $real_mime ) { $wp_check_filetype_and_ext = [ 'ext' => self::EXT, 'type' => self::MIME_TYPE, 'proper_filename' => false, ]; } return $wp_check_filetype_and_ext; } /** * Helper function to check if svg uploads are already enabled. * * @since 1.3.0 */ private function svg_already_enabled(): bool { $allowed_mime_types = get_allowed_mime_types(); $mime_types = array_values( $allowed_mime_types ); return \in_array( self::MIME_TYPE, $mime_types, true ); } /** * Get SVG image size. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.3.0 * * @param string $file Path to SVG file. * @return array|WP_Error * * @phpstan-return array{width: int, height: int}|WP_Error */ protected function get_svg_size( string $file ) { $svg = $this->get_svg_data( $file ); $xml = $this->get_xml( $svg ); if ( false === $xml ) { return new \WP_Error( 'invalid_xml_svg', __( 'Invalid XML in SVG.', 'web-stories' ) ); } $width = (int) $xml->getAttribute( 'width' ); $height = (int) $xml->getAttribute( 'height' ); // If height and width are not set, try the viewport attribute. if ( ! $width || ! $height ) { $view_box = $xml->getAttribute( 'viewBox' ); if ( empty( $view_box ) ) { $view_box = $xml->getAttribute( 'viewbox' ); } $pieces = explode( ' ', $view_box ); if ( 4 === \count( $pieces ) ) { [, , $width, $height] = $pieces; } } if ( ! $width || ! $height ) { return new \WP_Error( 'invalid_svg_size', __( 'Unable to generate SVG image size.', 'web-stories' ) ); } return array_map( 'absint', compact( 'width', 'height' ) ); } /** * Sanitize the SVG * * @since 1.3.0 * * @param string $file File path. * @return true|WP_Error */ protected function sanitize( string $file ) { $dirty = $this->get_svg_data( $file ); $sanitizer = new Sanitizer(); $clean = $sanitizer->sanitize( $dirty ); if ( empty( $clean ) ) { return new \WP_Error( 'invalid_xml_svg', __( 'Invalid XML in SVG.', 'web-stories' ) ); } $errors = $sanitizer->getXmlIssues(); if ( \count( $errors ) > 1 ) { return new \WP_Error( 'insecure_svg_file', __( "Sorry, this file couldn't be sanitized so for security reasons wasn't uploaded.", 'web-stories' ) ); } return true; } /** * Get xml document. * * @since 1.3.0 * * @param string $svg String of xml. * @return DOMElement|false */ protected function get_xml( string $svg ) { $dom = new DOMDocument(); $dom->preserveWhiteSpace = false; $dom->strictErrorChecking = false; $errors = libxml_use_internal_errors( true ); $loaded = $dom->loadXML( $svg ); if ( ! $loaded ) { return false; } $node = $dom->getElementsByTagName( 'svg' )->item( 0 ); libxml_clear_errors(); libxml_use_internal_errors( $errors ); if ( ! $node ) { return false; } return $node; } /** * Get SVG data. * * @since 1.3.0 * * @param string $file File path. * @return string File contents. */ protected function get_svg_data( string $file ): string { $key = md5( $file ); if ( ! isset( $this->svgs[ $key ] ) ) { if ( is_readable( $file ) ) { $this->svgs[ $key ] = (string) file_get_contents( $file ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown } else { $this->svgs[ $key ] = ''; } } return $this->svgs[ $key ]; } } ================================================ FILE: includes/Media/Types.php ================================================ $mime ) { if ( \in_array( $mime, $mime_types, true ) ) { array_push( $allowed_file_types, ...explode( '|', $ext ) ); } } sort( $allowed_file_types ); return $allowed_file_types; } /** * Returns a list of allowed mime types per media type (image, audio, video). * * @since 1.0.0 * * @return array List of allowed mime types. */ public function get_allowed_mime_types(): array { $default_allowed_mime_types = [ 'image' => [ 'image/webp', 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/avif', ], 'audio' => [ 'audio/mpeg', 'audio/aac', 'audio/wav', 'audio/ogg', ], 'caption' => [ 'text/vtt' ], 'vector' => [], 'video' => [ 'video/mp4', 'video/webm', ], ]; /** * Filter list of allowed mime types. * * This can be used to add additionally supported formats, for example by plugins * that do video transcoding. * * @since 1.0.0 * * @param array $default_allowed_mime_types Associative array of allowed mime types per media type (image, audio, video). */ $allowed_mime_types = apply_filters( 'web_stories_allowed_mime_types', $default_allowed_mime_types ); /** * Media type. * * @var string $media_type */ foreach ( array_keys( $default_allowed_mime_types ) as $media_type ) { if ( ! isset( $allowed_mime_types[ $media_type ] ) || empty( $allowed_mime_types[ $media_type ] ) ) { $allowed_mime_types[ $media_type ] = $default_allowed_mime_types[ $media_type ]; } // Only add currently supported mime types. $allowed_mime_types[ $media_type ] = array_values( array_intersect( $allowed_mime_types[ $media_type ], get_allowed_mime_types() ) ); } return $allowed_mime_types; } } ================================================ FILE: includes/Media/Video/Captions.php ================================================ register_meta(); } /** * Register post meta * * @since 1.23.0 */ public function register_meta(): void { register_post_meta( 'attachment', self::IS_GIF_POST_META_KEY, [ 'sanitize_callback' => 'rest_sanitize_boolean', 'type' => 'boolean', 'description' => __( 'Whether the video is to be considered a GIF', 'web-stories' ), 'show_in_rest' => true, 'default' => false, 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.23.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed Response data. * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } /** * Post ID. * * @var int $post_id */ $post_id = $response['id']; $response[ self::IS_GIF_POST_META_KEY ] = get_post_meta( $post_id, self::IS_GIF_POST_META_KEY, true ); return $response; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::IS_GIF_POST_META_KEY ); } } ================================================ FILE: includes/Media/Video/Muting.php ================================================ register_meta(); add_action( 'delete_attachment', [ $this, 'delete_video' ] ); add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] ); } /** * Register meta for attachment post type. * * @since 1.10.0 */ public function register_meta(): void { register_meta( 'post', self::IS_MUTED_POST_META_KEY, [ 'type' => 'boolean', 'description' => __( 'Whether the video is muted', 'web-stories' ), 'default' => false, 'single' => true, 'object_subtype' => 'attachment', ] ); register_meta( 'post', self::MUTED_ID_POST_META_KEY, [ 'sanitize_callback' => 'absint', 'type' => 'integer', 'description' => __( 'ID of muted video.', 'web-stories' ), 'show_in_rest' => true, 'default' => 0, 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Registers additional REST API fields upon API initialization. * * @since 1.10.0 */ public function rest_api_init(): void { register_rest_field( 'attachment', self::IS_MUTED_REST_API_KEY, [ 'get_callback' => [ $this, 'get_callback_is_muted' ], 'schema' => [ 'type' => [ 'boolean', 'null' ], 'description' => __( 'Whether the video is muted', 'web-stories' ), 'default' => null, 'context' => [ 'view', 'edit', 'embed' ], 'arg_options' => [ 'sanitize_callback' => 'rest_sanitize_boolean', ], ], 'update_callback' => [ $this, 'update_callback_is_muted' ], ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.10.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed Response data. * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } if ( 'video' === $response['type'] ) { // @phpstan-ignore argument.type $response[ self::IS_MUTED_REST_API_KEY ] = $this->get_callback_is_muted( $response ); } return $response; } /** * Get the attachment's post meta. * * @since 1.10.0 * * @param array $prepared Array of data to add to. */ public function get_callback_is_muted( array $prepared ): ?bool { /** * Attachment ID. * * @var int $id */ $id = $prepared['id']; /** * Muted value. * * @var bool|null $value */ $value = get_metadata_raw( 'post', $id, self::IS_MUTED_POST_META_KEY, true ); if ( null === $value ) { return $value; } return rest_sanitize_boolean( $value ); } /** * Update the attachment's post meta. * * @since 1.10.0 * * @param mixed $value Value to updated. * @param WP_Post $post Post object to be updated. * @return true|WP_Error */ public function update_callback_is_muted( $value, WP_Post $post ) { $object_id = $post->ID; $name = self::IS_MUTED_REST_API_KEY; $meta_key = self::IS_MUTED_POST_META_KEY; if ( ! current_user_can( 'edit_post_meta', $object_id, $meta_key ) ) { return new \WP_Error( 'rest_cannot_update', /* translators: %s: Custom field key.**/ \sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.', 'web-stories' ), $name ), [ 'key' => $name, 'status' => rest_authorization_required_code(), ] ); } update_post_meta( $object_id, $meta_key, $value ); return true; } /** * Deletes associated meta data when a video is deleted. * * @since 1.26.0 * * @param int $attachment_id ID of the attachment to be deleted. */ public function delete_video( int $attachment_id ): void { delete_metadata( 'post', 0, self::MUTED_ID_POST_META_KEY, $attachment_id, true ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::MUTED_ID_POST_META_KEY ); delete_post_meta_by_key( self::IS_MUTED_POST_META_KEY ); } } ================================================ FILE: includes/Media/Video/Optimization.php ================================================ register_meta(); add_action( 'delete_attachment', [ $this, 'delete_video' ] ); } /** * Register meta * * @since 1.15.0 */ public function register_meta(): void { register_meta( 'post', self::OPTIMIZED_ID_POST_META_KEY, [ 'sanitize_callback' => 'absint', 'type' => 'integer', 'description' => __( 'ID of optimized video.', 'web-stories' ), 'show_in_rest' => true, 'default' => 0, 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Deletes associated meta data when a video is deleted. * * @since 1.26.0 * * @param int $attachment_id ID of the attachment to be deleted. */ public function delete_video( int $attachment_id ): void { delete_metadata( 'post', 0, self::OPTIMIZED_ID_POST_META_KEY, $attachment_id, true ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::OPTIMIZED_ID_POST_META_KEY ); } } ================================================ FILE: includes/Media/Video/Poster.php ================================================ media_source_taxonomy = $media_source_taxonomy; } /** * Init. * * @since 1.10.0 */ public function register(): void { $this->register_meta(); add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); add_action( 'delete_attachment', [ $this, 'delete_video_poster' ] ); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ], 10, 2 ); } /** * Register meta for attachment post type. * * @since 1.10.0 */ public function register_meta(): void { register_meta( 'post', self::POSTER_ID_POST_META_KEY, [ 'sanitize_callback' => 'absint', 'type' => 'integer', 'description' => __( 'Attachment id of generated poster image.', 'web-stories' ), 'show_in_rest' => true, 'default' => 0, 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Registers additional REST API fields upon API initialization. * * @since 1.0.0 */ public function rest_api_init(): void { register_rest_field( 'attachment', 'featured_media', [ 'schema' => [ 'description' => __( 'The ID of the featured media for the object.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], ], ] ); register_rest_field( 'attachment', 'featured_media_src', [ 'get_callback' => [ $this, 'get_callback_featured_media_src' ], 'schema' => [ 'description' => __( 'URL, width and height.', 'web-stories' ), 'type' => 'object', 'properties' => [ 'src' => [ 'type' => 'string', 'format' => 'uri', ], 'width' => [ 'type' => 'integer', ], 'height' => [ 'type' => 'integer', ], 'generated' => [ 'type' => 'boolean', ], ], 'context' => [ 'view', 'edit', 'embed' ], ], ] ); } /** * Get attachment source for featured media. * * @since 1.0.0 * * @param array $prepared Prepared data before response. * @return array */ public function get_callback_featured_media_src( array $prepared ): array { /** * Featured media ID. * * @var int|null $id */ $id = $prepared['featured_media'] ?? null; $image = []; if ( $id ) { $image = $this->get_thumbnail_data( $id ); } return $image; } /** * Filters the attachment data prepared for JavaScript. * * @since 1.0.0 * * @param array|mixed $response Array of prepared attachment data. * @param WP_Post $attachment Attachment object. * @return array|mixed $response * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response, WP_Post $attachment ) { if ( ! \is_array( $response ) ) { return $response; } if ( 'video' === $response['type'] ) { $thumbnail_id = (int) get_post_thumbnail_id( $attachment ); $image = ''; if ( 0 !== $thumbnail_id ) { $image = $this->get_thumbnail_data( $thumbnail_id ); } $response['featured_media'] = $thumbnail_id; $response['featured_media_src'] = $image; } return $response; } /** * Get poster image data. * * @since 1.0.0 * * @param int $thumbnail_id Attachment ID. * @return array{src?: string, width?: int, height?: int, generated?: bool} */ public function get_thumbnail_data( int $thumbnail_id ): array { $img_src = wp_get_attachment_image_src( $thumbnail_id, 'full' ); if ( ! $img_src ) { return []; } [ $src, $width, $height ] = $img_src; $generated = $this->is_poster( $thumbnail_id ); return compact( 'src', 'width', 'height', 'generated' ); } /** * Deletes associated poster image when a video is deleted. * * This prevents the poster image from becoming an orphan because it is not * displayed anywhere in WordPress or the story editor. * * @since 1.0.0 * * @param int $attachment_id ID of the attachment to be deleted. */ public function delete_video_poster( int $attachment_id ): void { /** * Post ID. * * @var int|string $post_id */ $post_id = get_post_meta( $attachment_id, self::POSTER_ID_POST_META_KEY, true ); if ( empty( $post_id ) ) { return; } // Used in favor of slow meta queries. $is_poster = $this->is_poster( (int) $post_id ); if ( $is_poster ) { wp_delete_attachment( (int) $post_id, true ); } } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::POSTER_ID_POST_META_KEY ); delete_post_meta_by_key( self::POSTER_POST_META_KEY ); } /** * Helper util to check if attachment is a poster. * * @since 1.2.1 * * @param int $post_id Attachment ID. */ protected function is_poster( int $post_id ): bool { $terms = get_the_terms( $post_id, $this->media_source_taxonomy->get_taxonomy_slug() ); if ( \is_array( $terms ) && ! empty( $terms ) ) { $slugs = wp_list_pluck( $terms, 'slug' ); return \in_array( $this->media_source_taxonomy::TERM_POSTER_GENERATION, $slugs, true ); } return false; } } ================================================ FILE: includes/Media/Video/Trimming.php ================================================ register_meta(); add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] ); } /** * Register meta for attachment post type. * * @since 1.12.0 */ public function register_meta(): void { register_meta( 'post', self::TRIM_POST_META_KEY, [ 'type' => 'object', 'description' => __( 'Video trim data.', 'web-stories' ), 'show_in_rest' => [ 'schema' => [ 'properties' => [ 'original' => [ 'description' => __( 'Original attachment id', 'web-stories' ), 'type' => 'integer', ], 'start' => [ 'description' => __( 'Start time.', 'web-stories' ), 'type' => 'string', ], 'end' => [ 'description' => __( 'End time.', 'web-stories' ), 'type' => 'string', ], ], ], ], 'default' => [ 'original' => 0, ], 'single' => true, 'object_subtype' => 'attachment', ] ); } /** * Filters the attachment data prepared for JavaScript. * * @since 1.12.0 * * @param array|mixed $response Array of prepared attachment data. * @return array|mixed Response data. * * @template T * * @phpstan-return ($response is array ? array : mixed) */ public function wp_prepare_attachment_for_js( $response ) { if ( ! \is_array( $response ) ) { return $response; } if ( 'video' === $response['type'] ) { /** * Post ID. * * @var int $post_id */ $post_id = $response['id']; $response[ self::TRIM_DATA_KEY ] = get_post_meta( $post_id, self::TRIM_POST_META_KEY, true ); } return $response; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::TRIM_POST_META_KEY ); } } ================================================ FILE: includes/Mgid.php ================================================ settings = $settings; } /** * Initializes all hooks. * * @since 1.33.0 */ public function register(): void { add_action( 'web_stories_print_analytics', [ $this, 'print_mgid_tag' ] ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.33.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Prints the tag for single stories. * * @since 1.33.0 */ public function print_mgid_tag(): void { $widget = $this->get_widget_id(); $enabled = $this->is_enabled(); if ( ! $enabled || ! $widget ) { return; } $configuration = [ 'ad-attributes' => [ 'type' => 'mgid', 'data-widget' => $widget, ], ]; /** * Filters MGID configuration passed to ``. * * @since 1.33.0 * * @param array $settings MGID configuration. * @param string $widget MGID Widget ID. */ $configuration = apply_filters( 'web_stories_mgid_configuration', $configuration, $widget ); ?> settings->get_setting( $this->settings::SETTING_NAME_MGID_WIDGET_ID ); } /** * Returns if MGID is enabled. * * @since 1.33.0 */ private function is_enabled(): bool { return ( 'mgid' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) ); } } ================================================ FILE: includes/Migrations/Add_Media_Source.php ================================================ media_source_taxonomy = $media_source_taxonomy; } /** * Add the editor term, to make sure it exists. * * @since 1.9.0 */ public function migrate(): void { wp_insert_term( $this->get_term(), $this->media_source_taxonomy->get_taxonomy_slug() ); } /** * Override this method. * * @since 1.9.0 */ abstract protected function get_term(): string; } ================================================ FILE: includes/Migrations/Add_Media_Source_Editor.php ================================================ media_source_taxonomy::TERM_EDITOR; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Gif_Conversion.php ================================================ media_source_taxonomy::TERM_GIF_CONVERSION; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Page_Template.php ================================================ media_source_taxonomy::TERM_PAGE_TEMPLATE; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Recording.php ================================================ media_source_taxonomy::TERM_RECORDING; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Source_Image.php ================================================ media_source_taxonomy::TERM_SOURCE_IMAGE; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Source_Video.php ================================================ media_source_taxonomy::TERM_SOURCE_VIDEO; } } ================================================ FILE: includes/Migrations/Add_Media_Source_Video_Optimization.php ================================================ media_source_taxonomy::TERM_VIDEO_OPTIMIZATION; } } ================================================ FILE: includes/Migrations/Add_Poster_Generation_Media_Source.php ================================================ get_term_name(), $this->media_source_taxonomy->get_taxonomy_slug() ); parent::migrate(); } /** * Get name of meta key to be used in migration. * * @since 1.7.2 */ protected function get_post_meta_key(): string { return Poster::POSTER_POST_META_KEY; } } ================================================ FILE: includes/Migrations/Add_Stories_Caps.php ================================================ capabilities = $capabilities; } /** * Adds story capabilities to default user roles. * * @since 1.7.0 */ public function migrate(): void { $this->capabilities->add_caps_to_roles(); } } ================================================ FILE: includes/Migrations/Add_VideoPress_Poster_Generation_Media_Source.php ================================================ media_source_taxonomy = $media_source_taxonomy; } /** * Migration media post meta to taxonomy term. * * @since 1.7.2 * * @global \wpdb $wpdb WordPress database abstraction object. */ public function migrate(): void { global $wpdb; $post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s", $this->get_post_meta_key() ) ); if ( \is_array( $post_ids ) && ! empty( $post_ids ) ) { /** * Post ID. * * @var int|string $post_id */ foreach ( $post_ids as $post_id ) { wp_set_object_terms( (int) $post_id, $this->get_term_name(), $this->media_source_taxonomy->get_taxonomy_slug() ); } } } /** * Get name of meta key to be used in migration. * This method is designed for overridden. * * @since 1.7.2 */ abstract protected function get_post_meta_key(): string; /** * Get name of term to be used in migration. * This method is designed for overridden. * * @since 1.7.2 */ protected function get_term_name(): string { return $this->media_source_taxonomy::TERM_POSTER_GENERATION; } } ================================================ FILE: includes/Migrations/Remove_Broken_Text_Styles.php ================================================ , * textColors: mixed, * } */ class Remove_Broken_Text_Styles extends Migrate_Base { /** * Removes broken text styles (with color.r|g|b structure). * * @since 1.7.0 */ public function migrate(): void { /** * List of style presets. * * @phpstan-var StylePresets|false */ $style_presets = get_option( Story_Post_Type::STYLE_PRESETS_OPTION, false ); // Nothing to do if style presets don't exist. if ( ! \is_array( $style_presets ) ) { return; } $text_styles = []; if ( ! empty( $style_presets['textStyles'] ) ) { foreach ( $style_presets['textStyles'] as $preset ) { if ( isset( $preset['color']['r'] ) ) { continue; } $text_styles[] = $preset; } } $updated_style_presets = [ 'fillColors' => $style_presets['fillColors'], 'textColors' => $style_presets['textColors'], 'textStyles' => $text_styles, ]; update_option( Story_Post_Type::STYLE_PRESETS_OPTION, $updated_style_presets ); } } ================================================ FILE: includes/Migrations/Remove_Incorrect_Tracking_Id.php ================================================ settings = $settings; } /** * Remove incorrect tracking ID. * * @since 1.30.0 */ public function migrate(): void { $tracking_id = $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_ID ); if ( Tracking::TRACKING_ID === $tracking_id ) { $this->settings->update_setting( $this->settings::SETTING_NAME_TRACKING_ID, '' ); } } } ================================================ FILE: includes/Migrations/Remove_Unneeded_Attachment_Meta.php ================================================ , * textColors: mixed, * } */ class Replace_Conic_Style_Presets extends Migrate_Base { /** * Replaces conic color type with linear. * * @since 1.7.0 */ public function migrate(): void { /** * List of style presets. * * @var array|null $style_presets * @phpstan-var StylePresets|null $style_presets */ $style_presets = get_option( Story_Post_Type::STYLE_PRESETS_OPTION, false ); // Nothing to do if style presets don't exist. if ( ! \is_array( $style_presets ) ) { return; } $fill_colors = []; $text_styles = []; if ( ! empty( $style_presets['fillColors'] ) ) { foreach ( $style_presets['fillColors'] as $color ) { if ( ! isset( $color['type'] ) || 'conic' !== $color['type'] ) { $text_styles[] = $color; continue; } $updated_preset = $color; $updated_preset['type'] = 'linear'; $fill_colors[] = $updated_preset; } } if ( ! empty( $style_presets['textStyles'] ) ) { foreach ( $style_presets['textStyles'] as $preset ) { if ( empty( $preset['backgroundColor'] ) ) { $text_styles[] = $preset; continue; } $bg_color = $preset['backgroundColor']; if ( 'conic' !== $bg_color['type'] ) { $text_styles[] = $preset; continue; } $updated_preset = $preset; $updated_preset['backgroundColor']['type'] = 'linear'; $text_styles[] = $updated_preset; } } $updated_style_presets = [ 'fillColors' => $fill_colors, 'textColors' => $style_presets['textColors'], 'textStyles' => $text_styles, ]; update_option( Story_Post_Type::STYLE_PRESETS_OPTION, $updated_style_presets ); } } ================================================ FILE: includes/Migrations/Rewrite_Flush.php ================================================ settings = $settings; } /** * Set legacy analytics usage flag. * * @since 1.12.0 */ public function migrate(): void { $this->settings->update_setting( $this->settings::SETTING_NAME_USING_LEGACY_ANALYTICS, ! empty( $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_ID ) ) ); } } ================================================ FILE: includes/Migrations/Unify_Color_Presets.php ================================================ $colors, ]; update_option( Story_Post_Type::STYLE_PRESETS_OPTION, $updated_style_presets ); } } ================================================ FILE: includes/Migrations/Update_1.php ================================================ settings = $settings; } /** * Split publisher logos into two options. * * @since 1.7.0 */ public function migrate(): void { $publisher_logo_id = 0; $publisher_logo_settings = (array) get_option( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] ); if ( ! empty( $publisher_logo_settings['active'] ) ) { $publisher_logo_id = $publisher_logo_settings['active']; } $this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, $publisher_logo_id ); $this->settings->update_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, array_filter( [ $publisher_logo_id ] ) ); } } ================================================ FILE: includes/Model/Story.php ================================================ $story Array of attributes. */ public function __construct( array $story = [] ) { foreach ( $story as $key => $value ) { if ( property_exists( $this, $key ) ) { $this->$key = $value; } } } /** * Load story from post. * * @since 1.0.0 * * @param int|null|WP_Post $_post Post id or Post object. */ public function load_from_post( $_post ): bool { /** * Filters the publisher's name * * @since 1.7.0 * * @param string $name Publisher Name. */ $this->publisher_name = apply_filters( 'web_stories_publisher_name', get_bloginfo( 'name' ) ); $post = get_post( $_post ); if ( ! $post instanceof WP_Post ) { return false; } // At this point we assume being passed a legit web-story post or perhaps a web-story revision. $this->id = $post->ID; $this->title = get_the_title( $post ); $this->excerpt = $post->post_excerpt; $this->markup = $post->post_content; $this->url = (string) get_permalink( $post ); $thumbnail_id = (int) get_post_thumbnail_id( $post ); if ( 0 !== $thumbnail_id ) { $poster_src = wp_get_attachment_image_src( $thumbnail_id, Image_Sizes::POSTER_PORTRAIT_IMAGE_DIMENSIONS ); if ( $poster_src ) { [ $poster_url, $width, $height ] = $poster_src; $this->poster_portrait = $poster_url; $this->poster_portrait_size = [ (int) $width, (int) $height ]; $image_meta = wp_get_attachment_metadata( $thumbnail_id ); if ( $image_meta ) { $size_array = [ $image_meta['width'], $image_meta['height'] ]; $this->poster_sizes = (string) wp_calculate_image_sizes( $size_array, $poster_url, $image_meta, $thumbnail_id ); $this->poster_srcset = (string) wp_calculate_image_srcset( $size_array, $poster_url, $image_meta, $thumbnail_id ); } } } else { /** * Poster. * * @var array{url:string, width: int, height: int}|false $poster */ $poster = get_post_meta( $post->ID, Story_Post_Type::POSTER_META_KEY, true ); if ( ! empty( $poster ) ) { $this->poster_portrait = $poster['url']; $this->poster_portrait_size = [ (int) $poster['width'], (int) $poster['height'] ]; } } /** * Publisher logo ID. * * @var string|int $publisher_logo_id */ $publisher_logo_id = get_post_meta( $this->id, Story_Post_Type::PUBLISHER_LOGO_META_KEY, true ); if ( ! empty( $publisher_logo_id ) ) { $img_src = wp_get_attachment_image_src( (int) $publisher_logo_id, Image_Sizes::PUBLISHER_LOGO_IMAGE_DIMENSIONS ); if ( $img_src ) { [ $src, $width, $height ] = $img_src; $this->publisher_logo_size = [ $width, $height ]; $this->publisher_logo = $src; } } /** * Product data. * * @var ProductData[]|false $products */ $products = get_post_meta( $this->id, Product_Meta::PRODUCTS_POST_META_KEY, true ); if ( \is_array( $products ) ) { $product_objects = []; foreach ( $products as $product ) { $product_objects[] = Product::load_from_array( $product ); } $this->products = $product_objects; } return true; } /** * Setter for poster set sizes. * * @since 1.21.0 * * @param string $poster_sizes Poster sizes. */ public function set_poster_sizes( string $poster_sizes ): void { $this->poster_sizes = $poster_sizes; } /** * Setter for poster source set. * * @since 1.21.0 * * @param string $poster_srcset Poster source set. */ public function set_poster_srcset( string $poster_srcset ): void { $this->poster_srcset = $poster_srcset; } /** * Setter for title. * * @since 1.21.0 * * @param string $title Title. */ public function set_title( string $title ): void { $this->title = $title; } /** * Getter for poster source set sizes. * * @since 1.18.0 */ public function get_poster_sizes(): string { return $this->poster_sizes; } /** * Getter for poster source set. * * @since 1.18.0 */ public function get_poster_srcset(): string { return $this->poster_srcset; } /** * Getter for title attribute. * * @since 1.0.0 */ public function get_title(): string { return $this->title; } /** * Getter for excerpt attribute. */ public function get_excerpt(): string { return $this->excerpt; } /** * Getter for url attribute. * * @since 1.0.0 */ public function get_url(): string { return $this->url; } /** * Getter for markup attribute. * * @since 1.0.0 */ public function get_markup(): string { return $this->markup; } /** * Getter for poster portrait attribute. * * @since 1.0.0 */ public function get_poster_portrait(): string { return $this->poster_portrait; } /** * Get the story ID. */ public function get_id(): int { return $this->id; } /** * Get author of the story. */ public function get_author(): string { return $this->author; } /** * Date for the story. */ public function get_date(): string { return $this->date; } /** * Returns the publisher name. * * @since 1.12.0 * * @return string Publisher Name. */ public function get_publisher_name(): string { return $this->publisher_name; } /** * Returns the story's publisher logo URL. * * @since 1.12.0 * * @return string|null Publisher logo URL or null if not set. */ public function get_publisher_logo_url(): ?string { /** * Filters the publisher logo URL. * * @since 1.0.0 * @since 1.1.0 The second parameter was deprecated. * @since 1.12.0 The second parameter was repurposed to provide the current story ID. * * @param string|null $url Publisher logo URL or null if not set. * @param int|null $id Story ID if available. */ return apply_filters( 'web_stories_publisher_logo', $this->publisher_logo, $this->id ); } /** * Returns the story's publisher logo size. * * @since 1.12.0 * * @return array { * Publisher logo size. * * Array of image data, or empty array if no image is available. * * @type int $1 Image width in pixels. * @type int $2 Image height in pixels. * } * * @phpstan-return array{0: int, 1: int}|array{} */ public function get_publisher_logo_size(): array { /** * Filters the publisher logo size. * * @since 1.12.0 * * @param array $size { * Publisher logo size. * * Array of image data, or empty array if no image is available. * * @type int $1 Image width in pixels. * @type int $2 Image height in pixels. * } * @param int|null $id Story ID if available. */ return apply_filters( 'web_stories_publisher_logo_size', $this->publisher_logo_size, $this->id ); } /** * Get poster portrait size. * * @since 1.23.0 * * @return array { * Poster portrait logo size. * * Array of image data, or empty array if no image is available. * * @type int $1 Image width in pixels. * @type int $2 Image height in pixels. * } * * @phpstan-return array{0: int, 1: int}|array{} */ public function get_poster_portrait_size(): array { return $this->poster_portrait_size; } /** * Get product data. * * @since 1.26.0 * * @return Product[] */ public function get_products(): array { return $this->products; } } ================================================ FILE: includes/Page_Template_Post_Type.php ================================================ story_post_type = $story_post_type; } /** * Init */ public function register(): void { parent::register(); add_action( 'delete_post', [ $this, 'delete_poster_image' ] ); } /** * Get post type slug. * * @since 1.14.0 */ public function get_slug(): string { return self::POST_TYPE_SLUG; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Deletes the associated featured image when a page template is deleted. * * This prevents the featured image from becoming an orphan because it is not * displayed anywhere in WordPress or the story editor. * * @since 1.14.0 * * @param int $post_id Post ID. */ public function delete_poster_image( int $post_id ): void { if ( get_post_type( $post_id ) !== $this->get_slug() ) { return; } $thumbnail_id = get_post_thumbnail_id( $post_id ); if ( $thumbnail_id ) { wp_delete_attachment( $thumbnail_id, true ); } } /** * Registers the post type for page templates. * * @since 1.14.0 * * @return array Post type args. * * @phpstan-return PostTypeArgs */ protected function get_args(): array { /** * The edit_posts capability. * * @var string $edit_posts */ $edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' ); /** * The delete_posts capability. * * @var string $delete_posts */ $delete_posts = $this->story_post_type->get_cap_name( 'delete_posts' ); $capabilities = [ 'edit_post' => $edit_posts, 'read_post' => $edit_posts, 'delete_post' => $delete_posts, 'edit_posts' => $edit_posts, 'edit_others_posts' => $edit_posts, 'delete_posts' => $delete_posts, 'publish_posts' => $edit_posts, 'read_private_posts' => $edit_posts, 'delete_private_posts' => $delete_posts, 'delete_published_posts' => $delete_posts, 'delete_others_posts' => $delete_posts, 'edit_private_posts' => $edit_posts, 'edit_published_posts' => $edit_posts, 'create_posts' => $edit_posts, ]; return [ 'labels' => [ 'name' => _x( 'Page Templates', 'post type general name', 'web-stories' ), 'singular_name' => _x( 'Page Template', 'post type singular name', 'web-stories' ), 'add_new' => __( 'Add New Page Template', 'web-stories' ), 'add_new_item' => __( 'Add New Page Template', 'web-stories' ), 'edit_item' => __( 'Edit Page Template', 'web-stories' ), 'new_item' => __( 'New Page Template', 'web-stories' ), 'view_item' => __( 'View Page Template', 'web-stories' ), 'view_items' => __( 'View Page Templates', 'web-stories' ), 'search_items' => __( 'Search Page Templates', 'web-stories' ), 'not_found' => __( 'No page templates found.', 'web-stories' ), 'not_found_in_trash' => __( 'No page templates found in Trash.', 'web-stories' ), 'all_items' => __( 'All Page Templates', 'web-stories' ), 'archives' => __( 'Page Template Archives', 'web-stories' ), 'attributes' => __( 'Page Template Attributes', 'web-stories' ), 'insert_into_item' => __( 'Insert into page template', 'web-stories' ), 'uploaded_to_this_item' => __( 'Uploaded to this page template', 'web-stories' ), 'featured_image' => _x( 'Featured Image', 'page template', 'web-stories' ), 'set_featured_image' => _x( 'Set featured image', 'page template', 'web-stories' ), 'remove_featured_image' => _x( 'Remove featured image', 'page template', 'web-stories' ), 'use_featured_image' => _x( 'Use as featured image', 'page template', 'web-stories' ), 'filter_by_date' => __( 'Filter by date', 'web-stories' ), 'filter_items_list' => __( 'Filter page templates list', 'web-stories' ), 'items_list_navigation' => __( 'Page Templates list navigation', 'web-stories' ), 'items_list' => __( 'Page Templates list', 'web-stories' ), 'item_published' => __( 'Page Template published.', 'web-stories' ), 'item_published_privately' => __( 'Page Template published privately.', 'web-stories' ), 'item_reverted_to_draft' => __( 'Page Template reverted to draft.', 'web-stories' ), 'item_scheduled' => __( 'Page Template scheduled', 'web-stories' ), 'item_updated' => __( 'Page Template updated.', 'web-stories' ), 'menu_name' => _x( 'Page Templates', 'admin menu', 'web-stories' ), 'name_admin_bar' => _x( 'Page Template', 'add new on admin bar', 'web-stories' ), 'item_link' => _x( 'Page Template Link', 'navigation link block title', 'web-stories' ), 'item_link_description' => _x( 'A link to a page template.', 'navigation link block description', 'web-stories' ), 'item_trashed' => __( 'Page Template trashed.', 'web-stories' ), ], 'supports' => [ 'title', 'thumbnail', // Used for preview images in the editor. ], 'capabilities' => $capabilities, 'rewrite' => false, 'public' => false, 'show_ui' => false, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'rest_controller_class' => Page_Template_Controller::class, ]; } } ================================================ FILE: includes/Plugin.php ================================================ => * associations. */ public const SERVICES = [ 'activation_notice' => Admin\Activation_Notice::class, 'admin.google_fonts' => Admin\Google_Fonts::class, 'amp_output_buffer' => Output_Buffer::class, 'amp_story_player_assets' => AMP_Story_Player_Assets::class, 'adsense' => AdSense::class, 'ad_manager' => Ad_Manager::class, 'mgid' => Mgid::class, 'admin' => Admin\Admin::class, 'analytics' => Analytics::class, 'coi' => Admin\Cross_Origin_Isolation::class, 'customizer' => Admin\Customizer::class, 'dashboard' => Admin\Dashboard::class, 'database_upgrader' => Database_Upgrader::class, 'discovery' => Discovery::class, 'editor' => Admin\Editor::class, 'embed_shortcode' => Shortcode\Embed_Shortcode::class, 'experiments' => Experiments::class, 'integrations.amp' => Integrations\AMP::class, 'integrations.ezoic' => Integrations\Ezoic::class, 'integrations.jetpack' => Integrations\Jetpack::class, 'integrations.newrelic' => Integrations\New_Relic::class, 'integrations.nextgen_gallery' => Integrations\NextGen_Gallery::class, 'integrations.cfi' => Integrations\Conditional_Featured_Image::class, 'integrations.sitekit' => Integrations\Site_Kit::class, 'integrations.themes_support' => Integrations\Core_Themes_Support::class, 'integrations.shortpixel' => Integrations\ShortPixel::class, 'kses' => KSES::class, 'font_post_type' => Font_Post_Type::class, 'page_template_post_type' => Page_Template_Post_Type::class, 'plugin_row_meta' => Admin\PluginRowMeta::class, 'plugin_action_links' => Admin\PluginActionLinks::class, 'product_meta' => Shopping\Product_Meta::class, 'media.base_color' => Media\Base_Color::class, 'media.blurhash' => Media\Blurhash::class, 'media.image_sizes' => Media\Image_Sizes::class, 'media.media_source' => Media\Media_Source_Taxonomy::class, 'media.video.captions' => Media\Video\Captions::class, 'media.cropping' => Media\Cropping::class, 'media.video.muting' => Media\Video\Muting::class, 'media.video.optimization' => Media\Video\Optimization::class, 'media.video.poster' => Media\Video\Poster::class, 'media.video.trimming' => Media\Video\Trimming::class, 'media.video.is_gif' => Media\Video\Is_Gif::class, 'meta_boxes' => Admin\Meta_Boxes::class, 'settings' => Settings::class, 'site_health' => Admin\Site_Health::class, 'story_archive' => Story_Archive::class, 'story_post_type' => Story_Post_Type::class, 'story_revisions' => Story_Revisions::class, 'story_shortcode' => Shortcode\Stories_Shortcode::class, 'svg' => Media\SVG::class, 'tracking' => Tracking::class, 'tinymce' => Admin\TinyMCE::class, 'register.widget' => Register_Widget::class, 'renderer.archives' => Renderer\Archives::class, 'renderer.single' => Renderer\Single::class, 'renderer.oembed' => Renderer\Oembed::class, 'renderer.feed' => Renderer\Feed::class, 'user.capabilities' => User\Capabilities::class, 'rest.embed_controller' => REST_API\Embed_Controller::class, 'rest.link_controller' => REST_API\Link_Controller::class, 'rest.hotlinking_controller' => REST_API\Hotlinking_Controller::class, 'rest.products' => REST_API\Products_Controller::class, 'rest.publisher_logos' => REST_API\Publisher_Logos_Controller::class, 'rest.status_check_controller' => REST_API\Status_Check_Controller::class, 'rest.stories_lock' => REST_API\Stories_Lock_Controller::class, 'rest.media' => REST_API\Stories_Media_Controller::class, 'rest.settings' => REST_API\Stories_Settings_Controller::class, 'rest.users' => REST_API\Stories_Users_Controller::class, 'rest.taxonomies' => REST_API\Stories_Taxonomies_Controller::class, 'taxonomy.category' => Taxonomy\Category_Taxonomy::class, 'taxonomy.tag' => Taxonomy\Tag_Taxonomy::class, 'user_preferences' => User\Preferences::class, 'remove_transients' => Remove_Transients::class, 'web_stories_block' => Block\Web_Stories_Block::class, ]; /** * Get the list of services to register. * * The services array contains a map of => * associations. * * @since 1.6.0 * * @return array Associative array of identifiers mapped to fully * qualified class names. */ protected function get_service_classes(): array { return self::SERVICES; } /** * Get the shared instances for the dependency injector. * * The shared instances array contains a list of FQCNs that are meant to be * reused. For multiple "make()" requests, the injector will return the same * instance reference for these, instead of always returning a new one. * * This effectively turns these FQCNs into a "singleton", without incurring * all the drawbacks of the Singleton design anti-pattern. * * @since 1.6.0 * * @return array Array of fully qualified class names. */ protected function get_shared_instances(): array { return [ Admin\Customizer::class, Admin\Google_Fonts::class, Admin\Meta_Boxes::class, Analytics::class, Assets::class, Context::class, Decoder::class, Experiments::class, Story_Post_Type::class, Injector::class, Integrations\Plugin_Status::class, Integrations\Site_Kit::class, Integrations\WooCommerce::class, Media\Types::class, Shopping_Vendors::class, Locale::class, Settings::class, Stories_Script_Data::class, User\Preferences::class, ]; } /** * Get the delegations for the dependency injector. * * The delegations array contains a map of => * mappings. * * The is basically a factory to provide custom instantiation * logic for the given . * * @since 1.6.0 * * @return array Associative array of callables. */ protected function get_delegations(): array { return [ Injector::class => static fn() => Services::get( 'injector' ), ]; } } ================================================ FILE: includes/PluginFactory.php ================================================ , * map_meta_cap?: bool, * supports?: array>, * register_meta_box_cb?: callable, * taxonomies?: string[], * has_archive?: bool|string, * rewrite?: bool|array{ * slug?: string, * with_front?: bool, * feeds?: bool, * pages?: bool, * ep_mask?: int * }, * query_var?: string|bool, * can_export?: bool, * delete_with_user?: bool, * template?: array * }>, * template_lock?: string|false, * _builtin?: bool, * _edit_link?: string * } */ abstract class Post_Type_Base extends Service_Base implements PluginActivationAware, PluginDeactivationAware, SiteInitializationAware, PluginUninstallAware { /** * Default REST Namespace. */ public const REST_NAMESPACE = 'web-stories/v1'; /** * Registers the post type. * * @since 1.14.0 */ public function register(): void { $this->register_post_type(); } /** * Register post type. * * @since 1.14.0 * * @return WP_Post_Type|WP_Error */ public function register_post_type() { return register_post_type( $this->get_slug(), $this->get_args() ); } /** * Unregister post type. * * @since 1.14.0 */ public function unregister_post_type(): void { unregister_post_type( $this->get_slug() ); } /** * Act on site initialization. * * @since 1.14.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { $this->register_post_type(); } /** * Act on plugin activation. * * @since 1.14.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->register_post_type(); } /** * Act on plugin deactivation. * * @since 1.14.0 * * @param bool $network_wide Whether the deactivation was done network-wide. */ public function on_plugin_deactivation( bool $network_wide ): void { $this->unregister_post_type(); } /** * Post type slug. * * @since 1.14.0 * * @phpstan-return lowercase-string&non-empty-string */ abstract public function get_slug(): string; /** * Get REST base name based on the post type. * * @since 1.14.0 * * @return string REST base. */ public function get_rest_base(): string { $post_type_object = $this->get_object(); $rest_base = $this->get_slug(); if ( $post_type_object instanceof WP_Post_Type ) { $rest_base = ! empty( $post_type_object->rest_base ) && \is_string( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; } return (string) $rest_base; } /** * Get REST namespace on the post type. * * @since 1.14.0 * * @return string REST base. */ public function get_rest_namespace(): string { $post_type_object = $this->get_object(); $rest_namespace = isset( $post_type_object, $post_type_object->rest_namespace ) && \is_string( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : self::REST_NAMESPACE; return (string) $rest_namespace; } /** * Get REST url for post type. * * @since 1.14.0 * * @return string REST base. */ public function get_rest_url(): string { return rest_get_route_for_post_type_items( $this->get_slug() ); } /** * Returns all capabilities for the post type. * * @since 1.29.0 * * @return string[] The post type capabilities. */ public function get_caps(): array { $post_type_obj = $this->get_object(); if ( ! $post_type_obj instanceof WP_Post_Type ) { return []; } return (array) $post_type_obj->cap; } /** * Determines whether the current user has a specific capability for this post type. * * @since 1.14.0 * * @param string $cap Capability name. * @return bool Whether the user has the capability. */ public function has_cap( string $cap ): bool { $capability_name = $this->get_cap_name( $cap ); $capability = false; if ( $capability_name ) { $capability = current_user_can( $capability_name ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined } return $capability; } /** * Returns a specific post type capability name. * * @since 1.14.0 * * @param string $cap Capability name. * @return string|false Capability name if found, false otherwise. */ public function get_cap_name( string $cap ) { $post_type_obj = $this->get_object(); $capability_name = false; if ( ! $post_type_obj instanceof WP_Post_Type ) { return $capability_name; } if ( property_exists( $post_type_obj->cap, $cap ) ) { $capability_name = $post_type_obj->cap->$cap; } return $capability_name; } /** * Get Label on the post type slug and name. * * @since 1.14.0 * * @param string $label Label name. */ public function get_label( string $label ): string { $post_type_obj = $this->get_object(); $name = ''; if ( ! $post_type_obj instanceof WP_Post_Type ) { return $name; } if ( property_exists( $post_type_obj->labels, $label ) ) { $name = $post_type_obj->labels->$label; } return $name; } /** * Get rest controller on the post type slug. * * @since 1.14.0 * * @return WP_REST_Posts_Controller|WP_REST_Controller */ public function get_parent_controller(): WP_REST_Controller { $post_type_obj = $this->get_object(); $parent_controller = null; if ( $post_type_obj instanceof WP_Post_Type ) { $parent_controller = $post_type_obj->get_rest_controller(); } if ( ! $parent_controller ) { $parent_controller = new WP_REST_Posts_Controller( $this->get_slug() ); } return $parent_controller; } /** * Retrieves the permalink for a post type archive. * * Identical to {@see get_post_type_archive_link()}, but also returns a URL * if the archive page has been disabled. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.14.0 * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * * @param bool $ignore_has_archive Ignore 'has_archive' value to get default permalink. * @return string|false The post type archive permalink. False if the post type does not exist. */ public function get_archive_link( bool $ignore_has_archive = false ) { global $wp_rewrite; $post_type_obj = $this->get_object(); if ( ! $post_type_obj instanceof WP_Post_Type ) { return false; } if ( get_option( 'permalink_structure' ) && \is_array( $post_type_obj->rewrite ) ) { $struct = true === $post_type_obj->has_archive || $ignore_has_archive ? $post_type_obj->rewrite['slug'] : $post_type_obj->has_archive; if ( $post_type_obj->rewrite['with_front'] ) { $struct = $wp_rewrite->front . $struct; } else { $struct = $wp_rewrite->root . $struct; } $link = home_url( user_trailingslashit( $struct, 'post_type_archive' ) ); } else { $link = home_url( '?post_type=' . $this->get_slug() ); } /** This filter is documented in wp-includes/link-template.php */ return apply_filters( 'post_type_archive_link', $link, $this->get_slug() ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts -- False positive. $cpt_posts = get_posts( [ 'fields' => 'ids', 'suppress_filters' => false, 'post_status' => 'any', 'post_type' => $this->get_slug(), 'posts_per_page' => -1, ] ); foreach ( $cpt_posts as $post_id ) { wp_delete_post( (int) $post_id, true ); } } /** * Post type args. * * @since 1.14.0 * * @return array Post type args. * * @phpstan-return PostTypeArgs */ abstract protected function get_args(): array; /** * Get post type object. * * @since 1.14.0 */ protected function get_object(): ?WP_Post_Type { return get_post_type_object( $this->get_slug() ); } } ================================================ FILE: includes/REST_API/Embed_Controller.php ================================================ * } */ class Embed_Controller extends REST_Controller implements HasRequirements { /** * Story_Post_Type instance. * * @var Story_Post_Type Story_Post_Type instance. */ private Story_Post_Type $story_post_type; /** * Constructor. * * @param Story_Post_Type $story_post_type Story_Post_Type instance. */ public function __construct( Story_Post_Type $story_post_type ) { $this->story_post_type = $story_post_type; $this->namespace = 'web-stories/v1'; $this->rest_base = 'embed'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers routes for links. * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_proxy_item' ], 'permission_callback' => [ $this, 'get_proxy_item_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL for which to fetch embed data.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', ], ], ], ] ); } /** * Callback for the Web Stories embed endpoints. * * Returns information about the given story. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.0.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_proxy_item( $request ) { /** * Requested URL. * * @var string $url */ $url = $request['url']; $url = urldecode( untrailingslashit( $url ) ); if ( empty( $url ) ) { return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } /** * Filters the link data TTL value. * * @since 1.0.0 * * @param int $time Time to live (in seconds). Default is 1 day. * @param string $url The attempted URL. */ $cache_ttl = apply_filters( 'web_stories_embed_data_cache_ttl', DAY_IN_SECONDS, $url ); $cache_key = 'web_stories_embed_data_' . md5( $url ); $data = get_transient( $cache_key ); if ( \is_string( $data ) && ! empty( $data ) ) { /** * Decoded cached embed data. * * @var array|null $embed */ $embed = json_decode( $data, true ); if ( $embed ) { $response = $this->prepare_item_for_response( $embed, $request ); return rest_ensure_response( $response ); } } $data = $this->get_data_from_post( $url ); if ( $data ) { $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } $args = [ 'limit_response_size' => 153_600, // 150 KB. 'timeout' => 7, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout ]; /** * Filters the HTTP request args for link data retrieval. * * Can be used to adjust timeout and response size limit. * * @since 1.0.0 * * @param array $args Arguments used for the HTTP request * @param string $url The attempted URL. * * @phpstan-param array{ * method?: string, * timeout?: float, * redirection?: int, * httpversion?: string, * user-agent?: string, * reject_unsafe_urls?: bool, * blocking?: bool, * headers?: string|array, * cookies?: array, * body?: string|array, * compress?: bool, * decompress?: bool, * sslverify?: bool, * sslcertificates?: string, * stream?: bool, * filename?: string, * limit_response_size?: int, * } $args */ $args = apply_filters( 'web_stories_embed_data_request_args', $args, $url ); $response = wp_safe_remote_get( $url, $args ); if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) { // Not saving the error response to cache since the error might be temporary. return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } $html = wp_remote_retrieve_body( $response ); if ( ! $html ) { return new \WP_Error( 'rest_invalid_story', __( 'URL is not a story', 'web-stories' ), [ 'status' => 404 ] ); } $data = $this->get_data_from_document( $html ); if ( ! $data ) { return new \WP_Error( 'rest_invalid_story', __( 'URL is not a story', 'web-stories' ), [ 'status' => 404 ] ); } $response = $this->prepare_item_for_response( $data, $request ); set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); return rest_ensure_response( $response ); } /** * Prepares a single embed output for response. * * @since 1.10.0 * * @param array|false $embed Embed value, default to false is not set. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $embed, $request ) { $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $data = []; if ( \is_array( $embed ) ) { $check_fields = array_keys( $embed ); foreach ( $check_fields as $check_field ) { if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) { $data[ $check_field ] = rest_sanitize_value_from_schema( $embed[ $check_field ], $schema['properties'][ $check_field ] ); } } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the link's schema, conforming to JSON Schema. * * @since 1.10.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'embed', 'type' => 'object', 'properties' => [ 'title' => [ 'description' => __( 'Embed\'s title', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], 'poster' => [ 'description' => __( 'Embed\'s image', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if current user can process links. * * @since 1.0.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_proxy_item_permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to make proxied embed requests.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Retrieves the story metadata for a given URL on the current site. * * @since 1.0.0 * * @param string $url The URL that should be inspected for metadata. * @return array{title: string, poster: string}|false Story metadata if the URL does belong to the current site. False otherwise. */ private function get_data_from_post( string $url ) { $post = $this->url_to_post( $url ); if ( ! $post || $this->story_post_type->get_slug() !== $post->post_type ) { return false; } return $this->get_data_from_document( $post->post_content ); } /** * Examines a URL and try to determine the post it represents. * * Checks are supposedly from the hosted site blog. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.2.0 * * @see get_oembed_response_data_for_url * @see url_to_postid * * @param string $url Permalink to check. * @return WP_Post|null Post object on success, null otherwise. */ private function url_to_post( $url ): ?WP_Post { $post = null; $switched_blog = $this->maybe_switch_site( $url ); if ( \function_exists( 'wpcom_vip_url_to_postid' ) ) { $post_id = wpcom_vip_url_to_postid( $url ); } else { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions $post_id = url_to_postid( $url ); } if ( $post_id ) { $post = get_post( $post_id ); } if ( ! $post_id ) { // url_to_postid() does not recognize plain permalinks like https://example.com/?web-story=my-story. // Let's check for that ourselves. /** * The URL's hostname. * * @var string|false|null $url_host */ $url_host = wp_parse_url( $url, PHP_URL_HOST ); if ( $url_host ) { $url_host = str_replace( 'www.', '', $url_host ); } /** * The home URL's hostname. * * @var string|false|null $home_url_host */ $home_url_host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( $home_url_host ) { $home_url_host = str_replace( 'www.', '', $home_url_host ); } if ( $url_host && $home_url_host && $url_host === $home_url_host ) { $values = []; if ( preg_match( '#[?&](' . preg_quote( $this->story_post_type->get_slug(), '#' ) . ')=([^&]+)#', $url, $values ) ) { $slug = $values[2]; if ( \function_exists( 'wpcom_vip_get_page_by_path' ) ) { $post = wpcom_vip_get_page_by_path( $slug, OBJECT, $this->story_post_type->get_slug() ); } else { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions $post = get_page_by_path( $slug, OBJECT, $this->story_post_type->get_slug() ); } } } } if ( $switched_blog ) { restore_current_blog(); } if ( ! $post instanceof WP_Post ) { return null; } return $post; } /** * Maybe switch to site. * * @since 1.29.0 * * @param string $url Permalink to check. */ private function maybe_switch_site( $url ): bool { if ( ! is_multisite() ) { return false; } $switched_blog = false; /** * URL parts. * * @var array|false $url_parts */ $url_parts = wp_parse_url( $url ); if ( ! $url_parts ) { $url_parts = []; } $url_parts = wp_parse_args( $url_parts, [ 'host' => '', 'path' => '/', ] ); $qv = [ 'domain' => $url_parts['host'], 'path' => '/', 'number' => 1, 'update_site_cache' => false, 'update_site_meta_cache' => false, ]; // In case of subdirectory configs, set the path. if ( ! is_subdomain_install() ) { // Get "sub-site" part of "http://example.org/sub-site/web-stories/my-story/". // But given just "http://example.org/web-stories/my-story/", don't treat "web-stories" as site path. // This differs from the logic in get_oembed_response_data_for_url() which does not do this. // TODO: Investigate possible core bug in get_oembed_response_data_for_url()? $path = explode( '/', ltrim( $url_parts['path'], '/' ) ); $path = \count( $path ) > 2 ? reset( $path ) : false; $network = get_network(); if ( $path && $network instanceof WP_Network ) { $qv['path'] = $network->path . $path . '/'; } } $sites = (array) get_sites( $qv ); $site = reset( $sites ); if ( $site && get_current_blog_id() !== (int) $site->blog_id ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog switch_to_blog( (int) $site->blog_id ); $switched_blog = true; } return $switched_blog; } /** * Parses an HTML document to and returns the story's title and poster. * * @since 1.0.0 * * @param string $html HTML document markup. * @return array{title: string, poster: string}|false Response data or false if document is not a story. */ private function get_data_from_document( string $html ) { try { $doc = Document::fromHtml( $html ); } catch ( \DOMException $exception ) { return false; } if ( ! $doc ) { return false; } /** * List of elements. * * @var DOMNodeList|false $amp_story */ $amp_story = $doc->xpath->query( '//amp-story' ); if ( ! $amp_story instanceof DOMNodeList || 0 === $amp_story->length ) { return false; } $title = $this->get_dom_attribute_content( $amp_story, 'title' ); $poster = $this->get_dom_attribute_content( $amp_story, 'poster-portrait-src' ); return [ 'title' => $title ?: '', 'poster' => $poster ?: '', ]; } /** * Retrieve content of a given DOM node attribute. * * @since 1.0.0 * * @param DOMNodeList|false $query XPath query result. * @param string $attribute Attribute name. * @return string|false Attribute content on success, false otherwise. */ protected function get_dom_attribute_content( $query, string $attribute ) { if ( ! $query instanceof DOMNodeList || 0 === $query->length ) { return false; } /** * DOMElement * * @var DOMElement|DOMNode $node */ $node = $query->item( 0 ); if ( ! $node instanceof DOMElement ) { return false; } return $node->getAttribute( $attribute ); } } ================================================ FILE: includes/REST_API/Font_Controller.php ================================================ , * styles?: string[], * variants?: string[], * service?: string, * metrics?: mixed, * id?: string, * url?: string * } * @phpstan-type SchemaEntry array{ * description: string, * type: string, * context: string[], * default?: mixed, * } * @phpstan-type Schema array{ * properties: array{ * family?: SchemaEntry, * fallbacks?: SchemaEntry, * weights?: SchemaEntry, * styles?: SchemaEntry, * variants?: SchemaEntry, * service?: SchemaEntry, * metrics?: SchemaEntry, * id?: SchemaEntry, * url?: SchemaEntry * } * } */ class Font_Controller extends WP_REST_Posts_Controller { /** * Registers the routes for posts. * * @since 1.16.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], 'args' => $this->get_collection_params(), ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'create_item_permissions_check' ], 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __( 'Unique identifier for the font.', 'web-stories' ), 'type' => 'integer', ], ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'delete_item_permissions_check' ], ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); } /** * Retrieves a collection of fonts. * * @since 1.16.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function get_items( $request ) { /** * Fonts list. * * @phpstan-var Font[] $fonts */ $fonts = []; // Retrieve the list of registered collection query parameters. $registered = $this->get_collection_params(); if ( isset( $registered['service'], $request['service'] ) ) { if ( 'all' === $request['service'] || 'builtin' === $request['service'] ) { array_push( $fonts, ...$this->get_builtin_fonts() ); // For custom fonts the searching will be done in WP_Query already. if ( isset( $registered['search'], $request['search'] ) && ! empty( $request['search'] ) ) { $fonts = array_values( array_filter( $fonts, /** * Font data. * * @param array{family: string} $font * @return bool */ static fn( array $font ) => false !== stripos( $font['family'], $request['search'] ) ) ); } } if ( 'all' === $request['service'] || 'custom' === $request['service'] ) { array_push( $fonts, ...$this->get_custom_fonts( $request ) ); } // Filter before doing any sorting. if ( isset( $registered['include'], $request['include'] ) && ! empty( $request['include'] ) ) { $include_list = array_map( 'strtolower', $request['include'] ); $fonts = array_values( array_filter( $fonts, /** * Font data. * * @param array{family: string} $font * @return bool */ static fn( array $font ): bool => \in_array( strtolower( $font['family'] ), $include_list, true ) ) ); } if ( 'all' === $request['service'] ) { // Since the built-in fonts and custom fonts both are already sorted, // we only need to sort when including both. usort( $fonts, /** * Font A and Font B. * * @phpstan-param Font $a * @phpstan-param Font $b * @return int */ static fn( array $a, array $b ): int => strnatcasecmp( $a['family'], $b['family'] ) ); } } return rest_ensure_response( $fonts ); } /** * Checks if a given request has access to read posts. * * @since 1.16.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_items_permissions_check( $request ) { $post_type = get_post_type_object( $this->post_type ); if ( ! $post_type || ! current_user_can( $post_type->cap->read_post ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to list fonts.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Force-deletes a single font. * * @since 1.16.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function delete_item( $request ) { $request->set_param( 'force', true ); return parent::delete_item( $request ); } /** * Prepares a single post output for response. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.16.0 * * @param WP_Post $item Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $item, $request ): WP_REST_Response { // Restores the more descriptive, specific name for use within this method. $post = $item; $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited setup_postdata( $post ); $fields = $this->get_fields_for_response( $request ); $data = []; if ( rest_is_field_included( 'id', $fields ) ) { $data['id'] = $post->ID; } if ( rest_is_field_included( 'family', $fields ) ) { $data['family'] = $post->post_title; } if ( rest_is_field_included( 'service', $fields ) ) { $data['service'] = 'custom'; } /** * Font data. * * @var array|null $font_data */ $font_data = json_decode( $post->post_content, true ); if ( $font_data ) { foreach ( $font_data as $key => $value ) { if ( rest_is_field_included( $key, $fields ) ) { $data[ $key ] = $value; } } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); /** * Response object. * * @var WP_REST_Response $response */ $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { $links = $this->prepare_links( $post ); $response->add_links( $links ); if ( ! empty( $links['self']['href'] ) ) { $actions = $this->get_available_actions( $post, $request ); $self = $links['self']['href']; foreach ( $actions as $rel ) { $response->add_link( $rel, $self ); } } } return $response; } /** * Retrieves the query params for the fonts collection. * * @since 1.16.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['context']['default'] = 'view'; $query_params['search'] = [ 'description' => __( 'Limit results to those matching a string.', 'web-stories' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ]; $query_params['include'] = [ 'description' => __( 'Limit result set to specific fonts.', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'string', ], 'default' => [], ]; $query_params['service'] = [ 'description' => __( 'Filter fonts by service.', 'web-stories' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'all', 'enum' => [ 'all', 'custom', 'builtin', // system + fonts.google.com. ], ]; /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ return apply_filters( "rest_{$this->post_type}_collection_params", $query_params, $this->post_type ); } /** * Retrieves the font's schema, conforming to JSON Schema. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.16.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => $this->post_type, 'type' => 'object', // Base properties for every font. 'properties' => [ 'family' => [ 'description' => __( 'The font family', 'web-stories' ), 'type' => [ 'string', 'null' ], 'context' => [ 'view', 'edit', 'embed' ], 'required' => true, ], 'fallbacks' => [ 'description' => __( 'Fallback fonts', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'string', ], 'context' => [ 'view', 'edit' ], 'required' => true, ], 'weights' => [ 'description' => __( 'Font weights', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 900, ], 'minimum' => 1, 'context' => [ 'view', 'edit' ], 'required' => true, ], 'styles' => [ 'description' => __( 'Font styles', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'string', ], 'minimum' => 1, 'context' => [ 'view', 'edit' ], 'required' => true, ], 'variants' => [ 'description' => __( 'Font variants', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'array', 'items' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 900, ], 'minimum' => 2, 'maximum' => 2, ], 'context' => [ 'view', 'edit' ], 'required' => true, ], 'service' => [ 'description' => __( 'Font service', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], 'metrics' => [ 'description' => __( 'Font metrics', 'web-stories' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'required' => true, ], 'id' => [ 'description' => __( 'Unique identifier for the font.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'url' => [ 'description' => __( 'Font URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], 'required' => true, ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Returns a list of Google fonts. * * @since 1.16.0 * * @return array List of Google fonts. * * @phpstan-return Font[] */ protected function get_builtin_fonts(): array { $file = WEBSTORIES_PLUGIN_DIR_PATH . 'includes/data/fonts/fonts.json'; if ( ! is_readable( $file ) ) { return []; } $content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown if ( ! $content ) { return []; } /** * List of Google Fonts. * * @var array|null $fonts * @phpstan-var Font[]|null $fonts */ $fonts = json_decode( $content, true ); if ( ! $fonts ) { return []; } return $fonts; } /** * Returns a list of custom fonts. * * @since 1.16.0 * * @param WP_REST_Request $request Full details about the request. * @return array List of custom fonts. * * @phpstan-return Font[] */ protected function get_custom_fonts( $request ): array { // Retrieve the list of registered collection query parameters. $registered = $this->get_collection_params(); $args = [ 'orderby' => 'title', 'order' => 'ASC', ]; /* * This array defines mappings between public API query parameters whose * values are accepted as-passed, and their internal WP_Query parameter * name equivalents (some are the same). Only values which are also * present in $registered will be set. */ $parameter_mappings = [ 'search' => 's', ]; /* * For each known parameter which is both registered and present in the request, * set the parameter's value on the query $args. */ foreach ( $parameter_mappings as $api_param => $wp_param ) { if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { $args[ $wp_param ] = $request[ $api_param ]; } } // Ensure our per_page parameter overrides any provided posts_per_page filter. if ( isset( $registered['per_page'] ) ) { $args['posts_per_page'] = $request['per_page']; } // Force search to be case-insensitive. // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; $query_args = $this->prepare_items_query( $args, $request ); $posts_query = new WP_Query(); $query_result = $posts_query->query( $query_args ); /** * List of custom fonts. * * @var array $posts * @phpstan-var Font[] $posts */ $posts = []; /** * We're expecting a post object. * * @var WP_Post $post */ foreach ( $query_result as $post ) { if ( ! $this->check_read_permission( $post ) ) { continue; } $data = $this->prepare_item_for_response( $post, $request ); $posts[] = $this->prepare_response_for_collection( $data ); } // Reset filter. if ( 'edit' === $request['context'] ) { remove_filter( 'post_password_required', [ $this, 'check_password_required' ] ); } /** * List of custom fonts. * * @phpstan-var Font[] $posts */ return $posts; } /** * Prepares a single post for create. * * @since 1.16.0 * * @param WP_REST_Request $request Request object. * @return stdClass|WP_Error Post object or WP_Error. */ protected function prepare_item_for_database( $request ) { $prepared_post = new stdClass(); $prepared_post->post_status = 'publish'; $font_data = []; $fields = [ 'family', 'fallbacks', 'weights', 'styles', 'variants', 'metrics', 'url', ]; $schema = $this->get_item_schema(); foreach ( $fields as $field ) { if ( ! empty( $schema['properties'][ $field ] ) && ! empty( $request[ $field ] ) ) { $font_data[ $field ] = $request[ $field ]; if ( 'family' === $field ) { /** * Request data. * * @var array{family: string} $request */ $font_family = trim( $request['family'] ); $prepared_post->post_title = $font_family; if ( $this->font_exists( $font_family ) ) { return new \WP_Error( 'rest_invalid_field', __( 'A font with this name already exists', 'web-stories' ), [ 'status' => 400 ] ); } } } } $prepared_post->post_content = wp_json_encode( $font_data ); return $prepared_post; } /** * Determines whether a font with the same name already exists. * * Performs a case-insensitive comparison. * * @since 1.16.0 * * @param string $font_family Font family. * @return bool Whether a font with this exact name already exists. */ private function font_exists( string $font_family ): bool { /** * A custom request to perform the lookup. * * @phpstan-var WP_REST_Request}> $request */ $request = new WP_REST_Request( WP_REST_Server::READABLE, $this->namespace . '/' . $this->rest_base ); $request->set_param( 'include', [ $font_family ] ); $request->set_param( 'service', 'all' ); /** * Response object. * * @var WP_REST_Response $response */ $response = $this->get_items( $request ); return ! empty( $response->get_data() ); } } ================================================ FILE: includes/REST_API/Hotlinking_Controller.php ================================================ , * cookies?: array, * body?: string|string[], * compress?: bool, * decompress?: bool, * sslverify?: bool, * sslcertificates?: string, * stream?: bool, * filename?: string, * limit_response_size?: int * } */ class Hotlinking_Controller extends REST_Controller implements HasRequirements { public const PROXY_HEADERS_ALLOWLIST = [ 'Content-Type', 'Cache-Control', 'Etag', 'Last-Modified', 'Content-Range', ]; /** * Story_Post_Type instance. * * @var Story_Post_Type Story_Post_Type instance. */ private Story_Post_Type $story_post_type; /** * Types instance. * * @var Types Types instance. */ private Types $types; /** * File pointer resource. * * @var resource */ protected $stream_handle; /** * Constructor. * * @param Story_Post_Type $story_post_type Story_Post_Type instance. * @param Types $types Types instance. * @return void */ public function __construct( Story_Post_Type $story_post_type, Types $types ) { $this->story_post_type = $story_post_type; $this->types = $types; $this->namespace = 'web-stories/v1'; $this->rest_base = 'hotlink'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers routes for urls. * * @since 1.11.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/validate', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'parse_url' ], 'permission_callback' => [ $this, 'parse_url_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL to process.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', 'validate_callback' => [ $this, 'validate_callback' ], 'sanitize_callback' => 'esc_url_raw', ], ], ], ] ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/proxy', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'proxy_url' ], 'permission_callback' => [ $this, 'parse_url_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL to process.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', 'validate_callback' => [ $this, 'validate_callback' ], 'sanitize_callback' => 'esc_url_raw', ], ], ], ] ); } /** * Parses a URL to return some metadata for inserting external media. * * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.11.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function parse_url( WP_REST_Request $request ) { /** * Requested URL. * * @var string $raw_url */ $raw_url = $request['url']; $raw_url = untrailingslashit( $raw_url ); $url_or_ip = $this->validate_url( $raw_url ); $host = wp_parse_url( $raw_url, PHP_URL_HOST ); if ( ! $url_or_ip || ! $host ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } /** * Filters the hotlinking data TTL value. * * @since 1.11.0 * * @param int $time Time to live (in seconds). Default is 1 day. * @param string $url The attempted URL. */ $cache_ttl = apply_filters( 'web_stories_hotlinking_url_data_cache_ttl', DAY_IN_SECONDS, $raw_url ); $cache_key = 'web_stories_url_data_' . md5( $raw_url ); $data = get_transient( $cache_key ); if ( \is_string( $data ) && ! empty( $data ) ) { /** * Decoded cached link data. * * @var array|null $link * @phpstan-var LinkData|null $link */ $link = json_decode( $data, true ); if ( $link ) { $response = $this->prepare_item_for_response( $link, $request ); return rest_ensure_response( $response ); } } $callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip ); add_action( 'http_api_curl', $callback ); $response = wp_safe_remote_head( $raw_url, [ 'redirection' => 0, // No redirects allowed. 'headers' => [ 'Host' => $host, ], ] ); remove_action( 'http_api_curl', $callback ); if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } $headers = wp_remote_retrieve_headers( $response ); $mime_type = $headers['content-type']; if ( $mime_type && str_contains( $mime_type, ';' ) ) { $pieces = explode( ';', $mime_type ); $mime_type = array_shift( $pieces ); } $file_size = (int) $headers['content-length']; $path = wp_parse_url( $raw_url, PHP_URL_PATH ); if ( ! \is_string( $path ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } $file_name = basename( $path ); $exts = $this->types->get_file_type_exts( [ $mime_type ] ); $ext = ''; if ( $exts ) { $ext = end( $exts ); } $allowed_mime_types = $this->get_allowed_mime_types(); $type = ''; foreach ( $allowed_mime_types as $key => $mime_types ) { if ( \in_array( $mime_type, $mime_types, true ) ) { $type = $key; break; } } $data = [ 'ext' => $ext, 'file_name' => $file_name, 'file_size' => $file_size, 'mime_type' => $mime_type, 'type' => $type, ]; set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } /** * Parses a URL to return proxied file. * * @SuppressWarnings("PHPMD.ErrorControlOperator") * * @since 1.13.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|void Proxied data on success, error otherwise. */ public function proxy_url( WP_REST_Request $request ) { /** * Requested URL. * * @var string $raw_url */ $raw_url = $request['url']; $raw_url = untrailingslashit( $raw_url ); $url_or_ip = $this->validate_url( $raw_url ); $host = wp_parse_url( $raw_url, PHP_URL_HOST ); if ( ! $url_or_ip || ! $host ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } // Remove any relevant headers already set by WP_REST_Server::serve_request() // wp_get_nocache_headers(). if ( ! headers_sent() ) { header_remove( 'Cache-Control' ); header_remove( 'Content-Type' ); header_remove( 'Expires' ); header_remove( 'Last Modified' ); } header( 'Cache-Control: max-age=3600' ); header( 'Accept-Ranges: bytes' ); $args = [ 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout 'blocking' => false, 'headers' => [ 'Range' => $request->get_header( 'Range' ), 'Host' => $host, ], 'redirection' => 0, // No redirects allowed. ]; $callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip ); add_action( 'http_api_curl', $callback ); $http = _wp_http_get_object(); $transport = $http->_get_first_available_transport( $args, $raw_url ); // When cURL is available, we might be able to use it together with fopen(). if ( 'WP_Http_Curl' === $transport ) { // php://temp is a read-write streams that allows temporary data to be stored in a file-like wrapper. // Other than php://memory, php://temp will use a temporary file once the amount of data stored hits a predefined limit (the default is 2 MB). // The location of this temporary file is determined in the same way as the {@see sys_get_temp_dir()} function. if ( WP_DEBUG ) { $stream_handle = fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen } else { $stream_handle = @fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden } if ( $stream_handle ) { $this->stream_handle = $stream_handle; $this->proxy_url_curl( $raw_url, $args ); exit; } } // If either cURL is not available or fopen() did not succeed, // fall back to using whatever else is set up on the site, // presumably WP_Http_Streams or still WP_Http_Curl but without streams. unset( $args['blocking'] ); $this->proxy_url_fallback( $raw_url, $args ); exit; } /** * Prepares response asset response. * * @since 1.11.0 * * @param LinkData|false $link URL data value, default to false is not set. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param LinkData $link * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $link, $request ) { $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $data = []; $error = new WP_Error(); foreach ( $schema['properties'] as $field => $args ) { if ( ! isset( $link[ $field ] ) || ! rest_is_field_included( $field, $fields ) ) { continue; } $check = rest_validate_value_from_schema( $link[ $field ], $args, $field ); if ( is_wp_error( $check ) ) { $error->add( 'rest_invalid_' . $field, $check->get_error_message(), [ 'status' => 400 ] ); continue; } $data[ $field ] = rest_sanitize_value_from_schema( $link[ $field ], $args, $field ); } if ( $error->get_error_codes() ) { return $error; } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the link's schema, conforming to JSON Schema. * * @since 1.11.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $allowed_mime_types = $this->get_allowed_mime_types(); $types = array_keys( $allowed_mime_types ); $allowed_mime_types = array_merge( ...array_values( $allowed_mime_types ) ); $exts = $this->types->get_file_type_exts( $allowed_mime_types ); $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'link', 'type' => 'object', 'properties' => [ 'ext' => [ 'description' => __( 'File extension', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $exts, ], 'file_name' => [ 'description' => __( 'File name', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], 'file_size' => [ 'description' => __( 'File size', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], ], 'mime_type' => [ 'description' => __( 'Mime type', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $allowed_mime_types, ], 'type' => [ 'description' => __( 'Type', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $types, ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if current user can process urls. * * @since 1.11.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function parse_url_permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to insert external media.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Callback to validate urls. * * @since 1.11.0 * * @param string $value Value to be validated. * @return true|WP_Error */ public function validate_callback( $value ) { $url = untrailingslashit( $value ); if ( empty( $url ) || ! $this->validate_url( $url ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! $path ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } return true; } /** * Returns a callback to modify the cURL configuration before the request is executed. * * @since 1.22.1 * * @param string $url URL. * @param string $url_or_ip URL or IP address. */ public function get_curl_resolve_callback( string $url, string $url_or_ip ): callable { /** * CURL configuration callback. * * @param resource $handle The cURL handle returned by curl_init() (passed by reference). */ return static function ( $handle ) use ( $url, $url_or_ip ): void { // Just some safeguard in case cURL is not really available, // despite this method being run in the context of WP_Http_Curl. if ( ! \function_exists( '\curl_setopt' ) ) { return; } if ( $url === $url_or_ip ) { return; } $host = wp_parse_url( $url, PHP_URL_HOST ); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ) ?? 'http'; $port = wp_parse_url( $url, PHP_URL_PORT ) ?? 'http' === $scheme ? 80 : 443; // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt curl_setopt( $handle, CURLOPT_RESOLVE, [ "$host:$port:$url_or_ip", ] ); // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt }; } /** * Modifies the cURL configuration before the request is executed. * * @since 1.15.0 * * @param resource $handle The cURL handle returned by {@see curl_init()} (passed by reference). */ public function modify_curl_configuration( $handle ): void { // Just some safeguard in case cURL is not really available, // despite this method being run in the context of WP_Http_Curl. if ( ! \function_exists( '\curl_setopt' ) ) { return; } // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt curl_setopt( $handle, CURLOPT_FILE, $this->stream_handle ); curl_setopt( $handle, CURLOPT_HEADERFUNCTION, [ $this, 'stream_headers' ] ); // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt } /** * Grabs the headers of the cURL request. * * Each header is sent individually to this callback, * so we take a look at each one to see if we should "forward" it. * * @since 1.15.0 * * @param resource $handle cURL handle. * @param string $header cURL header. * @return int Header length. */ public function stream_headers( $handle, $header ): int { // Parse Status-Line, the first component in the HTTP response, e.g. HTTP/1.1 200 OK. // Extract the status code to re-send that here. if ( str_starts_with( $header, 'HTTP/' ) ) { $status = explode( ' ', $header ); http_response_code( (int) $status[1] ); return \strlen( $header ); } foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) { if ( str_starts_with( strtolower( $header ), strtolower( $_header ) . ': ' ) ) { header( $header, true ); } } return \strlen( $header ); } /** * Proxy a given URL via a PHP read-write stream. * * @since 1.15.0 * * @param string $url Request URL. * @param array $args Request args. * * @phpstan-param HttpArgs $args */ private function proxy_url_curl( string $url, array $args ): void { add_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] ); wp_safe_remote_get( $url, $args ); remove_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] ); rewind( $this->stream_handle ); while ( ! feof( $this->stream_handle ) ) { echo fread( $this->stream_handle, 1024 * 1024 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread } fclose( $this->stream_handle ); } /** * Proxy a given URL by storing in memory. * * @since 1.15.0 * * @param string $url Request URL. * @param array $args Request args. * * @phpstan-param HttpArgs $args */ private function proxy_url_fallback( string $url, array $args ): void { $response = wp_safe_remote_get( $url, $args ); $status = wp_remote_retrieve_response_code( $response ); if ( ! $status ) { http_response_code( 404 ); return; } http_response_code( (int) $status ); $headers = wp_remote_retrieve_headers( $response ); foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) { if ( isset( $headers[ $_header ] ) ) { header( $_header . ': ' . $headers[ $_header ] ); } } echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } /** * Validate a URL for safe use in the HTTP API. * * Like {@see wp_http_validate_url} in core, but with extra hardening * to avoid DNS rebinding issues. * * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.CyclomaticComplexity") * * @since 1.22.0 * * @param string $url Request URL. * @return string|false Original URL, resolved IP address, or false on failure. */ private function validate_url( string $url ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh if ( '' === $url || is_numeric( $url ) ) { return false; } $original_url = $url; $url = wp_kses_bad_protocol( $url, [ 'http', 'https' ] ); if ( ! $url || strtolower( $url ) !== strtolower( $original_url ) ) { return false; } $parsed_url = wp_parse_url( $url ); if ( ! $parsed_url || ! isset( $parsed_url['host'], $parsed_url['scheme'] ) ) { return false; } if ( isset( $parsed_url['user'] ) || isset( $parsed_url['pass'] ) ) { return false; } if ( false !== strpbrk( $parsed_url['host'], ':#?[]' ) ) { return false; } /** * Home URL. * * @var string */ $home_url = get_option( 'home' ); $parsed_home = wp_parse_url( $home_url ); if ( ! $parsed_home ) { return false; } $same_host = isset( $parsed_home['host'] ) && strtolower( $parsed_home['host'] ) === strtolower( $parsed_url['host'] ); $host = trim( $parsed_url['host'], '.' ); $validated_url = $url; if ( ! $same_host ) { if ( preg_match( '#^(([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)\.){3}([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)$#', $host ) ) { $ip = $host; } else { $ip = gethostbyname( $host ); if ( $ip === $host ) { // Error condition for gethostbyname(). return false; } } $parts = array_map( 'intval', explode( '.', $ip ) ); if ( 0 === $parts[0] // 0.0.0.0/8. || 127 === $parts[0] // 127.0.0.0/8. || 10 === $parts[0] // 10.0.0.0/8. || ( 172 === $parts[0] && 16 <= $parts[1] && 31 >= $parts[1] ) // 172.16.0.0/12. || ( 192 === $parts[0] && 168 === $parts[1] ) // 192.168.0.0/16. || ( 169 === $parts[0] && 254 === $parts[1] ) // 169.254.0.0/16. || // phpcs:ignore Squiz.PHP.CommentedOutCode.Found ( 100 === $parts[0] && 64 <= $parts[1] && 127 >= $parts[1] ) // Private: 100.64.0.0/10. ) { // If host appears local, reject. return false; } // Use resolved IP address to avoid DNS rebinding issues. $validated_url = $ip; } /** This filter is documented in wp-includes/http.php */ $allowed_ports = apply_filters( 'http_allowed_safe_ports', [ 80, 443, 8080 ], $host, $url ); if ( ! isset( $parsed_url['port'] ) || ( \is_array( $allowed_ports ) && \in_array( $parsed_url['port'], $allowed_ports, true ) ) ) { return $validated_url; } if ( $same_host && isset( $parsed_home['port'] ) && $parsed_home['port'] === $parsed_url['port'] ) { return $validated_url; } return false; } /** * Returns a list of allowed mime types per media type (image, audio, video). * * @since 1.19.0 * * @return array List of allowed mime types. */ protected function get_allowed_mime_types(): array { $mime_type = $this->types->get_allowed_mime_types(); // Do not support hotlinking SVGs for security reasons. unset( $mime_type['vector'] ); return $mime_type; } } ================================================ FILE: includes/REST_API/Link_Controller.php ================================================ * } */ class Link_Controller extends REST_Controller implements HasRequirements { /** * Story_Post_Type instance. * * @var Story_Post_Type Story_Post_Type instance. */ private Story_Post_Type $story_post_type; /** * Constructor. * * @param Story_Post_Type $story_post_type Story_Post_Type instance. */ public function __construct( Story_Post_Type $story_post_type ) { $this->story_post_type = $story_post_type; $this->namespace = 'web-stories/v1'; $this->rest_base = 'link'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers routes for links. * * @since 1.0.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'parse_link' ], 'permission_callback' => [ $this, 'parse_link_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL to process.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', 'validate_callback' => [ $this, 'validate_url' ], ], ], ], ] ); } /** * Parses a URL to return some metadata for inserting links. * * @SuppressWarnings("PHPMD.CyclomaticComplexity") * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.0.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function parse_link( $request ) { /** * Requested URL. * * @var string $url */ $url = $request['url']; $url = untrailingslashit( $url ); /** * Filters the link data TTL value. * * @since 1.0.0 * * @param int $time Time to live (in seconds). Default is 1 day. * @param string $url The attempted URL. */ $cache_ttl = apply_filters( 'web_stories_link_data_cache_ttl', DAY_IN_SECONDS, $url ); $cache_key = 'web_stories_link_data_' . md5( $url ); $data = get_transient( $cache_key ); if ( \is_string( $data ) && ! empty( $data ) ) { /** * Decoded cached link data. * * @var array{title: string, image: string, description: string}|null $link */ $link = json_decode( $data, true ); if ( $link ) { $response = $this->prepare_item_for_response( $link, $request ); return rest_ensure_response( $response ); } } $data = [ 'title' => '', 'image' => '', 'description' => '', ]; // Do not request instagram.com, as it redirects to a login page. // See https://github.com/GoogleForCreators/web-stories-wp/issues/10451. $matches = []; $query_string = wp_parse_url( $url, PHP_URL_QUERY ); $check_url = \is_string( $query_string ) ? str_replace( "?$query_string", '', $url ) : $url; if ( preg_match( '~^https?://(www\.)?instagram\.com/([^/]+)/?$~', $check_url, $matches ) ) { $data['title'] = \sprintf( /* translators: %s: Instagram username. */ __( 'Instagram - @%s', 'web-stories' ), $matches[2] ); set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } $args = [ 'limit_response_size' => 153_600, // 150 KB. 'timeout' => 7, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout ]; /** * Filters the HTTP request args for link data retrieval. * * Can be used to adjust timeout and response size limit. * * @since 1.0.0 * * @param array $args Arguments used for the HTTP request * @param string $url The attempted URL. */ $args = apply_filters( 'web_stories_link_data_request_args', $args, $url ); $response = wp_safe_remote_get( $url, $args ); if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) { return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) { // Not saving to cache since the error might be temporary. $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } $html = wp_remote_retrieve_body( $response ); // Strip . $html_head_end = stripos( $html, '' ); if ( $html_head_end ) { $html = substr( $html, 0, $html_head_end ); } if ( ! $html ) { set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } try { $doc = Document::fromHtml( $html ); } catch ( \DOMException $exception ) { set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } if ( ! $doc ) { set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } $xpath = $doc->xpath; // Link title. $title = ''; $title_query = $xpath->query( '//title' ); if ( $title_query instanceof DOMNodeList && $title_query->length > 0 ) { $title_node = $title_query->item( 0 ); if ( $title_node instanceof DOMElement ) { $title = $title_node->textContent; } } if ( ! $title ) { /** * List of found elements. * * @var DOMNodeList $og_title_query */ $og_title_query = $xpath->query( '//meta[@property="og:title"]' ); $title = $this->get_dom_attribute_content( $og_title_query, 'content' ); } if ( ! $title ) { /** * List of found elements. * * @var DOMNodeList $og_site_name_query */ $og_site_name_query = $xpath->query( '//meta[@property="og:site_name"]' ); $title = $this->get_dom_attribute_content( $og_site_name_query, 'content' ); } // Site icon. /** * List of found elements. * * @var DOMNodeList $og_image_query */ $og_image_query = $xpath->query( '//meta[@property="og:image"]' ); $image = $this->get_dom_attribute_content( $og_image_query, 'content' ); if ( ! $image ) { /** * List of found elements. * * @var DOMNodeList $icon_query */ $icon_query = $xpath->query( '//link[contains(@rel, "icon")]' ); $image = $this->get_dom_attribute_content( $icon_query, 'content' ); } if ( ! $image ) { /** * List of found elements. * * @var DOMNodeList $touch_icon_query */ $touch_icon_query = $xpath->query( '//link[contains(@rel, "apple-touch-icon")]' ); $image = $this->get_dom_attribute_content( $touch_icon_query, 'href' ); } // Link description. /** * List of found elements. * * @var DOMNodeList $description_query */ $description_query = $xpath->query( '//meta[@name="description"]' ); $description = $this->get_dom_attribute_content( $description_query, 'content' ); if ( ! $description ) { /** * List of found elements. * * @var DOMNodeList $og_description_query */ $og_description_query = $xpath->query( '//meta[@property="og:description"]' ); $description = $this->get_dom_attribute_content( $og_description_query, 'content' ); } $data = [ 'title' => $title ?: '', 'image' => $image ?: '', 'description' => $description ?: '', ]; $response = $this->prepare_item_for_response( $data, $request ); set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); return rest_ensure_response( $response ); } /** * Prepares a single lock output for response. * * @since 1.10.0 * * @param array $link Link value, default to false is not set. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param array{title: string, image: string, description: string} $link * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $link, $request ) { $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $data = []; $check_fields = array_keys( $link ); foreach ( $check_fields as $check_field ) { if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) { $data[ $check_field ] = rest_sanitize_value_from_schema( $link[ $check_field ], $schema['properties'][ $check_field ] ); } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. return rest_ensure_response( $data ); } /** * Retrieves the link's schema, conforming to JSON Schema. * * @since 1.10.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'link', 'type' => 'object', 'properties' => [ 'title' => [ 'description' => __( 'Link\'s title', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], 'image' => [ 'description' => __( 'Link\'s image', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], ], 'description' => [ 'description' => __( 'Link\'s description', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], ], ]; $this->schema = $schema; /** * Schema * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if current user can process links. * * @since 1.0.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function parse_link_permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to process links.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Callback to validate urls. * * @since 1.11.0 * * @param string $value Value to be validated. * @return true|WP_Error */ public function validate_url( $value ) { $url = untrailingslashit( $value ); if ( empty( $url ) || ! wp_http_validate_url( $url ) ) { return new \WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } return true; } /** * Retrieve content of a given DOM node attribute. * * @since 1.0.0 * * @param DOMNodeList|false $query XPath query result. * @param string $attribute Attribute name. * @return string|false Attribute content on success, false otherwise. */ protected function get_dom_attribute_content( $query, string $attribute ) { if ( ! $query instanceof DOMNodeList || 0 === $query->length ) { return false; } /** * DOMElement * * @var DOMElement|DOMNode $node */ $node = $query->item( 0 ); if ( ! $node instanceof DOMElement ) { return false; } return $node->getAttribute( $attribute ); } } ================================================ FILE: includes/REST_API/Page_Template_Controller.php ================================================ envelope_response( $response, $embed ); } return $response; } /** * Retrieves the query params for the posts collection. * * @since 1.7.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['_web_stories_envelope'] = [ 'description' => __( 'Envelope request for preloading.', 'web-stories' ), 'type' => 'boolean', 'default' => false, ]; return $query_params; } /** * Retrieves the attachment's schema, conforming to JSON Schema. * * Removes some unneeded fields to improve performance by * avoiding some expensive database queries. * * @since 1.10.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Schema. * * @phpstan-var Schema $schema */ $schema = parent::get_item_schema(); unset( $schema['properties']['permalink_template'], $schema['properties']['generated_slug'] ); $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if a given request has access to read posts. * * @since 1.14.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_items_permissions_check( $request ) { $ret = parent::get_items_permissions_check( $request ); if ( is_wp_error( $ret ) ) { return $ret; } $post_type = get_post_type_object( $this->post_type ); if ( ! $post_type || ! current_user_can( $post_type->cap->edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { return new \WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit page templates.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Checks if a given request has access to read a post. * * @since 1.14.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ public function get_item_permissions_check( $request ) { $ret = parent::get_item_permissions_check( $request ); if ( is_wp_error( $ret ) ) { return $ret; } $post_type = get_post_type_object( $this->post_type ); if ( ! $post_type || ! current_user_can( $post_type->cap->edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { return new \WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit page templates.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } } ================================================ FILE: includes/REST_API/Products_Controller.php ================================================ settings = $settings; $this->story_post_type = $story_post_type; $this->shopping_vendors = $shopping_vendors; $this->namespace = 'web-stories/v1'; $this->rest_base = 'products'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.20.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings', 'story_post_type' ]; } /** * Registers routes for links. * * @since 1.20.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], 'args' => $this->get_collection_params(), ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); } /** * Checks if a given request has access to get and create items. * * @since 1.20.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_items_permissions_check( $request ) { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to manage products.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Retrieves all products. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.20.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function get_items( $request ) { /** * Shopping provider. * * @var string $shopping_provider */ $shopping_provider = $this->settings->get_setting( Settings::SETTING_NAME_SHOPPING_PROVIDER ); $query = $this->shopping_vendors->get_vendor_class( $shopping_provider ); if ( 'none' === $shopping_provider ) { return new WP_Error( 'rest_shopping_provider', __( 'No shopping provider set up.', 'web-stories' ), [ 'status' => 400 ] ); } if ( ! $query ) { return new WP_Error( 'rest_shopping_provider_not_found', __( 'Unable to find shopping integration.', 'web-stories' ), [ 'status' => 400 ] ); } $search_term = ! empty( $request['search'] ) ? $request['search'] : ''; $orderby = ! empty( $request['orderby'] ) ? $request['orderby'] : 'date'; $page = ! empty( $request['page'] ) ? $request['page'] : 1; $per_page = ! empty( $request['per_page'] ) ? $request['per_page'] : 100; $order = ! empty( $request['order'] ) ? $request['order'] : 'desc'; $query_result = $query->get_search( $search_term, $page, $per_page, $orderby, $order ); if ( is_wp_error( $query_result ) ) { return $query_result; } $products = []; foreach ( $query_result['products'] as $product ) { $data = $this->prepare_item_for_response( $product, $request ); $products[] = $this->prepare_response_for_collection( $data ); } /** * Response object. * * @var WP_REST_Response $response */ $response = rest_ensure_response( $products ); $response->header( 'X-WP-HasNextPage', $query_result['has_next_page'] ? 'true' : 'false' ); if ( $request['_web_stories_envelope'] ) { $embed = $request['_embed'] ?? false; $embed = $embed ? rest_parse_embed_param( $embed ) : false; $response = rest_get_server()->envelope_response( $response, $embed ); } return $response; } /** * Prepares a single post output for response. * * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.CyclomaticComplexity") * * @since 1.20.0 * * @param Product $item Project object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $item, $request ): WP_REST_Response { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh $product = $item; $fields = $this->get_fields_for_response( $request ); $data = []; if ( rest_is_field_included( 'productId', $fields ) ) { $data['productId'] = $product->get_id(); } if ( rest_is_field_included( 'productUrl', $fields ) ) { $data['productUrl'] = $product->get_url(); } if ( rest_is_field_included( 'productTitle', $fields ) ) { $data['productTitle'] = $product->get_title(); } if ( rest_is_field_included( 'productBrand', $fields ) ) { $data['productBrand'] = $product->get_brand(); } if ( rest_is_field_included( 'productPrice', $fields ) ) { $data['productPrice'] = $product->get_price(); } if ( rest_is_field_included( 'productPriceCurrency', $fields ) ) { $data['productPriceCurrency'] = $product->get_price_currency(); } if ( rest_is_field_included( 'productDetails', $fields ) ) { $data['productDetails'] = $product->get_details(); } if ( rest_is_field_included( 'productImages', $fields ) ) { $data['productImages'] = []; foreach ( $product->get_images() as $image ) { $image_data = []; if ( rest_is_field_included( 'productImages.url', $fields ) ) { $image_data['url'] = $image['url']; } if ( rest_is_field_included( 'productImages.alt', $fields ) ) { $image_data['alt'] = $image['alt']; } $data['productImages'][] = $image_data; } } $rating = $product->get_aggregate_rating(); if ( $rating ) { if ( rest_is_field_included( 'aggregateRating', $fields ) ) { $data['aggregateRating'] = []; } if ( rest_is_field_included( 'aggregateRating.ratingValue', $fields ) ) { $data['aggregateRating']['ratingValue'] = isset( $rating['rating_value'] ) ? (float) $rating['rating_value'] : 0; } if ( rest_is_field_included( 'aggregateRating.reviewCount', $fields ) ) { $data['aggregateRating']['reviewCount'] = isset( $rating['review_count'] ) ? (int) $rating['review_count'] : 0; } if ( rest_is_field_included( 'aggregateRating.reviewUrl', $fields ) ) { $data['aggregateRating']['reviewUrl'] = $rating['review_url'] ?? null; } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); /** * Response object. * * @var WP_REST_Response $response */ $response = rest_ensure_response( $data ); return $response; } /** * Retrieves the product schema, conforming to JSON Schema. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.20.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', // This must not be an actually existing post type like "product". // See https://github.com/GoogleForCreators/web-stories-wp/issues/12735. 'title' => 'web-story-product', 'type' => 'object', 'properties' => [ 'productId' => [ 'description' => __( 'Product ID.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productUrl' => [ 'description' => __( 'Product URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productTitle' => [ 'description' => __( 'Product title.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productBrand' => [ 'description' => __( 'Product brand.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productPrice' => [ 'description' => __( 'Product price.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productPriceCurrency' => [ 'description' => __( 'Product currency.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productImages' => [ 'description' => __( 'Product brand.', 'web-stories' ), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'url' => [ 'description' => __( 'Product image URL', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], ], 'alt' => [ 'description' => __( 'Product image alt text', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], ], ], 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'aggregateRating' => [ 'description' => __( 'Product rating.', 'web-stories' ), 'type' => 'object', 'properties' => [ 'ratingValue' => [ 'description' => __( 'Average rating.', 'web-stories' ), 'type' => 'number', 'context' => [ 'view', 'edit', 'embed' ], ], 'reviewCount' => [ 'description' => __( 'Number of reviews.', 'web-stories' ), 'type' => 'number', 'context' => [ 'view', 'edit', 'embed' ], ], 'reviewUrl' => [ 'description' => __( 'Product review URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], ], ], 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'productDetails' => [ 'description' => __( 'Product description.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Retrieves the query params for the products collection. * * @since 1.21.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['per_page']['default'] = 100; $query_params['orderby'] = [ 'description' => __( 'Sort collection by product attribute.', 'web-stories' ), 'type' => 'string', 'default' => 'date', 'enum' => [ 'date', 'price', 'title', ], ]; $query_params['order'] = [ 'description' => __( 'Order sort attribute ascending or descending.', 'web-stories' ), 'type' => 'string', 'default' => 'desc', 'enum' => [ 'asc', 'desc' ], ]; $query_params['_web_stories_envelope'] = [ 'description' => __( 'Envelope request for preloading.', 'web-stories' ), 'type' => 'boolean', 'default' => false, ]; return $query_params; } } ================================================ FILE: includes/REST_API/Publisher_Logos_Controller.php ================================================ settings = $settings; $this->story_post_type = $story_post_type; $this->namespace = 'web-stories/v1'; $this->rest_base = 'publisher-logos'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings', 'story_post_type' ]; } /** * Registers routes for links. * * @since 1.12.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'permissions_check' ], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __( 'Publisher logo ID.', 'web-stories' ), 'type' => 'integer', ], ], [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_item' ], 'permission_callback' => [ $this, 'update_item_permissions_check' ], 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'permissions_check' ], ], ] ); } /** * Checks if a given request has access to get and create items. * * @since 1.12.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to manage publisher logos.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Checks if a given request has access to manage a single item. * * @since 1.12.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to manage publisher logos.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Retrieves all active publisher logos. * * @since 1.12.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { /** * Publisher logo IDs. * * @var int[] $publisher_logos_ids */ $publisher_logos_ids = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] ); _prime_post_caches( $publisher_logos_ids ); $publisher_logos = $this->filter_publisher_logos( $publisher_logos_ids ); $results = []; foreach ( $publisher_logos as $logo ) { /** * We're expecting a post object after the filtering above. * * @var WP_Post $post */ $post = get_post( $logo ); $data = $this->prepare_item_for_response( $post, $request ); $results[] = $this->prepare_response_for_collection( $data ); } // Ensure the default publisher logo is first in the list. $active = array_column( $results, 'active' ); array_multisort( $active, SORT_DESC, $results ); return rest_ensure_response( $results ); } /** * Adds a new publisher logo to the collection. * * Supports adding multiple logos at once. * * @since 1.12.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function create_item( $request ) { /** * List of publisher logos. * * @var int[] $publisher_logos */ $publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] ); $publisher_logos = $this->filter_publisher_logos( $publisher_logos ); /** * Publisher logo ID(s). * * Could be a single attachment ID or an array of attachment IDs. * * @var int|int[] $logo_id */ $logo_id = $request['id']; $posts = (array) $logo_id; if ( empty( $posts ) ) { return new \WP_Error( 'rest_invalid_id', __( 'Invalid ID', 'web-stories' ), [ 'status' => 400 ] ); } foreach ( $posts as $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'attachment' !== $post->post_type ) { return new \WP_Error( 'rest_invalid_id', __( 'Invalid ID', 'web-stories' ), [ 'status' => 400 ] ); } if ( \in_array( $post->ID, $publisher_logos, true ) ) { return new \WP_Error( 'rest_publisher_logo_exists', __( 'Publisher logo already exists', 'web-stories' ), [ 'status' => 400 ] ); } $publisher_logos[] = $post->ID; } $this->settings->update_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, $publisher_logos ); $active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) ); if ( 1 === \count( $publisher_logos ) || ! \in_array( $active_publisher_logo_id, $publisher_logos, true ) ) { $this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, $posts[0] ); } $results = []; foreach ( $posts as $post ) { /** * Post object. * * @var WP_Post $post */ $post = get_post( $post ); $data = $this->prepare_item_for_response( $post, $request ); if ( 1 === \count( $posts ) ) { return $data; } $results[] = $this->prepare_response_for_collection( $data ); } return rest_ensure_response( $results ); } /** * Removes a publisher logo from the collection. * * @since 1.12.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function delete_item( $request ) { /** * Publisher logo ID. * * @var int $logo_id */ $logo_id = $request['id']; $post = $this->get_publisher_logo( $logo_id ); if ( is_wp_error( $post ) ) { return $post; } $prepared = $this->prepare_item_for_response( $post, $request ); /** * List of publisher logos. * * @var int[] $publisher_logos */ $publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] ); $publisher_logos = $this->filter_publisher_logos( $publisher_logos ); $publisher_logos = array_values( array_diff( $publisher_logos, [ $post->ID ] ) ); $active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) ); if ( $post->ID === $active_publisher_logo_id || ! \in_array( $active_publisher_logo_id, $publisher_logos, true ) ) { // Mark the first available publisher logo as the new default. $this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, ! empty( $publisher_logos[0] ) ? $publisher_logos[0] : 0 ); } $this->settings->update_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, $publisher_logos ); return new WP_REST_Response( [ 'deleted' => true, 'previous' => $prepared->get_data(), ] ); } /** * Updates a publisher logo in the collection. * * Can only be used to make it the "active" one. * * @since 1.12.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function update_item( $request ) { /** * Publisher logo ID. * * @var int $logo_id */ $logo_id = $request['id']; $post = $this->get_publisher_logo( $logo_id ); if ( is_wp_error( $post ) ) { return $post; } if ( $request['active'] ) { $this->settings->update_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, $post->ID ); } return $this->prepare_item_for_response( $post, $request ); } /** * Prepares a single publisher logo output for response. * * @since 1.12.0 * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $post, $request ): WP_REST_Response { $fields = $this->get_fields_for_response( $request ); // Base fields for every post. $data = []; if ( rest_is_field_included( 'id', $fields ) ) { $data['id'] = $post->ID; } if ( rest_is_field_included( 'title', $fields ) ) { $data['title'] = get_the_title( $post->ID ); } if ( rest_is_field_included( 'active', $fields ) ) { $active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) ); $data['active'] = $post->ID === $active_publisher_logo_id; } if ( rest_is_field_included( 'url', $fields ) ) { $data['url'] = wp_get_attachment_url( $post->ID ); } /** * Wrapped response object. * * @var WP_REST_Response $response Response object. */ $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { $response->add_links( $this->prepare_links( $post ) ); } return $response; } /** * Retrieves the publisher logo's schema, conforming to JSON Schema. * * @since 1.12.0 * * @return array|bool|string>>|string> Item schema data. */ public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'publisher-logo', 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'Publisher logo ID.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'title' => [ 'description' => __( 'Publisher logo title.', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'url' => [ 'description' => __( 'Publisher logo URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], 'active' => [ 'description' => __( 'Whether the publisher logo is the default one.', 'web-stories' ), 'type' => 'boolean', 'context' => [ 'view', 'edit', 'embed' ], 'readonly' => true, ], ], ]; } /** * Get an existing publisher logo's post object, if valid. * * @since 1.12.0 * * @param int $id Supplied ID. * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. */ protected function get_publisher_logo( $id ) { /** * List of publisher logos. * * @var int[] $publisher_logos */ $publisher_logos = $this->settings->get_setting( $this->settings::SETTING_NAME_PUBLISHER_LOGOS, [] ); $publisher_logos = $this->filter_publisher_logos( $publisher_logos ); $post = get_post( $id ); if ( ! $post || ! \in_array( $post->ID, $publisher_logos, true ) ) { return new \WP_Error( 'rest_invalid_id', __( 'Invalid ID', 'web-stories' ), [ 'status' => 400 ] ); } return $post; } /** * Filters publisher logos to remove non-existent or invalid ones. * * @param int[] $publisher_logos List of publisher logos. * @return int[] Filtered list of publisher logos. */ protected function filter_publisher_logos( $publisher_logos ): array { return array_filter( $publisher_logos, 'wp_attachment_is_image' ); } /** * Prepares links for the request. * * @since 1.12.0 * * @param WP_Post $post Post object. * @return array Links for the given post. * * @phpstan-return Links */ protected function prepare_links( $post ): array { $base = \sprintf( '%s/%s', $this->namespace, $this->rest_base ); // Entity meta. return [ 'self' => [ 'href' => rest_url( trailingslashit( $base ) . $post->ID ), ], 'collection' => [ 'href' => rest_url( $base ), ], ]; } } ================================================ FILE: includes/REST_API/REST_Controller.php ================================================ register_routes(); } /** * Get the action to use for registering the service. * * @since 1.7.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'rest_api_init'; } /** * Get the action priority to use for registering the service. * * @since 1.7.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 100; } } ================================================ FILE: includes/REST_API/Status_Check_Controller.php ================================================ story_post_type = $story_post_type; $this->namespace = 'web-stories/v1'; $this->rest_base = 'status-check'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers routes for links. * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => WP_REST_Server::ALLMETHODS, 'callback' => [ $this, 'status_check' ], 'permission_callback' => [ $this, 'status_check_permissions_check' ], 'args' => [ 'content' => [ 'description' => __( 'Test HTML content.', 'web-stories' ), 'required' => true, 'type' => 'string', ], ], ], ] ); } /** * Status check, return true for now. * * @since 1.1.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function status_check( $request ) { $data = [ 'success' => true, ]; $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } /** * Prepares a status data output for response. * * @since 1.10.0 * * @param array{success: bool} $item Status array. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $item, $request ) { $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $data = []; if ( ! empty( $schema['properties']['success'] ) && rest_is_field_included( 'success', $fields ) ) { $data['success'] = rest_sanitize_value_from_schema( $item['success'], $schema['properties']['success'] ); } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the status schema, conforming to JSON Schema. * * @since 1.10.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'status', 'type' => 'object', 'properties' => [ 'success' => [ 'description' => __( 'Whether check was successful', 'web-stories' ), 'type' => 'boolean', 'context' => [ 'view', 'edit', 'embed' ], ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if current user can process status. * * @since 1.1.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function status_check_permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed run status check.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } } ================================================ FILE: includes/REST_API/Stories_Autosaves_Controller.php ================================================ get_rest_controller(); $this->parent_controller = $parent_controller; $this->parent_base = ! empty( $post_type_object->rest_base ) ? (string) $post_type_object->rest_base : $post_type_object->name; $this->namespace = ! empty( $post_type_object->rest_namespace ) ? (string) $post_type_object->rest_namespace : 'wp/v2'; } /** * Registers the routes for autosaves. * * Used to override the create_item() callback. * * @since 1.0.0 * * @see register_rest_route() */ public function register_routes(): void { parent::register_routes(); register_rest_route( $this->namespace, '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, [ 'args' => [ 'parent' => [ 'description' => __( 'The ID for the parent of the object.', 'web-stories' ), 'type' => 'integer', ], ], [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], 'args' => $this->get_collection_params(), ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'create_item_permissions_check' ], 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ], 'schema' => [ $this, 'get_public_item_schema' ], ], true // required so that the existing route is overridden. ); } /** * Prepares a single template output for response. * * Adds post_content_filtered field to output. * * @since 1.0.0 * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $post, $request ): WP_REST_Response { $response = parent::prepare_item_for_response( $post, $request ); $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); /** * Response data. * * @var array $data */ $data = $response->get_data(); if ( ! empty( $schema['properties']['story_data'] ) && rest_is_field_included( 'story_data', $fields ) ) { $post_story_data = json_decode( $post->post_content_filtered, true ); $data['story_data'] = rest_sanitize_value_from_schema( $post_story_data, $schema['properties']['story_data'] ); } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); $links = $response->get_links(); // Wrap the data in a response object. $response = new WP_REST_Response( $data ); foreach ( $links as $rel => $rel_links ) { foreach ( $rel_links as $link ) { $response->add_link( $rel, $link['href'], $link['attributes'] ); } } /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php */ return apply_filters( 'rest_prepare_autosave', $response, $post, $request ); } /** * Retrieves the story's schema, conforming to JSON Schema. * * @since 1.0.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $autosaves_schema = parent::get_item_schema(); $stories_schema = $this->parent_controller->get_item_schema(); $autosaves_schema['properties']['story_data'] = $stories_schema['properties']['story_data']; $this->schema = $autosaves_schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } } ================================================ FILE: includes/REST_API/Stories_Base_Controller.php ================================================ * @phpstan-type SchemaEntry array{ * description: string, * type: string, * context: string[], * default?: mixed, * } * @phpstan-type Schema array{ * properties: array{ * content?: SchemaEntry, * story_data?: SchemaEntry * } * } * @phpstan-type RegisteredMetadata array{ * type: string, * description: string, * single: bool, * sanitize_callback?: callable, * auth_callback: callable, * show_in_rest: bool|array{schema: array}, * default?: mixed * } */ class Stories_Base_Controller extends WP_REST_Posts_Controller { /** * Decoder instance. * * @var Decoder Decoder instance. */ private Decoder $decoder; /** * Constructor. * * Override the namespace. * * @since 1.0.0 * * @param string $post_type Post type. */ public function __construct( $post_type ) { parent::__construct( $post_type ); $injector = Services::get_injector(); /** * Decoder instance. * * @var Decoder $decoder Decoder instance. */ $decoder = $injector->make( Decoder::class ); $this->decoder = $decoder; } /** * Prepares a single template output for response. * * Adds post_content_filtered field to output. * * @since 1.0.0 * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $post, $request ): WP_REST_Response { $response = parent::prepare_item_for_response( $post, $request ); $fields = $this->get_fields_for_response( $request ); /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->get_item_schema(); /** * Response data. * * @var array $data */ $data = $response->get_data(); if ( ! empty( $schema['properties']['story_data'] ) && rest_is_field_included( 'story_data', $fields ) ) { $post_story_data = json_decode( $post->post_content_filtered, true ); $data['story_data'] = post_password_required( $post ) ? (object) [] : rest_sanitize_value_from_schema( $post_story_data, $schema['properties']['story_data'] ); } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); $links = $response->get_links(); // Wrap the data in a response object. $response = new WP_REST_Response( $data ); foreach ( $links as $rel => $rel_links ) { foreach ( $rel_links as $link ) { $response->add_link( $rel, $link['href'], $link['attributes'] ); } } /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request ); } /** * Retrieves the story's schema, conforming to JSON Schema. * * @since 1.0.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $schema = parent::get_item_schema(); $schema['properties']['story_data'] = [ 'description' => __( 'Story data', 'web-stories' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], 'default' => [], ]; $schema['properties']['original_id'] = [ 'description' => __( 'Unique identifier for original story id.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Prepares a single story for create or update. Add post_content_filtered field to save/insert. * * @since 1.0.0 * * @param WP_REST_Request $request Request object. * @return stdClass|WP_Error Post object or WP_Error. */ protected function prepare_item_for_database( $request ) { $prepared_post = parent::prepare_item_for_database( $request ); if ( is_wp_error( $prepared_post ) ) { return $prepared_post; } /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->get_item_schema(); // Post content. if ( ! empty( $schema['properties']['content'] ) ) { // Ensure that content and story_data are updated together. // Exception: new auto-draft created from a template. if ( ( ( ! empty( $request['story_data'] ) && empty( $request['content'] ) ) || ( ! empty( $request['content'] ) && empty( $request['story_data'] ) ) ) && ( 'auto-draft' !== $prepared_post->post_status ) ) { return new \WP_Error( 'rest_empty_content', \sprintf( /* translators: 1: content, 2: story_data */ __( '%1$s and %2$s should always be updated together.', 'web-stories' ), 'content', 'story_data' ), [ 'status' => 412 ] ); } if ( isset( $request['content'] ) ) { $prepared_post->post_content = $this->decoder->base64_decode( $prepared_post->post_content ); } } // If the request is updating the content as well, let's make sure the JSON representation of the story is saved, too. if ( ! empty( $schema['properties']['story_data'] ) && isset( $request['story_data'] ) ) { $prepared_post->post_content_filtered = wp_json_encode( $request['story_data'] ); } return $prepared_post; } /** * Get registered post meta. * * @since 1.23.0 * * @param WP_Post $original_post Post Object. * @return array $meta */ protected function get_registered_meta( WP_Post $original_post ): array { $meta_keys = get_registered_meta_keys( 'post', $this->post_type ); $meta = []; /** * Meta key settings. * * @var array $settings * @phpstan-var RegisteredMetadata $settings */ foreach ( $meta_keys as $key => $settings ) { if ( $settings['show_in_rest'] ) { $meta[ $key ] = get_post_meta( $original_post->ID, $key, $settings['single'] ); } } return $meta; } /** * Prepares links for the request. * * Ensures that {@see Stories_Users_Controller} is used for author embeds. * * @since 1.10.0 * * @param WP_Post $post Post object. * @return array Links for the given post. * * @phpstan-return Links */ protected function prepare_links( $post ): array { $links = parent::prepare_links( $post ); if ( ! empty( $post->post_author ) && post_type_supports( $post->post_type, 'author' ) ) { $links['author'] = [ 'href' => rest_url( \sprintf( '%s/%s/%s', $this->namespace, 'users', $post->post_author ) ), 'embeddable' => true, ]; } // If we have a featured media, add that. $featured_media = get_post_thumbnail_id( $post->ID ); if ( $featured_media ) { $image_url = rest_url( \sprintf( '%s/%s/%s', $this->namespace, 'media', $featured_media ) ); $links['https://api.w.org/featuredmedia'] = [ 'href' => $image_url, 'embeddable' => true, ]; } if ( ! \in_array( $post->post_type, [ 'attachment', 'nav_menu_item', 'revision' ], true ) ) { $attachments_url = rest_url( \sprintf( '%s/%s', $this->namespace, 'media' ) ); $attachments_url = add_query_arg( 'parent', $post->ID, $attachments_url ); $links['https://api.w.org/attachment'] = [ 'href' => $attachments_url, ]; } return $links; } /** * Get the link relations available for the post and current user. * * @since 1.10.0 * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return string[] List of link relations. */ protected function get_available_actions( $post, $request ): array { $rels = parent::get_available_actions( $post, $request ); if ( $this->check_delete_permission( $post ) ) { $rels[] = 'https://api.w.org/action-delete'; } if ( $this->check_update_permission( $post ) ) { $rels[] = 'https://api.w.org/action-edit'; } return $rels; } } ================================================ FILE: includes/REST_API/Stories_Controller.php ================================================ * } * @phpstan-import-type Links from \Google\Web_Stories\REST_API\Stories_Base_Controller */ class Stories_Controller extends Stories_Base_Controller { /** * Default style presets to pass if not set. */ public const EMPTY_STYLE_PRESETS = [ 'colors' => [], 'textStyles' => [], ]; /** * Query args. * * @var array * @phpstan-var QueryArgs */ private array $args = []; /** * Prepares a single story output for response. Add post_content_filtered field to output. * * @SuppressWarnings("PHPMD.CyclomaticComplexity") * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.0.0 * * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $post, $request ): WP_REST_Response { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; if ( 'auto-draft' === $post->post_status && wp_validate_boolean( $request['web_stories_demo'] ) ) { $demo = new Demo_Content(); $demo_content = $demo->get_content(); if ( ! empty( $demo_content ) ) { $post->post_title = $demo->get_title(); $post->post_content_filtered = $demo_content; } } $response = parent::prepare_item_for_response( $post, $request ); $fields = $this->get_fields_for_response( $request ); /** * Response data. * * @var array $data */ $data = $response->get_data(); if ( rest_is_field_included( 'style_presets', $fields ) ) { $style_presets = get_option( Story_Post_Type::STYLE_PRESETS_OPTION, self::EMPTY_STYLE_PRESETS ); $data['style_presets'] = \is_array( $style_presets ) ? $style_presets : self::EMPTY_STYLE_PRESETS; } if ( rest_is_field_included( 'preview_link', $fields ) ) { // Based on https://github.com/WordPress/wordpress-develop/blob/8153c8ba020c4aec0b9d94243cd39c689a0730f7/src/wp-admin/includes/post.php#L1445-L1457. if ( 'draft' === $post->post_status || empty( $post->post_name ) ) { $view_link = get_preview_post_link( $post ); } elseif ( 'publish' === $post->post_status ) { $view_link = get_permalink( $post ); } else { if ( ! \function_exists( 'get_sample_permalink' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } [ $permalink ] = get_sample_permalink( $post->ID, $post->post_title, '' ); // Allow non-published (private, future) to be viewed at a pretty permalink, in case $post->post_name is set. $view_link = str_replace( [ '%pagename%', '%postname%' ], $post->post_name, $permalink ); } $data['preview_link'] = $view_link; } if ( rest_is_field_included( 'edit_link', $fields ) ) { $edit_link = get_edit_post_link( $post, 'rest-api' ); if ( $edit_link ) { $data['edit_link'] = $edit_link; } } if ( rest_is_field_included( 'embed_post_link', $fields ) && current_user_can( 'edit_posts' ) ) { $data['embed_post_link'] = add_query_arg( [ 'from-web-story' => $post->ID ], admin_url( 'post-new.php' ) ); } if ( rest_is_field_included( 'story_poster', $fields ) ) { $story_poster = $this->get_story_poster( $post ); if ( $story_poster ) { $data['story_poster'] = $story_poster; } } $data = $this->filter_response_by_context( $data, $context ); $links = $response->get_links(); $response = new WP_REST_Response( $data ); foreach ( $links as $rel => $rel_links ) { foreach ( $rel_links as $link ) { $response->add_link( $rel, $link['href'], $link['attributes'] ); } } /** * Filters the post data for a response. * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * * @since 1.0.0 * * @param WP_REST_Response $response The response object. * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. */ return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request ); } /** * Creates a single story. * * Override the existing method so we can set parent id. * * @since 1.11.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function create_item( $request ) { $original_id = ! empty( $request['original_id'] ) ? $request['original_id'] : null; if ( ! $original_id ) { return parent::create_item( $request ); } $original_post = $this->get_post( $original_id ); if ( is_wp_error( $original_post ) ) { return $original_post; } if ( ! $this->check_update_permission( $original_post ) ) { return new \WP_Error( 'rest_cannot_create', __( 'Sorry, you are not allowed to duplicate this story.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } $request['content'] = $original_post->post_content; $request['excerpt'] = $original_post->post_excerpt; $title = \sprintf( /* translators: %s: story title. */ __( '%s (Copy)', 'web-stories' ), $original_post->post_title ); $request['title'] = $title; $story_data = json_decode( $original_post->post_content_filtered, true ); if ( $story_data ) { $request['story_data'] = $story_data; } $thumbnail_id = get_post_thumbnail_id( $original_post ); if ( $thumbnail_id ) { $request['featured_media'] = $thumbnail_id; } $meta = $this->get_registered_meta( $original_post ); if ( $meta ) { $request['meta'] = $meta; } return parent::create_item( $request ); } /** * Updates a single post. * * @since 1.0.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function update_item( $request ) { $response = parent::update_item( $request ); if ( is_wp_error( $response ) ) { return rest_ensure_response( $response ); } // If style presets are set. $style_presets = $request->get_param( 'style_presets' ); if ( \is_array( $style_presets ) ) { update_option( Story_Post_Type::STYLE_PRESETS_OPTION, $style_presets ); } return rest_ensure_response( $response ); } /** * Retrieves the story's schema, conforming to JSON Schema. * * @since 1.0.0 * * @return array>> Item schema data. */ public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $schema = parent::get_item_schema(); $schema['properties']['style_presets'] = [ 'description' => __( 'Style presets used by all stories', 'web-stories' ), 'type' => 'object', 'context' => [ 'edit' ], ]; $schema['properties']['preview_link'] = [ 'description' => __( 'Preview Link.', 'web-stories' ), 'type' => 'string', 'context' => [ 'edit' ], 'format' => 'uri', 'default' => '', ]; $schema['properties']['edit_link'] = [ 'description' => _x( 'Edit Link', 'compound noun', 'web-stories' ), 'type' => 'string', 'context' => [ 'edit' ], 'format' => 'uri', 'default' => '', ]; $schema['properties']['embed_post_link'] = [ 'description' => __( 'Embed Post Edit Link.', 'web-stories' ), 'type' => 'string', 'context' => [ 'edit' ], 'format' => 'uri', 'default' => '', ]; $schema['properties']['story_poster'] = [ 'description' => __( 'Story poster image.', 'web-stories' ), 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', 'description' => __( 'Poster ID', 'web-stories' ), ], 'needsProxy' => [ 'description' => __( 'If poster needs to be proxied', 'web-stories' ), 'type' => 'boolean', ], 'height' => [ 'type' => 'integer', 'description' => __( 'Poster height', 'web-stories' ), ], 'url' => [ 'description' => __( 'Poster URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', ], 'width' => [ 'description' => __( 'Poster width.', 'web-stories' ), 'type' => 'integer', ], ], 'default' => null, ]; $schema['properties']['status']['enum'][] = 'auto-draft'; $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); } /** * Filters query clauses to sort posts by the author's display name. * * @since 1.0.0 * * @param string[] $clauses Associative array of the clauses for the query. * @param WP_Query $query The WP_Query instance. * @return string[] Filtered query clauses. */ public function filter_posts_clauses( $clauses, WP_Query $query ): array { global $wpdb; if ( $this->post_type !== $query->get( 'post_type' ) ) { return $clauses; } if ( 'story_author' !== $query->get( 'orderby' ) ) { return $clauses; } /** * Order value. * * @var string $order */ $order = $query->get( 'order' ); // phpcs:disable WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users $clauses['join'] .= " LEFT JOIN {$wpdb->users} ON {$wpdb->posts}.post_author={$wpdb->users}.ID"; $clauses['orderby'] = "{$wpdb->users}.display_name $order, " . $clauses['orderby']; // phpcs:enable WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users return $clauses; } /** * Prime post caches for attachments and parents. * * @since 1.21.0 * * @param WP_Post[] $posts Array of post objects. * @return WP_Post[] Array of posts. */ public function prime_post_caches( array $posts ): array { $post_ids = $this->get_attached_post_ids( $posts ); if ( ! empty( $post_ids ) ) { _prime_post_caches( $post_ids ); } return $posts; } /** * Filter the query to cache the value to a class property. * * @param array $args WP_Query arguments. * @return array Current args. * * @phpstan-param QueryArgs $args */ public function filter_query( $args ): array { $this->args = $args; return $args; } /** * Retrieves a collection of web stories. * * @since 1.0.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function get_items( $request ) { add_filter( "rest_{$this->post_type}_query", [ $this, 'filter_query' ], 100, 1 ); add_filter( 'posts_clauses', [ $this, 'filter_posts_clauses' ], 10, 2 ); add_filter( 'posts_results', [ $this, 'prime_post_caches' ] ); $response = parent::get_items( $request ); remove_filter( 'posts_results', [ $this, 'prime_post_caches' ] ); remove_filter( 'posts_clauses', [ $this, 'filter_posts_clauses' ], 10 ); remove_filter( "rest_{$this->post_type}_query", [ $this, 'filter_query' ], 100 ); if ( is_wp_error( $response ) ) { return $response; } if ( 'edit' !== $request['context'] ) { return $response; } $response = $this->add_response_headers( $response, $request ); if ( is_wp_error( $response ) ) { return $response; } if ( $request['_web_stories_envelope'] ) { $embed = $request['_embed']; $embed = $embed ? rest_parse_embed_param( $embed ) : false; $response = rest_get_server()->envelope_response( $response, $embed ); } return $response; } /** * Retrieves the query params for the posts collection. * * @since 1.0.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['_web_stories_envelope'] = [ 'description' => __( 'Envelope request for preloading.', 'web-stories' ), 'type' => 'boolean', 'default' => false, ]; $query_params['web_stories_demo'] = [ 'description' => __( 'Load demo data.', 'web-stories' ), 'type' => 'boolean', 'default' => false, ]; if ( ! empty( $query_params['orderby'] ) ) { $query_params['orderby']['enum'][] = 'story_author'; } return $query_params; } /** * Get an array of attached post objects. * * @since 1.22.0 * * @param WP_Post[] $posts Array of post objects. * @return int[] Array of post ids. */ protected function get_attached_post_ids( array $posts ): array { return array_unique( array_filter( array_map( [ $this, 'get_publisher_logo_id' ], $posts ) ) ); } /** * Add response headers, with post counts. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.12.0 * * @param WP_REST_Response $response Response object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ protected function add_response_headers( WP_REST_Response $response, WP_REST_Request $request ) { // Add counts for other statuses. $statuses = [ 'publish' => 'publish', ]; $post_type = get_post_type_object( $this->post_type ); if ( ! ( $post_type instanceof WP_Post_Type ) ) { return $response; } if ( current_user_can( $post_type->cap->edit_posts ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined $statuses['draft'] = 'draft'; $statuses['future'] = 'future'; $statuses['pending'] = 'pending'; } if ( current_user_can( $post_type->cap->publish_posts ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined $statuses['private'] = 'private'; } $edit_others_posts = current_user_can( $post_type->cap->edit_others_posts ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined $edit_private_posts = current_user_can( $post_type->cap->edit_private_posts ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined $statuses_count = [ 'all' => 0 ]; $total_posts = 0; $query_args = $this->prepare_items_query( $this->args, $request ); // Strip down query for speed. $query_args['fields'] = 'ids'; $query_args['posts_per_page'] = 1; $query_args['paged'] = 1; $query_args['update_post_meta_cache'] = false; $query_args['update_post_term_cache'] = false; foreach ( $statuses as $key => $status ) { $posts_query = new WP_Query(); $query_args['post_status'] = $status; if ( \in_array( $status, [ 'draft', 'future', 'pending' ], true ) && ! $edit_others_posts ) { $query_args['author'] = get_current_user_id(); } if ( 'private' === $status && ! $edit_private_posts ) { $query_args['author'] = get_current_user_id(); } $posts_query->query( $query_args ); $statuses_count[ $key ] = absint( $posts_query->found_posts ); $statuses_count['all'] += $statuses_count[ $key ]; if ( \in_array( $status, $this->args['post_status'] ?? [], true ) ) { $total_posts += $statuses_count[ $key ] ?? 0; } } // Encode the array as headers do not support passing an array. $encoded_statuses = wp_json_encode( $statuses_count ); if ( $encoded_statuses ) { $response->header( 'X-WP-TotalByStatus', $encoded_statuses ); } $page = (int) $posts_query->query_vars['paged']; $max_pages = ceil( $total_posts / (int) ( $this->args['posts_per_page'] ?? 10 ) ); if ( $page > $max_pages && $total_posts > 0 ) { return new \WP_Error( 'rest_post_invalid_page_number', __( 'The page number requested is larger than the number of pages available.', 'web-stories' ), [ 'status' => 400 ] ); } $response->header( 'X-WP-Total', (string) $total_posts ); $response->header( 'X-WP-TotalPages', (string) $max_pages ); return $response; } /** * Prepares links for the request. * * @param WP_Post $post Post object. * @return array Links for the given post. * * @phpstan-return Links */ protected function prepare_links( $post ): array { $links = parent::prepare_links( $post ); $links = $this->add_post_locking_link( $links, $post ); $links = $this->add_publisher_logo_link( $links, $post ); return $links; } /** * Adds a REST API link if the story is locked. * * @since 1.12.0 * * @param array $links Links for the given post. * @param WP_Post $post Post object. * @return array Modified list of links. * * @phpstan-param Links $links * @phpstan-return Links */ private function add_post_locking_link( array $links, WP_Post $post ): array { $base = \sprintf( '%s/%s', $this->namespace, $this->rest_base ); $lock_url = rest_url( trailingslashit( $base ) . $post->ID . '/lock' ); $links['https://api.w.org/lock'] = [ 'href' => $lock_url, 'embeddable' => true, ]; /** * Lock data. * * @var string|false $lock */ $lock = get_post_meta( $post->ID, '_edit_lock', true ); if ( ! empty( $lock ) ) { [ $time, $user ] = explode( ':', $lock ); /** This filter is documented in wp-admin/includes/ajax-actions.php */ $time_window = apply_filters( 'wp_check_post_lock_window', 150 ); if ( $time && $time > time() - $time_window ) { $links['https://api.w.org/lockuser'] = [ 'href' => rest_url( \sprintf( '%s/%s', $this->namespace, 'users/' ) . $user ), 'embeddable' => true, ]; } } return $links; } /** * Helper method to get publisher logo id. * * @since 1.22.0 * * @param WP_Post $post Post Object. * @return int ID of attachment for publisher logo. */ private function get_publisher_logo_id( WP_Post $post ): int { /** * Publisher logo ID. * * @var string|int $publisher_logo_id */ $publisher_logo_id = get_post_meta( $post->ID, Story_Post_Type::PUBLISHER_LOGO_META_KEY, true ); return (int) $publisher_logo_id; } /** * Adds a REST API link for the story's publisher logo. * * @since 1.12.0 * * @param array $links Links for the given post. * @param WP_Post $post Post object. * @return array Modified list of links. * * @phpstan-param Links $links * @phpstan-return Links */ private function add_publisher_logo_link( array $links, WP_Post $post ): array { $publisher_logo_id = $this->get_publisher_logo_id( $post ); if ( $publisher_logo_id ) { $links['https://api.w.org/publisherlogo'] = [ 'href' => rest_url( \sprintf( '%s/%s/%s', $this->namespace, 'media', $publisher_logo_id ) ), 'embeddable' => true, ]; } return $links; } /** * Helper method to get the story poster. * * Checks for the regular featured image as well as a hotlinked image. * * @since 1.23.2 * * @param WP_Post $post Post Object. * @return array{url:string, width: int, height: int, needsProxy: bool, id?: int}|null Story poster data. */ private function get_story_poster( WP_Post $post ): ?array { $thumbnail_id = (int) get_post_thumbnail_id( $post ); if ( 0 !== $thumbnail_id ) { $poster_src = wp_get_attachment_image_src( $thumbnail_id, Image_Sizes::POSTER_PORTRAIT_IMAGE_DIMENSIONS ); if ( $poster_src ) { [$url, $width, $height] = $poster_src; return [ 'id' => $thumbnail_id, 'url' => $url, 'width' => $width, 'height' => $height, 'needsProxy' => false, ]; } } else { /** * Poster. * * @var array{url:string, width: int, height: int, needsProxy: bool}|false $poster */ $poster = get_post_meta( $post->ID, Story_Post_Type::POSTER_META_KEY, true ); if ( ! empty( $poster ) ) { return $poster; } } return null; } } ================================================ FILE: includes/REST_API/Stories_Lock_Controller.php ================================================ story_post_type = $story_post_type; $this->parent_controller = $story_post_type->get_parent_controller(); $this->rest_base = $story_post_type->get_rest_base(); $this->namespace = $story_post_type->get_rest_namespace(); } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers the routes for the objects of the controller. * * @since 1.6.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/lock', [ 'args' => [ 'id' => [ 'description' => __( 'Unique identifier for the object.', 'web-stories' ), 'type' => 'integer', ], ], [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_item' ], 'permission_callback' => [ $this, 'get_item_permissions_check' ], 'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ), ], ], [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_item' ], 'permission_callback' => [ $this, 'update_item_permissions_check' ], 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'delete_item_permissions_check' ], 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::DELETABLE ), ], ] ); } /** * Get post lock * * @since 1.6.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success. */ public function get_item( $request ) { /** * Post ID. * * @var int $post_id */ $post_id = $request['id']; $lock = $this->get_lock( $post_id ); return $this->prepare_item_for_response( $lock, $request ); } /** * Update post lock * * @since 1.6.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success. */ public function update_item( $request ) { if ( ! \function_exists( '\wp_set_post_lock' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } /** * Post ID. * * @var int $post_id */ $post_id = $request['id']; wp_set_post_lock( $post_id ); $lock = $this->get_lock( $post_id ); return $this->prepare_item_for_response( $lock, $request ); } /** * Delete post lock * * @since 1.6.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response Response object on success. */ public function delete_item( $request ): WP_REST_Response { /** * Post ID. * * @var int $post_id */ $post_id = $request['id']; $lock = $this->get_lock( $post_id ); $previous = $this->prepare_item_for_response( $lock, $request ); $result = delete_post_meta( $post_id, '_edit_lock' ); $data = []; if ( ! is_wp_error( $previous ) ) { $data = $previous->get_data(); } $response = new WP_REST_Response(); $response->set_data( [ 'deleted' => $result, 'previous' => $data, ] ); return $response; } /** * Checks if a given request has access to read a lock. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ public function get_item_permissions_check( $request ) { return $this->parent_controller->update_item_permissions_check( $request ); } /** * Checks if a given request has access to update a lock. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. */ public function update_item_permissions_check( $request ) { return $this->parent_controller->update_item_permissions_check( $request ); } /** * Checks if a given request has access to delete a lock. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. */ public function delete_item_permissions_check( $request ) { $result = $this->parent_controller->update_item_permissions_check( $request ); if ( is_wp_error( $result ) ) { return $result; } /** * Post ID. * * @var int $post_id */ $post_id = $request['id']; $lock = $this->get_lock( $post_id ); if ( \is_array( $lock ) && isset( $lock['user'] ) && get_current_user_id() !== (int) $lock['user'] ) { return new \WP_Error( 'rest_cannot_delete_others_lock', __( 'Sorry, you are not allowed delete others lock.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Prepares a single lock output for response. * * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.6.0 * * @param array{time?: int, user?: int}|false $item Lock value, default to false is not set. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param WP_REST_Request $request */ public function prepare_item_for_response( $item, $request ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $nonce = wp_create_nonce( 'wp_rest' ); $lock_data = [ 'locked' => false, 'time' => '', 'user' => [ 'name' => '', 'id' => 0, ], 'nonce' => $nonce, ]; if ( get_option( 'show_avatars' ) ) { $lock_data['user']['avatar'] = []; } if ( ! empty( $item ) ) { /** This filter is documented in wp-admin/includes/ajax-actions.php */ $time_window = apply_filters( 'wp_check_post_lock_window', 150 ); if ( isset( $item['time'] ) && $item['time'] > time() - $time_window ) { $lock_data = [ 'locked' => true, 'time' => $item['time'], 'user' => isset( $item['user'] ) ? (int) $item['user'] : 0, 'nonce' => $nonce, ]; if ( isset( $item['user'] ) ) { $user = get_user_by( 'id', $item['user'] ); if ( $user ) { $lock_data['user'] = [ 'name' => $user->display_name, 'id' => $item['user'], ]; if ( get_option( 'show_avatars' ) ) { $lock_data['user']['avatar'] = rest_get_avatar_urls( $user ); } } } } } $data = []; $check_fields = array_keys( $lock_data ); foreach ( $check_fields as $check_field ) { if ( ! empty( $schema['properties'][ $check_field ] ) && rest_is_field_included( $check_field, $fields ) ) { $data[ $check_field ] = rest_sanitize_value_from_schema( $lock_data[ $check_field ], $schema['properties'][ $check_field ] ); } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); /** * Response object. * * @var WP_REST_Response $response */ $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { if ( $request['id'] ) { $response->add_links( $this->prepare_links( $item, $request['id'] ) ); } } $post_type = $this->story_post_type->get_slug(); /** * Filters the lock data for a response. * * The dynamic portion of the hook name, `$post_type`, refers to the post type slug. * * @since 1.6.0 * * @param WP_REST_Response $response The response object. * @param array|false $item Lock array if available. * @param WP_REST_Request $request Request object. */ return apply_filters( "rest_prepare_{$post_type}_lock", $response, $item, $request ); } /** * Retrieves the post's schema, conforming to JSON Schema. * * @since 1.6.0 * * @return array>> Item schema data. */ public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'lock', 'type' => 'object', 'properties' => [ 'time' => [ 'description' => __( 'Unix time of lock', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], 'nonce' => [ 'description' => __( 'Nonce value', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], ], 'locked' => [ 'description' => __( 'If the current object is locked or not.', 'web-stories' ), 'type' => 'boolean', 'context' => [ 'view', 'edit', 'embed' ], ], 'user' => [ 'description' => __( 'User', 'web-stories' ), 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The ID for the author of the lock.', 'web-stories' ), 'type' => 'integer', 'readonly' => true, 'context' => [ 'view', 'edit', 'embed' ], ], 'name' => [ 'description' => __( 'Display name for the user.', 'web-stories' ), 'type' => 'string', 'readonly' => true, 'context' => [ 'embed', 'view', 'edit' ], ], ], ], ], ]; if ( get_option( 'show_avatars' ) ) { $avatar_properties = []; $avatar_sizes = rest_get_avatar_sizes(); foreach ( $avatar_sizes as $size ) { $avatar_properties[ $size ] = [ /* translators: %d: Avatar image size in pixels. */ 'description' => \sprintf( __( 'Avatar URL with image size of %d pixels.', 'web-stories' ), $size ), 'type' => 'string', 'format' => 'uri', 'context' => [ 'embed', 'view', 'edit' ], ]; } $schema['properties']['user']['properties']['avatar'] = [ 'description' => __( 'Avatar URLs for the user.', 'web-stories' ), 'type' => 'object', 'context' => [ 'embed', 'view', 'edit' ], 'readonly' => true, 'properties' => $avatar_properties, ]; } $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); } /** * Get the lock, if the ID is valid. * * @param int $post_id Supplied ID. * @return array{time?: int, user?: int}|false Lock data or false. */ protected function get_lock( int $post_id ) { /** * Lock data. * * @var string|false $lock */ $lock = get_post_meta( $post_id, '_edit_lock', true ); if ( ! empty( $lock ) ) { [ $time, $user ] = explode( ':', $lock ); if ( $time && $user ) { return [ 'time' => (int) $time, 'user' => (int) $user, ]; } } return false; } /** * Prepares links for the request. * * @param array{time?: int, user?: int}|false $lock Lock state. * @param int $post_id Post object ID. * @return array{self: array{href?: string}, author?: array{href: string, embeddable: true}} Links for the given term. */ protected function prepare_links( $lock, int $post_id ): array { $base = $this->namespace . '/' . $this->rest_base; $links = [ 'self' => [ 'href' => rest_url( trailingslashit( $base ) . $post_id . '/lock' ), ], ]; if ( ! empty( $lock ) ) { /** This filter is documented in wp-admin/includes/ajax-actions.php */ $time_window = apply_filters( 'wp_check_post_lock_window', 150 ); if ( isset( $lock['time'] ) && $lock['time'] > time() - $time_window && isset( $lock['user'] ) ) { $links['author'] = [ 'href' => rest_url( \sprintf( '%s/%s/%s', $this->namespace, 'users', $lock['user'] ) ), 'embeddable' => true, ]; } } return $links; } } ================================================ FILE: includes/REST_API/Stories_Media_Controller.php ================================================ namespace = 'web-stories/v1'; $this->types = $types; } /** * Register the service. * * @since 1.7.0 */ public function register(): void { $this->register_routes(); } /** * Get the action to use for registering the service. * * @since 1.7.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'rest_api_init'; } /** * Get the action priority to use for registering the service. * * @since 1.7.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 100; } /** * Retrieves a collection of media. * * Read _web_stories_envelope param to envelope response. * * @since 1.0.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function get_items( $request ) { $response = parent::get_items( $request ); if ( $request['_web_stories_envelope'] && ! is_wp_error( $response ) ) { $embed = $request['_embed'] ?? false; $embed = $embed ? rest_parse_embed_param( $embed ) : false; $response = rest_get_server()->envelope_response( $response, $embed ); } return $response; } /** * Creates a single attachment. * * Override the existing method so we can set parent id. * * @since 1.2.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function create_item( $request ) { // WP_REST_Attachments_Controller doesn't allow setting an attachment as the parent post. // Hence we are working around this here. $parent_post = ! empty( $request['post'] ) ? $request['post'] : null; $original_id = ! empty( $request['original_id'] ) ? $request['original_id'] : null; unset( $request['post'] ); $response = parent::create_item( $request ); if ( ( ! $parent_post && ! $original_id ) || is_wp_error( $response ) ) { return $response; } /** * Response data. * * @var array $data */ $data = $response->get_data(); /** * Post ID. * * @var int $post_id */ $post_id = $data['id']; $attachment = $this->process_post( $post_id, $parent_post, $original_id ); if ( is_wp_error( $attachment ) ) { return $attachment; } $new_response = $this->prepare_item_for_response( $attachment, $request ); $data = $new_response->get_data(); $response->set_data( $data ); return $response; } /** * Retrieves the query params for the posts collection. * * @since 1.0.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['_web_stories_envelope'] = [ 'description' => __( 'Envelope request for preloading.', 'web-stories' ), 'type' => 'boolean', 'default' => false, ]; return $query_params; } /** * Prepares a single attachment output for response. * * @since 1.7.2 * * @param WP_Post $post Attachment object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $post, $request ): WP_REST_Response { $response = parent::prepare_item_for_response( $post, $request ); /** * Response data. * * @var array|bool> $data * @phpstan-var ResponseData $data */ $data = $response->get_data(); $fields = $this->get_fields_for_response( $request ); if ( rest_is_field_included( 'media_details', $fields ) ) { // Could also be a stdClass if empty. $data['media_details'] = (array) $data['media_details']; if ( empty( $data['media_details']['width'] ) ) { $data['media_details']['width'] = 150; } if ( empty( $data['media_details']['height'] ) ) { $data['media_details']['height'] = 150; } } $response->set_data( $data ); /** * Filters an attachment returned from the REST API. * * Allows modification of the attachment right before it is returned. * * Note the filter is run after rest_prepare_attachment is run. This filter is designed to only target web stories rest api requests. * * @since 1.7.2 * * @param WP_REST_Response $response The response object. * @param WP_Post $post The original attachment post. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'web_stories_rest_prepare_attachment', $response, $post, $request ); } /** * Retrieves the attachment's schema, conforming to JSON Schema. * * Removes some unneeded fields to improve performance by * avoiding some expensive database queries. * * @since 1.10.0 * * @return array>> Item schema data. */ public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $schema = parent::get_item_schema(); unset( $schema['properties']['permalink_template'], $schema['properties']['generated_slug'], $schema['properties']['description'] ); $schema['properties']['original_id'] = [ 'description' => __( 'Unique identifier for original attachment id.', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], ]; $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); } /** * Process post to update attribute. * * @since 1.11.0 * * @param int $post_id Post id. * @param int|null $parent_post New post parent. Default null. * @param int|null $original_id Original id to copy data from. Default null. * @return WP_Post|WP_Error */ protected function process_post( $post_id, $parent_post, $original_id ) { $args = [ 'ID' => $post_id ]; if ( $parent_post ) { $args['post_parent'] = $parent_post; } if ( $original_id ) { $attachment_post = $this->get_post( (int) $original_id ); if ( is_wp_error( $attachment_post ) ) { return $attachment_post; } $args['post_content'] = $attachment_post->post_content; $args['post_excerpt'] = $attachment_post->post_excerpt; $args['post_title'] = $attachment_post->post_title; $meta_fields = [ '_wp_attachment_image_alt', Base_Color::BASE_COLOR_POST_META_KEY ]; foreach ( $meta_fields as $meta_field ) { /** * Meta value. * * @var string $value */ $value = get_post_meta( $original_id, $meta_field, true ); if ( ! empty( $value ) ) { // update_post_meta() expects slashed. update_post_meta( $post_id, $meta_field, wp_slash( $value ) ); } } } $attachment_id = wp_update_post( $args, true ); if ( is_wp_error( $attachment_id ) ) { if ( 'db_update_error' === $attachment_id->get_error_code() ) { $attachment_id->add_data( [ 'status' => 500 ] ); } else { $attachment_id->add_data( [ 'status' => 400 ] ); } return $attachment_id; } return $this->get_post( $attachment_id ); } /** * Filter request by allowed mime types. * * @since 1.2.0 * * @param array $prepared_args Optional. Array of prepared arguments. Default empty array. * @param WP_REST_Request $request Optional. Request to prepare items for. * @return array Array of query arguments. */ protected function prepare_items_query( $prepared_args = [], $request = null ): array { $query_args = parent::prepare_items_query( $prepared_args, $request ); if ( empty( $request['mime_type'] ) && empty( $request['media_type'] ) ) { $media_types = $this->get_media_types(); $media_type_mimes = array_values( $media_types ); $media_type_mimes = array_filter( $media_type_mimes ); $media_type_mimes = array_merge( ...$media_type_mimes ); $query_args['post_mime_type'] = $media_type_mimes; } /** * Filters WP_Query arguments when querying posts via the REST API. * * @since 1.10.0 * * @see WP_Query * * @param array $args Array of arguments for WP_Query. * @param WP_REST_Request|null $request The REST API request. */ return apply_filters( 'web_stories_rest_attachment_query', $query_args, $request ); } /** * Retrieves the supported media types. * * Media types are considered the MIME type category. * * @since 1.2.0 * * @return array Array of supported media types. */ protected function get_media_types(): array { $mime_type = $this->types->get_allowed_mime_types(); // TODO: Update once audio elements are supported. $mime_type['audio'] = []; unset( $mime_type['caption'] ); return $mime_type; } } ================================================ FILE: includes/REST_API/Stories_Settings_Controller.php ================================================ namespace = 'web-stories/v1'; } /** * Register the service. * * @since 1.7.0 */ public function register(): void { $this->register_routes(); } /** * Get the action to use for registering the service. * * @since 1.7.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'rest_api_init'; } /** * Get the action priority to use for registering the service. * * @since 1.7.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 100; } } ================================================ FILE: includes/REST_API/Stories_Taxonomies_Controller.php ================================================ namespace = 'web-stories/v1'; } /** * Retrieves all public taxonomies. * * Adds support for filtering by the hierarchical attribute. * * @since 1.22.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. * * @phpstan-param WP_REST_Request $request */ public function get_items( $request ) { // Retrieve the list of registered collection query parameters. $registered = $this->get_collection_params(); if ( isset( $registered['type'] ) && ! empty( $request['type'] ) ) { $type = $request['type']; $taxonomies = get_object_taxonomies( $type, 'objects' ); } else { $taxonomies = get_taxonomies( [], 'objects' ); } $filters = [ 'hierarchical', 'show_ui' ]; foreach ( $filters as $filter ) { if ( isset( $registered[ $filter ], $request[ $filter ] ) ) { $taxonomies = wp_filter_object_list( $taxonomies, [ $filter => (bool) $request[ $filter ] ] ); } } $data = []; /** * Taxonomy. * * @var WP_Taxonomy $value */ foreach ( $taxonomies as $tax_type => $value ) { if ( empty( $value->show_in_rest ) || ( 'edit' === $request['context'] && ! current_user_can( $value->cap->assign_terms ) ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { continue; } $tax = $this->prepare_item_for_response( $value, $request ); $tax = $this->prepare_response_for_collection( $tax ); $data[ $tax_type ] = $tax; } if ( empty( $data ) ) { // Response should still be returned as a JSON object when it is empty. $data = (object) $data; } return rest_ensure_response( $data ); } /** * Retrieves the query params for collections. * * Adds support for filtering by the hierarchical attribute. * * @since 1.22.0 * * @return array> Collection parameters. */ public function get_collection_params(): array { $query_params = parent::get_collection_params(); $query_params['per_page']['default'] = 100; $query_params['hierarchical'] = [ 'description' => __( 'Whether to show only hierarchical taxonomies.', 'web-stories' ), 'type' => 'boolean', ]; $query_params['show_ui'] = [ 'description' => __( 'Whether to show only show taxonomies that allow a UI in the admin.', 'web-stories' ), 'type' => 'boolean', ]; return $query_params; } /** * Register the service. * * @since 1.12.0 */ public function register(): void { $this->register_routes(); } /** * Get the action to use for registering the service. * * @since 1.12.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'rest_api_init'; } /** * Get the action priority to use for registering the service. * * @since 1.12.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 100; } } ================================================ FILE: includes/REST_API/Stories_Terms_Controller.php ================================================ Links for the given term. */ protected function prepare_links( $term ): array { $links = parent::prepare_links( $term ); $links['about'] = [ 'href' => rest_url( \sprintf( '%s/taxonomies/%s', $this->namespace, $this->taxonomy ) ), ]; return $links; } } ================================================ FILE: includes/REST_API/Stories_Users_Controller.php ================================================ namespace = 'web-stories/v1'; $this->story_post_type = $story_post_type; } /** * Register the service. * * @since 1.7.0 */ public function register(): void { $this->register_routes(); } /** * Get the action to use for registering the service. * * @since 1.7.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'rest_api_init'; } /** * Get the action priority to use for registering the service. * * @since 1.7.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 100; } /** * Permissions check for getting all users. * * Allows edit_posts capabilities queries for stories if the user has the same cap, * enabling them to see the users dropdown. * * @since 1.28.1 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, otherwise WP_Error object. */ public function get_items_permissions_check( $request ) { /** * The edit_posts capability. * * @var string $edit_posts */ $edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' ); if ( ! empty( $request['capabilities'] ) && [ $edit_posts ] === $request['capabilities'] && current_user_can( $edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { unset( $request['capabilities'] ); } return parent::get_items_permissions_check( $request ); } /** * Retrieves all users. * * Includes a workaround for a shortcoming in WordPress core where * only users with published posts are returned if not an admin * and not using a 'who' -> 'authors' query, since we're using * the recommended capabilities queries instead. * * @since 1.28.1 * * @link https://github.com/WordPress/wordpress-develop/blob/008277583be15ee1738fba51ad235af5bbc5d721/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php#L308-L312 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { /** * The edit_posts capability. * * @var string $edit_posts */ $edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' ); if ( ! isset( $request['has_published_posts'] ) && ! empty( $request['capabilities'] ) && [ $edit_posts ] === $request['capabilities'] && current_user_can( $edit_posts ) // phpcs:ignore WordPress.WP.Capabilities.Undetermined ) { add_filter( 'rest_user_query', [ $this, 'filter_query_args' ] ); $response = parent::get_items( $request ); remove_filter( 'rest_user_query', [ $this, 'filter_query_args' ] ); return $response; } return parent::get_items( $request ); } /** * Filters WP_User_Query arguments when querying users via the REST API. * * Removes 'has_published_posts' query argument. * * @since 1.28.1 * * @param array $prepared_args Array of arguments for WP_User_Query. * @return array Filtered query args. */ public function filter_query_args( array $prepared_args ): array { unset( $prepared_args['has_published_posts'] ); return $prepared_args; } /** * Checks if a given request has access to read a user. * * Same as the parent function but with using a cached version of {@see count_user_posts()}. * * @since 1.10.0 * * @see WP_REST_Users_Controller::get_item_permissions_check() * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object. */ public function get_item_permissions_check( $request ) { /** * User ID. * * @var int $user_id */ $user_id = $request['id']; $user = $this->get_user( $user_id ); if ( is_wp_error( $user ) ) { return $user; } if ( get_current_user_id() === $user->ID ) { return true; } if ( 'edit' === $request['context'] && ! current_user_can( 'list_users' ) ) { return new \WP_Error( 'rest_user_cannot_view', __( 'Sorry, you are not allowed to list users.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } if ( ! $this->user_posts_count_public( $user->ID, $this->story_post_type->get_slug() ) && ! current_user_can( 'edit_user', $user->ID ) && ! current_user_can( 'list_users' ) ) { return new \WP_Error( 'rest_user_cannot_view', __( 'Sorry, you are not allowed to list users.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Number of posts user has written. * * Wraps {@see count_user_posts()} results in a cache. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.10.0 * * @link https://core.trac.wordpress.org/ticket/39242 * * @param int $userid User ID. * @param string $post_type Optional. Single post type or array of post types to count the number of posts for. Default 'post'. * @return int Number of posts the user has written in this post type. */ protected function user_posts_count_public( int $userid, string $post_type = 'post' ): int { $cache_key = "count_user_{$post_type}_{$userid}"; $cache_group = 'user_posts_count'; /** * Post count. * * @var string|false $count */ $count = wp_cache_get( $cache_key, $cache_group ); if ( false === $count ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.count_user_posts_count_user_posts $count = count_user_posts( $userid, $post_type, true ); wp_cache_add( $cache_key, $count, $cache_group ); } return (int) $count; } } ================================================ FILE: includes/Register_Widget.php ================================================ stories = $stories; } /** * Register Widgets. * * @since 1.6.0 */ public function register(): void { add_action( 'widgets_init', [ $this, 'register_widgets' ] ); add_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_widget' ] ); add_filter( 'body_class', [ $this, 'body_class' ] ); } /** * Register widget. * * @since 1.9.0 */ public function register_widgets(): void { register_widget( $this->stories ); } /** * Hide widget stories from legacy widget list. * * @since 1.9.0 * * @param array|mixed $widget_types An array of excluded widget-type IDs. * @return array|mixed * * @template T * * @phpstan-return ($widget_types is array ? array : mixed) */ public function hide_widget( $widget_types ) { if ( ! \is_array( $widget_types ) ) { return $widget_types; } $widget_types[] = $this->stories->id_base; return $widget_types; } /** * Filters the list of CSS body class names for embedded iframes to add a class. * * @since 1.9.0 * * @param string[]|mixed $classes An array of body class names. * @return array|mixed * * @template T * * @phpstan-return ($classes is array ? array : mixed) */ public function body_class( $classes ) { if ( ! \is_array( $classes ) ) { return $classes; } if ( is_admin() && \defined( 'IFRAME_REQUEST' ) && IFRAME_REQUEST ) { $classes[] = 'ws-legacy-widget-preview'; } return $classes; } /** * Get the action priority to use for registering the service. * * @since 1.38.1 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { // Because wp_widgets_init() runs on priority 1. return 0; } } ================================================ FILE: includes/Remove_Transients.php ================================================ delete_transients(); return; } $this->delete_network_transients(); $site_ids = get_sites( [ 'fields' => 'ids', 'number' => 0, 'update_site_cache' => false, 'update_site_meta_cache' => false, ] ); foreach ( $site_ids as $site_id ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog switch_to_blog( $site_id ); $this->delete_transients(); } restore_current_blog(); } /** * Delete transients. * * @since 1.26.0 */ protected function delete_transients(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $transients = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_' . self::PREFIX, '_transient_timeout_' . self::PREFIX ) ); if ( ! empty( $transients ) ) { array_map( 'delete_option', (array) $transients ); } } /** * Delete transients on multisite. * * @since 1.26.0 */ protected function delete_network_transients(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $transients = $wpdb->get_col( $wpdb->prepare( "SELECT meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key LIKE %s", '_site_transient_' . self::PREFIX, '_site_transient_timeout_' . self::PREFIX ) ); if ( ! empty( $transients ) ) { array_map( 'delete_site_option', (array) $transients ); } } } ================================================ FILE: includes/Renderer/Archives.php ================================================ assets = $assets; $this->amp_story_player_assets = $amp_story_player_assets; $this->context = $context; } /** * Filter content and excerpt for search and post type archive. * * @since 1.7.0 */ public function register(): void { add_filter( 'the_content', [ $this, 'embed_player' ], PHP_INT_MAX ); add_filter( 'the_excerpt', [ $this, 'embed_player' ], PHP_INT_MAX ); } /** * Change the content to an embedded player * * @since 1.0.0 * * @param string|mixed $content Current content of filter. * @return string|mixed */ public function embed_player( $content ) { $post = get_post(); if ( is_feed() ) { return $content; } if ( ! is_search() && ! is_post_type_archive( Story_Post_Type::POST_TYPE_SLUG ) ) { return $content; } if ( $post instanceof WP_Post && Story_Post_Type::POST_TYPE_SLUG === $post->post_type ) { $story = new Story(); $story->load_from_post( $post ); $embed = new Embed( $story, $this->assets, $this->context ); $content = $embed->render(); } return $content; } } ================================================ FILE: includes/Renderer/Feed.php ================================================ post_type ) { $story = new Story(); $story->load_from_post( $post ); $image = new Image( $story ); $content = $image->render(); } return $content; } } ================================================ FILE: includes/Renderer/Oembed.php ================================================ post_type ) { return $output; } if ( ! has_post_thumbnail( $post ) ) { return $output; } $new_data = $this->get_embed_height_width( $width ); $new_width = $new_data['width']; $new_height = $new_data['height']; return str_replace( [ "width=\"$width\"", "height=\"$height\"" ], [ "width=\"$new_width\"", "height=\"$new_height\"", ], $output ); } /** * Filters the oEmbed response data for a specific post. * * For stories, changes the aspect ratio from 16/9 to 3/5. * * @since 1.7.0 * * @param array|mixed $data The response data. * @param WP_Post $post The post object. * @param int $width The requested width. * @return array|mixed The modified response data. * * @template T * * @phpstan-return ($data is array ? array : mixed) */ public function filter_oembed_response_data( $data, WP_Post $post, int $width ) { if ( Story_Post_Type::POST_TYPE_SLUG !== $post->post_type ) { return $data; } if ( ! has_post_thumbnail( $post ) ) { return $data; } if ( ! \is_array( $data ) ) { return $data; } $new_data = $this->get_embed_height_width( $width ); return array_merge( $data, $new_data ); } /** * Generate new height and width for embed. * * @since 1.7.0 * * @param int $old_width Old width, used to generate new height and width. * @return array{width: int, height: int} */ protected function get_embed_height_width( int $old_width ): array { /** This filter is documented in wp-includes/embed.php */ $min_max_width = apply_filters( 'oembed_min_max_width', [ 'min' => 200, 'max' => 360, ] ); $width = (int) min( max( $min_max_width['min'], $old_width ), $min_max_width['max'] ); $height = (int) max( ceil( $width / 3 * 5 ), 330 ); return compact( 'width', 'height' ); } } ================================================ FILE: includes/Renderer/Single.php ================================================ context = $context; } /** * Initializes the Single logic. * * @since 1.7.0 */ public function register(): void { // This is hooked to both the `template_include` and the `single_template` filters, // as an additional measure to improve compatibility with themes // overriding the template hierarchy in an unusual way, like the Sage theme does. add_filter( 'single_template', [ $this, 'filter_template_include' ], PHP_INT_MAX ); add_filter( 'template_include', [ $this, 'filter_template_include' ], PHP_INT_MAX ); add_filter( 'show_admin_bar', [ $this, 'show_admin_bar' ] ); // phpcs:ignore WordPressVIPMinimum.UserExperience.AdminBarRemoval.RemovalDetected } /** * Filters the path of the queried template for single stories. * * @since 1.0.0 * * @param string|mixed $template Absolute path to template file. * @return string|mixed Filtered template file path. */ public function filter_template_include( $template ) { if ( $this->context->is_web_story() ) { return WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/frontend/single-web-story.php'; } return $template; } /** * Filter if show admin bar on single post type. * * @since 1.0.0 * * @param bool|mixed $show Current value of filter. * @return bool|mixed */ public function show_admin_bar( $show ) { if ( $this->context->is_web_story() ) { $show = false; } return $show; } } ================================================ FILE: includes/Renderer/Stories/Carousel_Renderer.php ================================================ load_assets(); } /** * Enqueue assets. * * @since 1.5.0 * * @return void */ public function load_assets(): void { parent::load_assets(); $this->assets->register_script_asset( self::SCRIPT_HANDLE, [], false ); wp_localize_script( self::SCRIPT_HANDLE, 'webStoriesCarouselSettings', $this->get_carousel_settings() ); } /** * Renders the stories output for given attributes. * * @SuppressWarnings("PHPMD.UnusedLocalVariable") * * @since 1.5.0 * * @param array $args Array of rendering arguments. * @return string Rendered stories output. */ public function render( array $args = [] ): string { if ( ! $this->valid() ) { return ''; } parent::render( $args ); $container_classes = $this->get_container_classes(); $container_styles = $this->get_container_styles(); ob_start(); ?>
maybe_render_archive_link(); if ( ! $this->context->is_amp() ) { $this->assets->enqueue_script( self::SCRIPT_HANDLE ); ?>
render_single_story_content(); $this->next(); }, $this->stories ); ?>
|string> Carousel settings. */ protected function get_carousel_settings(): array { return [ 'config' => [ 'isRTL' => is_rtl(), ], 'publicPath' => $this->assets->get_base_url( 'assets/js/' ), ]; } } ================================================ FILE: includes/Renderer/Stories/FieldState/BaseFieldState.php ================================================ has_archive = (bool) $story_post_type->get_has_archive(); } /** * Image alignment FieldState. * * @since 1.5.0 * * @return Field */ public function image_alignment() { return new BaseField( [ 'label' => __( 'Image Alignment', 'web-stories' ), 'show' => false, 'hidden' => true, ] ); } /** * Excerpt FieldState. * * @since 1.5.0 * * @return Field */ public function excerpt() { return new BaseField( [ 'label' => __( 'Display Excerpt', 'web-stories' ), 'show' => false, 'hidden' => true, ] ); } /** * Author Field State. * * @since 1.5.0 * * @return Field */ public function author() { return new BaseField( [ 'label' => __( 'Display Author', 'web-stories' ), 'show' => true, 'hidden' => false, ] ); } /** * Date field state. * * @since 1.5.0 * * @return Field */ public function date() { return new BaseField( [ 'label' => __( 'Display Date', 'web-stories' ), 'show' => false, 'hidden' => false, ] ); } /** * Archive link field state. * * @since 1.5.0 * * @return Field */ public function archive_link() { return new BaseField( [ 'label' => __( 'Display Archive Link', 'web-stories' ), 'show' => $this->has_archive, 'hidden' => ! $this->has_archive, ] ); } /** * Title field state. * * @since 1.5.0 * * @return Field */ public function title() { return new BaseField( [ 'label' => __( 'Display Title', 'web-stories' ), 'show' => true, 'hidden' => true, ] ); } /** * Sharp corners field state. * * @since 1.5.0 * * @return Field */ public function sharp_corners() { return new BaseField( [ 'label' => __( 'Use Sharp Corners', 'web-stories' ), 'show' => false, 'hidden' => false, ] ); } /** * Circle size field. * * @since 1.5.0 * * @return BaseField */ public function circle_size() { return new BaseField( [ 'label' => __( 'Circle Size', 'web-stories' ), 'show' => false, ] ); } /** * Number of columns field. * * @since 1.5.0 * * @return BaseField */ public function number_of_columns() { return new BaseField( [ 'label' => __( 'Number of Columns', 'web-stories' ), 'show' => false, ] ); } /** * Prepare a field object. * * @since 1.5.0 * * @param array $args Arguments to build field. * @return BaseField */ protected function prepare_field( array $args ): BaseField { return new BaseField( $args ); } } ================================================ FILE: includes/Renderer/Stories/FieldState/CarouselView.php ================================================ label(); return $this->prepare_field( [ 'label' => $label, 'show' => false, 'hidden' => false, ] ); } } ================================================ FILE: includes/Renderer/Stories/FieldState/CircleView.php ================================================ label(); return $this->prepare_field( [ 'label' => $label, 'show' => false, 'hidden' => false, ] ); } /** * Author field. * * @return \Google\Web_Stories\Interfaces\Field|BaseField */ public function author() { $label = parent::author()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => false, 'hidden' => true, ] ); } /** * Date field. * * @return \Google\Web_Stories\Interfaces\Field|BaseField */ public function date() { $label = parent::date()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => false, 'hidden' => true, ] ); } /** * Sharp corners field. * * @return \Google\Web_Stories\Interfaces\Field|BaseField */ public function sharp_corners() { $label = parent::sharp_corners()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => false, 'hidden' => true, ] ); } /** * Circle size field. * * @return BaseField */ public function circle_size() { $label = parent::circle_size()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => true, 'hidden' => false, ] ); } } ================================================ FILE: includes/Renderer/Stories/FieldState/GridView.php ================================================ label(); return $this->prepare_field( [ 'label' => $label, 'show' => true, 'hidden' => false, ] ); } } ================================================ FILE: includes/Renderer/Stories/FieldState/ListView.php ================================================ label(); return $this->prepare_field( [ 'label' => $label, 'show' => true, 'hidden' => false, ] ); } /** * Author field. * * @since 1.5.0 * * @return \Google\Web_Stories\Interfaces\Field|BaseField */ public function date() { $label = parent::date()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => true, 'hidden' => false, ] ); } /** * Image alignment field. * * @since 1.5.0 * * @return \Google\Web_Stories\Interfaces\Field|BaseField */ public function image_alignment() { $label = parent::image_alignment()->label(); return $this->prepare_field( [ 'label' => $label, 'show' => true, 'hidden' => false, ] ); } } ================================================ FILE: includes/Renderer/Stories/FieldStateFactory/Factory.php ================================================ injector = $injector; } /** * Returns field state for the provided view type. * * @since 1.5.0 * * @param string $view View Type. * @return FieldState */ public function get_field( $view = 'grid' ) { switch ( $view ) { case 'grid': /** * GridView instance. * * @var FieldState */ $field_state = $this->injector->make( GridView::class ); break; case 'list': /** * ListView instance. * * @var FieldState */ $field_state = $this->injector->make( ListView::class ); break; case 'circles': /** * CircleView instance. * * @var FieldState */ $field_state = $this->injector->make( CircleView::class ); break; case 'carousel': /** * CarouselView instance. * * @var FieldState */ $field_state = $this->injector->make( CarouselView::class ); break; default: /** * CircleView instance. * * @var FieldState $default_field_state */ $default_field_state = $this->injector->make( CircleView::class ); /** * Filters the field state object. * * This depicts * * @since 1.5.0 * * @param FieldState $default_field_state Field state object. */ $field_state = apply_filters( 'web_stories_default_field_state', $default_field_state ); } return $field_state; } } ================================================ FILE: includes/Renderer/Stories/Fields/BaseField.php ================================================ $args Arguments. */ public function __construct( array $args ) { $this->label = isset( $args['label'] ) ? (string) $args['label'] : ''; $this->hidden = ! isset( $args['hidden'] ) || $args['hidden']; $this->show = ! isset( $args['show'] ) || $args['show']; } /** * Label for the field. * * @return string */ public function label(): string { return $this->label; } /** * Flag for field display. * * @return bool */ public function show(): bool { return $this->show; } /** * Whether the field is hidden. * * @return bool */ public function hidden(): bool { return $this->hidden; } } ================================================ FILE: includes/Renderer/Stories/Generic_Renderer.php ================================================ load_assets(); } /** * Renders the stories output for given attributes. * * @SuppressWarnings("PHPMD.UnusedLocalVariable") * * @since 1.5.0 * * @param array $args Array of rendering arguments. * @return string Rendered stories output. */ public function render( array $args = [] ): string { if ( ! $this->valid() ) { return ''; } parent::render( $args ); $container_classes = $this->get_container_classes(); $container_styles = $this->get_container_styles(); ob_start(); ?>
render_single_story_content(); $this->next(); }, $this->stories ); $this->maybe_render_archive_link(); ?>
get_view_type(); $content = (string) ob_get_clean(); $content = wp_interactivity_process_directives( $content ); /** * Filters the Generic renderer stories content. * * The dynamic portion of the hook `$this->get_view_type()` refers to the story view type. * * @since 1.5.0 * * @param string $content Stories content. */ return apply_filters( "web_stories_{$view_type}_renderer_stories_content", $content ); } } ================================================ FILE: includes/Renderer/Stories/Renderer.php ================================================ */ abstract class Renderer implements RenderingInterface, Iterator { /** * Web Stories stylesheet handle. */ public const STYLE_HANDLE = 'web-stories-list-styles'; /** * Number of instances invoked. Kept it static to keep track. */ protected static int $instances = 0; /** * Assets instance. * * @var Assets Assets instance. */ protected Assets $assets; /** * Context instance. * * @var Context Context instance. */ protected Context $context; /** * Object ID for the Renderer class. * To enable support for multiple carousels and lightboxes * on the same page, we needed to identify each Renderer instance. * * This variable is used to add appropriate class to the Web Stories * wrapper. */ protected int $instance_id = 0; /** * Story_Query instance. * * @var Story_Query Story_Query instance. */ protected Story_Query $query; /** * Story attributes * * @var array An array of story attributes. * @phpstan-var StoryAttributes */ protected array $attributes = []; /** * Story posts. * * @var Story[] An array of story posts. */ protected array $stories = []; /** * Holds required html for the lightbox. * * @var string A string of lightbox markup. */ protected string $lightbox_html = ''; /** * Pointer to iterate over stories. */ private int $position = 0; /** * Height for displaying story. */ protected int $height = 308; /** * Width for displaying story. */ protected int $width = 185; /** * Whether content overlay is enabled for story. */ protected bool $content_overlay; /** * Constructor * * @since 1.5.0 * * @param Story_Query $query Story_Query instance. */ public function __construct( Story_Query $query ) { $this->query = $query; $this->attributes = $this->query->get_story_attributes(); $this->content_overlay = $this->attributes['show_title'] || $this->attributes['show_date'] || $this->attributes['show_author'] || $this->attributes['show_excerpt']; // TODO, find a way to inject this a cleaner way. $injector = Services::get_injector(); /** * Assets instance. * * @var Assets $assets Assets instance. */ $assets = $injector->make( Assets::class ); /** * Context instance. * * @var Context $context Context instance. */ $context = $injector->make( Context::class ); $this->assets = $assets; $this->context = $context; } /** * Output markup for amp stories. * * @since 1.5.0 * * @param array $args Array of rendering arguments. * @return string */ public function render( array $args = [] ): string { ++self::$instances; $this->instance_id = self::$instances; foreach ( $args as $key => $val ) { if ( property_exists( $this, $key ) ) { $this->{$key} = $val; } } return ''; } /** * Retrieve current story. * * @since 1.5.0 * * @return Story|null */ public function current(): ?Story { return $this->stories[ $this->position ] ?? null; } /** * Retrieve next story. * * @since 1.5.0 * * @return void */ public function next(): void { ++$this->position; } /** * Retrieve the key for current node in list. * * @since 1.5.0 * * @return bool|float|int|string|void|null */ #[\ReturnTypeWillChange] public function key() { return $this->position; } /** * Check if current position is valid. * * @since 1.5.0 * * @return bool */ public function valid(): bool { return isset( $this->stories[ $this->position ] ); } /** * Reset pointer to start of the list. * * @since 1.5.0 * * @return void */ public function rewind(): void { $this->position = 0; } /** * Perform initial setup for object. * * @since 1.5.0 * * @return void */ public function init(): void { $this->stories = array_filter( array_map( [ $this, 'prepare_stories' ], $this->query->get_stories() ) ); add_action( 'wp_footer', [ $this, 'render_stories_lightbox' ] ); add_action( 'amp_post_template_footer', [ $this, 'render_stories_lightbox' ] ); } /** * Initializes renderer functionality. * * @since 1.5.0 * * @return void */ public function load_assets(): void { if ( wp_style_is( self::STYLE_HANDLE, 'registered' ) ) { return; } // Web Stories styles for AMP and non-AMP pages. $this->assets->register_style_asset( self::STYLE_HANDLE ); if ( \defined( 'AMPFORWP_VERSION' ) ) { add_action( 'amp_post_template_css', [ $this, 'add_amp_post_template_css' ] ); } } /** * Prints required inline CSS when using the AMP for WP plugin. * * @since 1.13.0 * * @return void */ public function add_amp_post_template_css(): void { $path = $this->assets->get_base_path( \sprintf( 'assets/css/%s%s.css', self::STYLE_HANDLE, is_rtl() ? '-rtl' : '' ) ); if ( is_readable( $path ) ) { $css = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown echo $css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** * Returns story item data. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.5.0 * * @param object $post Array of post objects. * @return Story|null Story object or null if given post */ public function prepare_stories( $post ): ?Story { if ( ! $post instanceof WP_Post ) { return null; } $story_data = []; // TODO: get from field state instead. if ( ! $this->is_view_type( 'circles' ) ) { if ( isset( $this->attributes['show_author'] ) && true === $this->attributes['show_author'] ) { $author_id = absint( get_post_field( 'post_author', $post->ID ) ); $story_data['author'] = get_the_author_meta( 'display_name', $author_id ); } if ( isset( $this->attributes['show_date'] ) && true === $this->attributes['show_date'] ) { /* translators: Date format, see https://www.php.net/manual/en/datetime.format.php */ $story_data['date'] = get_the_date( __( 'M j, Y', 'web-stories' ), $post->ID ); } } $story_data['classes'] = $this->get_single_story_classes(); $story = new Story( $story_data ); $story->load_from_post( $post ); return $story; } /** * Render story markup. * * @since 1.5.0 * * @return void */ public function render_single_story_content(): void { /** * Story object. * * @var Story $story */ $story = $this->current(); $single_story_classes = $this->get_single_story_classes(); $lightbox_state = 'lightbox' . $story->get_id() . $this->instance_id; // No need to load these styles on admin as editor styles are being loaded by the block. if ( ! is_admin() || ( \defined( 'IFRAME_REQUEST' ) && IFRAME_REQUEST ) ) { // Web Stories Styles for AMP and non-AMP pages. $this->assets->enqueue_style_asset( self::STYLE_HANDLE ); } $this->assets->enqueue_script_module( 'web-stories-embed-view-script-module', $this->assets->get_base_url( 'assets/js/web-stories-block-view.js' ) ); if ( $this->context->is_amp() ) { ?>
render_story_with_poster(); ?>
assets->enqueue_style( AMP_Story_Player_Assets::SCRIPT_HANDLE ); $this->assets->enqueue_script( AMP_Story_Player_Assets::SCRIPT_HANDLE ); ?>
$this->instance_id, ] ); ?> data-wp-on--click="actions.open" data-wp-on-window--popstate="actions.onPopstate" > render_story_with_poster(); ?>
[ [ 'name' => 'close', 'position' => 'start', ], [ 'name' => 'skip-next', ], ], 'behavior' => [ 'autoplay' => false, ], ]; ?>
lightbox_html ); ?>
lightbox_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Generated with properly escaped data. } /** * Renders stories lightbox on 'wp_footer'. * * @return void */ public function render_stories_lightbox(): void { // Return if we don't have anything to render. if ( empty( $this->lightbox_html ) ) { return; } ?>
context->is_amp() ) { $this->render_stories_with_lightbox_amp(); } else { $this->render_stories_with_lightbox(); } ?>
attributes['view_type'] ) && $view_type === $this->attributes['view_type'] ); } /** * Get view type for stories. * * @since 1.5.0 * * @return string */ protected function get_view_type(): string { return ! empty( $this->attributes['view_type'] ) ? $this->attributes['view_type'] : 'circles'; } /** * Renders stories archive link if the 'show_archive_link' attribute is set to true. * * @since 1.5.0 * * @return void */ protected function maybe_render_archive_link(): void { if ( empty( $this->attributes['show_archive_link'] ) || true !== $this->attributes['show_archive_link'] || empty( $this->attributes['archive_link_label'] ) ) { return; } $web_stories_archive = get_post_type_archive_link( Story_Post_Type::POST_TYPE_SLUG ); if ( empty( $web_stories_archive ) ) { return; } ?>
attributes['view_type'] ) ? \sprintf( 'is-view-type-%1$s', $this->attributes['view_type'] ) : 'is-view-type-circles'; if ( $this->is_view_type( 'grid' ) && ! empty( $this->attributes['number_of_columns'] ) ) { $view_classes[] = \sprintf( 'columns-%1$d', $this->attributes['number_of_columns'] ); } if ( ! $this->is_view_type( 'circles' ) && ! empty( $this->attributes['sharp_corners'] ) ) { $view_classes[] = 'is-style-squared'; } else { $view_classes[] = 'is-style-default'; } if ( $this->is_view_type( 'circles' ) && ! empty( $this->attributes['show_title'] ) ) { $view_classes[] = 'has-title'; } if ( $this->is_view_type( 'circles' ) || $this->is_view_type( 'carousel' ) ) { $view_classes[] = 'is-carousel'; } return implode( ' ', $view_classes ); } /** * Gets the classes for renderer container. * * @since 1.5.0 * * @return string */ protected function get_container_classes(): string { $container_classes = []; $container_classes[] = 'web-stories-list'; $container_classes[] = ! empty( $this->attributes['align'] ) ? \sprintf( 'align%1$s', $this->attributes['align'] ) : 'alignnone'; $container_classes[] = ! empty( $this->attributes['class'] ) ? $this->attributes['class'] : ''; if ( ! empty( $this->attributes['show_archive_link'] ) ) { $container_classes[] = 'has-archive-link'; } $container_classes = array_filter( $container_classes ); $view_type_classes = $this->get_view_classes(); return \sprintf( '%1$s %2$s', implode( ' ', $container_classes ), $view_type_classes ); } /** * Gets the single story container classes. * * @since 1.5.0 * * @return string */ protected function get_single_story_classes(): string { $single_story_classes = []; $single_story_classes[] = 'web-stories-list__story'; if ( $this->context->is_amp() ) { $single_story_classes[] = 'web-stories-list__story--amp'; } if ( ! empty( $this->attributes['image_alignment'] ) && ( 'right' === $this->attributes['image_alignment'] ) ) { $single_story_classes[] = 'image-align-right'; } $classes = implode( ' ', $single_story_classes ); /** * Filters the web stories renderer single story classes. * * @since 1.5.0 * * @param string $classes Single story classes. */ return apply_filters( 'web_stories_renderer_single_story_classes', $classes ); } /** * Gets the single story container styles. * * @since 1.5.0 * * @return string Style string. */ protected function get_container_styles(): string { $story_styles = ! empty( $this->attributes['circle_size'] ) && $this->is_view_type( 'circles' ) ? \sprintf( '--ws-circle-size:%1$dpx', $this->attributes['circle_size'] ) : ''; $story_styles .= $this->is_view_type( 'carousel' ) ? \sprintf( '--ws-story-max-width:%1$dpx', $this->width ) : ''; /** * Filters the web stories renderer single story classes. * * @since 1.5.0 * * @param string $story_styles Single story classes. */ return apply_filters( 'web_stories_renderer_container_styles', $story_styles ); } /** * Renders a story with story's poster image. * * @since 1.5.0 * * @return void */ protected function render_story_with_poster(): void { /** * Story object. * * @var Story $story */ $story = $this->current(); $poster_url = $story->get_poster_portrait(); $poster_srcset = $story->get_poster_srcset(); $poster_sizes = $story->get_poster_sizes(); if ( ! $poster_url ) { ?>
render_link_attributes(); ?>> get_title() ); ?>
render_link_attributes(); ?>> <?php echo esc_attr( $story->get_title() ); ?> srcset="" sizes="" loading="lazy" decoding="async" >
get_content_overlay(); if ( ! $this->context->is_amp() ) { $this->generate_lightbox_html( $story ); } else { $this->generate_amp_lightbox_html_amp( $story ); } } /** * Render additional link attributes. * * Allows customization of html attributes in the web stories widget anchor tag loop * Converts array into escaped inline html attributes. * * @since 1.17.0 * * @return void */ protected function render_link_attributes(): void { /** * The current story. * * @var Story $story */ $story = $this->current(); /** * Filters the link attributes added to a story's tag. * * @since 1.17.0 * * @param array $attributes Key value array of attribute name to attribute value. * @param Story $story The current story instance. * @param int $position The current story's position within the list. * @param string $view_type The current view type. */ $attributes = apply_filters( 'web_stories_renderer_link_attributes', [], $story, $this->position, $this->get_view_type() ); $attrs = []; if ( ! empty( $attributes ) ) { foreach ( $attributes as $attribute => $value ) { $attrs[] = wp_kses_one_attr( $attribute . '="' . esc_attr( $value ) . '"', 'a' ); } } $attrs = array_filter( $attrs ); // Filter out empty values rejected by KSES. //phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped echo implode( ' ', $attrs ); } /** * Renders the content overlay markup. * * @since 1.5.0 * * @return void */ protected function get_content_overlay(): void { /** * Story object. * * @var Story $story */ $story = $this->current(); if ( empty( $this->content_overlay ) ) { return; } ?>
attributes['show_title'] ) ) { ?>
get_title() ); ?>
attributes['show_excerpt'] ) ) { ?>
get_excerpt() ); ?>
get_author() ) ) { ?>
get_author() ) ); ?>
get_date() ) ) { ?>
render_link_attributes(); ?>>get_title() ); ?> lightbox_html .= ob_get_clean(); } /** * Markup for the lightbox used on AMP pages. * * @since 1.5.0 * * @param Story $story Current Story. * @return void */ protected function generate_amp_lightbox_html_amp( $story ): void { // Start collecting markup for the lightbox stories. This way we don't have to re-run the loop. ob_start(); $lightbox_state = 'lightbox' . $story->get_id() . $this->instance_id; $lightbox_id = 'lightbox-' . $story->get_id() . $this->instance_id; ?> lightbox_html .= ob_get_clean(); } } ================================================ FILE: includes/Renderer/Story/Embed.php ================================================ assets = $assets; $this->story = $story; $this->context = $context; } /** * Renders the block output in default context. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.0.0 * * @param array $args Array of Argument to render. * @return string Rendered block type output. */ public function render( array $args = [] ): string { $defaults = [ 'align' => 'none', 'class' => 'wp-block-web-stories-embed', 'height' => 600, 'width' => 360, ]; $args = wp_parse_args( $args, $defaults ); $align = \sprintf( 'align%s', $args['align'] ); $class = $args['class']; $url = $this->story->get_url(); $title = $this->story->get_title(); $poster = $this->story->get_poster_portrait(); $poster_srcset = $this->story->get_poster_srcset(); $poster_sizes = $this->story->get_poster_sizes(); $wrapper_style = \sprintf( '--aspect-ratio: %F; --width: %dpx; --height: %dpx', 0 !== $args['height'] ? $args['width'] / $args['height'] : 1, (int) $args['width'], (int) $args['height'] ); // This CSS is used for AMP and non-AMP. $this->assets->enqueue_style_asset( Embed_Base::SCRIPT_HANDLE ); if ( $this->context->is_amp() ) { ob_start(); ?> assets->enqueue_style( AMP_Story_Player_Assets::SCRIPT_HANDLE ); $this->assets->enqueue_script( AMP_Story_Player_Assets::SCRIPT_HANDLE ); ob_start(); ?> story = $story; } /** * Renders the story. * * @since 1.0.0 * * @return string The complete HTML markup for the story. */ public function render(): string { $markup = $this->story->get_markup(); $markup = $this->fix_incorrect_charset( $markup ); $markup = $this->fix_malformed_script_link_tags( $markup ); $markup = $this->replace_html_head( $markup ); $markup = wp_replace_insecure_home_url( $markup ); $markup = $this->print_analytics( $markup ); $markup = $this->print_social_share( $markup ); return $markup; } /** * Fix incorrect tags. * * React/JSX outputs the charset attribute name as "charSet", * but libdom and the AMP toolbox only recognize lowercase "charset" * * @since 1.28.0 * * @param string $content Story markup. * @return string Filtered content */ public function fix_incorrect_charset( string $content ): string { return (string) preg_replace( '//i', '', $content ); } /** * Fix malformed tags in the . * * On certain environments like WordPress.com VIP, there is additional KSES * hardening that prevents saving `` * and `https://cdn.ampproject.org/v0/amp-story-1.0.js` * into ``. * * @since 1.13.0 * * @param string $content Story markup. * @return string Filtered content */ protected function fix_malformed_script_link_tags( string $content ): string { $replaced_content = preg_replace_callback( '/]+href="(?P[^"]+)"[^>]*>\1<\/a>/m', static function ( $matches ) { if ( str_starts_with( $matches['href'], 'https://cdn.ampproject.org/' ) ) { $script_url = $matches['href']; // Turns `https://cdn.ampproject.org/v0.js` // into ``. if ( 'https://cdn.ampproject.org/v0.js' === $script_url ) { return ""; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript } // Extract 'amp-story' from 'https://cdn.ampproject.org/v0/amp-story-1.0.js'. $sub_matches = []; preg_match( '/v0\/(?P[\w-]+)-[\d.]+\.js/', $script_url, $sub_matches ); if ( isset( $sub_matches['custom_element'] ) ) { $custom_element = $sub_matches['custom_element']; // Turns `https://cdn.ampproject.org/v0/amp-story-1.0.js` // into . return ""; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript } } return $matches[0]; }, $content ); // On errors the return value of preg_replace_callback() is null. return $replaced_content ?: $content; } /** * Returns the full HTML markup for a given story besides boilerplate. * * @since 1.0.0 * * @return string Filtered content. */ protected function get_html_head_markup(): string { ob_start(); ?> with dynamic content. * * @since 1.0.0 * * @param string $content Story markup. * @return string Filtered content. */ protected function replace_html_head( string $content ): string { $start_tag = ''; $end_tag = ''; // Replace malformed meta tags with correct tags. $content = (string) preg_replace( '//i', $start_tag, $content ); $content = (string) preg_replace( '//i', $end_tag, $content ); $start_tag_pos = strpos( $content, $start_tag ); $end_tag_pos = strpos( $content, $end_tag ); if ( false !== $start_tag_pos && false !== $end_tag_pos ) { $end_tag_pos += \strlen( $end_tag ); $content = substr_replace( $content, $this->get_html_head_markup(), $start_tag_pos, $end_tag_pos - $start_tag_pos ); } return $content; } /** * Print analytics code before closing ``. * * @since 1.2.0 * * @param string $content String to replace. */ protected function print_analytics( string $content ): string { ob_start(); /** * Fires before the closing tag. * * Can be used to print configuration. * * @since 1.1.0 */ do_action( 'web_stories_print_analytics' ); $output = (string) ob_get_clean(); return str_replace( '', $output . '', $content ); } /** * Print amp-story-social-share before closing ``. * * @since 1.6.0 * * @param string $content String to replace. */ protected function print_social_share( string $content ): string { $share_providers = [ [ 'provider' => 'twitter', ], [ 'provider' => 'linkedin', ], [ 'provider' => 'email', ], [ 'provider' => 'system', ], ]; /** * Filters the list of sharing providers in the Web Stories sharing dialog. * * @since 1.3.0 * * @link https://amp.dev/documentation/components/amp-social-share/?format=stories#pre-configured-providers * * @param array[] $share_providers List of sharing providers. */ $share_providers = apply_filters( 'web_stories_share_providers', $share_providers ); if ( empty( $share_providers ) ) { return $content; } $config = [ 'shareProviders' => $share_providers, ]; $social_share = \sprintf( '', wp_json_encode( $config ) ); return str_replace( '', $social_share . '', $content ); } } ================================================ FILE: includes/Renderer/Story/Image.php ================================================ story = $story; } /** * Renders the block as an image. * * @since 1.0.0 * * @param array $args Array of Argument to render. * @return string Rendered block type output. */ public function render( array $args = [] ): string { $defaults = [ 'align' => 'none', 'class' => 'wp-block-web-stories-embed', 'height' => 600, 'width' => 360, ]; $args = wp_parse_args( $args, $defaults ); $align = \sprintf( 'align%s', $args['align'] ); $class = $args['class']; $url = $this->story->get_url(); $title = $this->story->get_title(); $poster = $this->story->get_poster_portrait(); $poster_srcset = $this->story->get_poster_srcset(); $poster_sizes = $this->story->get_poster_sizes(); ob_start(); ?> assets = $assets; $this->story = $story; } /** * Renders the block output in default context. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.30.0 * * @param array $args Array of Argument to render. * @return string Rendered block type output. */ public function render( array $args = [] ): string { ++self::$instances; $this->instance_id = self::$instances; $defaults = [ 'align' => 'none', 'class' => 'wp-block-web-stories-embed', 'height' => 600, 'width' => 360, ]; $args = wp_parse_args( $args, $defaults ); $args['align'] = ! empty( $args['align'] ) ? $args['align'] : 'none'; $align = \sprintf( 'align%s', $args['align'] ); $class = $args['class']; $wrapper_style = \sprintf( '--aspect-ratio: %F; --width: %dpx; --height: %dpx', 0 !== $args['height'] ? $args['width'] / $args['height'] : 1, (int) $args['width'], (int) $args['height'] ); $this->assets->enqueue_style_asset( Renderer::STYLE_HANDLE ); // For the lightbox styles. $this->assets->enqueue_style( AMP_Story_Player_Assets::SCRIPT_HANDLE ); $this->assets->enqueue_script( AMP_Story_Player_Assets::SCRIPT_HANDLE ); $this->assets->enqueue_style_asset( Embed_Base::SCRIPT_HANDLE ); $this->assets->enqueue_script_module( 'web-stories-embed-view-script-module', $this->assets->get_base_url( 'assets/js/web-stories-block-view.js' ) ); ob_start(); ?>
" data-id="instance_id ); ?>" data-wp-interactive="web-stories-block" $this->instance_id, ] ); ?> >
render_story_with_poster( $args ); ?>
[ [ 'name' => 'close', 'position' => 'start', ], [ 'name' => 'skip-next', ], ], 'behavior' => [ 'autoplay' => false, ], ]; ?>
story->get_title() ); ?>
$args Array of Argument to render. */ protected function render_story_with_poster( array $args ): void { $poster_url = $this->story->get_poster_portrait(); $poster_srcset = $this->story->get_poster_srcset(); $poster_sizes = $this->story->get_poster_sizes(); if ( ! $poster_url ) { ?> render_content_overlay(); } /** * Renders the content overlay markup. * * @since 1.30.0 */ protected function render_content_overlay(): void { ?>
story->get_title() ); ?>
*/ private static ?ServiceContainer $container = null; /** * Dependency injector object instance. */ private static ?Injector $injector = null; /** * Get a particular service out of the service container. * * @since 1.6.0 * * @param string $service Service ID to retrieve. */ public static function get( string $service ): Service { return self::get_container()->get( $service ); } /** * Check if a particular service has been registered in the service container. * * @since 1.6.0 * * @param string $service Service ID to retrieve. */ public static function has( string $service ): bool { return self::get_container()->has( $service ); } /** * Get an instance of the plugin. * * @since 1.6.0 * * @return Plugin Plugin object instance. */ public static function get_plugin(): Plugin { if ( null === self::$plugin ) { self::$plugin = PluginFactory::create(); } return self::$plugin; } /** * Get an instance of the service container. * * @since 1.6.0 * * @return ServiceContainer Service container object instance. */ public static function get_container(): ServiceContainer { if ( null === self::$container ) { self::$container = self::get_plugin()->get_container(); } return self::$container; } /** * Get an instance of the dependency injector. * * @since 1.6.0 * * @return Injector Dependency injector object instance. */ public static function get_injector(): Injector { if ( null === self::$injector ) { self::$injector = self::get_container()->get( 'injector' ); } return self::$injector; } } ================================================ FILE: includes/Settings.php ================================================ shopping_vendors = $shopping_vendors; } /** * Primes option caches for specified groups if the function exists. * * @since 1.37.0 */ public function prime_option_caches(): void { wp_prime_option_caches_by_group( self::SETTING_GROUP ); wp_prime_option_caches_by_group( self::SETTING_GROUP_EXPERIMENTS ); } /** * Register settings. * * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.0.0 */ public function register(): void { register_setting( self::SETTING_GROUP, self::SETTING_NAME_TRACKING_ID, [ 'description' => __( 'Google Analytics Tracking ID', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_USING_LEGACY_ANALYTICS, [ 'description' => __( 'Using legacy analytics configuration', 'web-stories' ), 'type' => 'boolean', 'default' => false, 'show_in_rest' => true, 'sanitize_callback' => 'rest_sanitize_boolean', ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_AD_NETWORK, [ 'description' => __( 'Ad Network', 'web-stories' ), 'type' => 'string', 'default' => 'none', 'enum' => [ 'none', 'adsense', 'admanager', 'mgid' ], 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_ADSENSE_PUBLISHER_ID, [ 'description' => __( 'Google AdSense Publisher ID', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_ADSENSE_SLOT_ID, [ 'description' => __( 'Google AdSense Slot ID', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_AD_MANAGER_SLOT_ID, [ 'description' => __( 'Google Ad Manager Slot ID', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_MGID_WIDGET_ID, [ 'description' => __( 'MGID Widget ID', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, [ 'description' => __( 'Default Publisher Logo', 'web-stories' ), 'type' => 'integer', 'default' => 0, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_PUBLISHER_LOGOS, [ 'description' => __( 'Publisher Logos', 'web-stories' ), 'type' => 'array', 'default' => [], 'show_in_rest' => [ 'schema' => [ 'items' => [ 'type' => 'integer', ], ], ], // WPGraphQL errors when encountering array or object types. // See https://github.com/wp-graphql/wp-graphql/issues/2065. 'show_in_graphql' => false, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_VIDEO_CACHE, [ 'description' => __( 'Video Cache', 'web-stories' ), 'type' => 'boolean', 'default' => false, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_DATA_REMOVAL, [ 'description' => __( 'Data Removal', 'web-stories' ), 'type' => 'boolean', 'default' => false, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_ARCHIVE, [ 'description' => __( 'Web Stories Archive', 'web-stories' ), 'type' => 'string', 'default' => 'default', 'show_in_rest' => [ 'schema' => [ 'type' => 'string', 'enum' => [ 'default', 'disabled', 'custom' ], ], ], ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_ARCHIVE_PAGE_ID, [ 'description' => __( 'Web Stories Archive Page ID', 'web-stories' ), 'type' => 'integer', 'default' => 0, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP_EXPERIMENTS, self::SETTING_NAME_EXPERIMENTS, [ 'description' => __( 'Experiments', 'web-stories' ), 'type' => 'object', 'default' => [], 'show_in_rest' => [ 'schema' => [ 'properties' => [], 'additionalProperties' => true, ], ], ] ); $vendors = $this->shopping_vendors->get_vendors(); $vendor_options = array_keys( $vendors ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_SHOPPING_PROVIDER, [ 'description' => __( 'Shopping provider', 'web-stories' ), 'type' => 'string', 'default' => 'none', 'enum' => $vendor_options, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_SHOPIFY_HOST, [ 'description' => __( 'Shopify Host', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_SHOPIFY_ACCESS_TOKEN, [ 'description' => __( 'Shopify API Access Token', 'web-stories' ), 'type' => 'string', 'default' => '', 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_AUTO_ADVANCE, [ 'description' => __( 'Auto-advance', 'web-stories' ), 'type' => 'boolean', 'default' => true, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_DEFAULT_PAGE_DURATION, [ 'description' => __( 'Default Page Duration', 'web-stories' ), 'type' => 'number', 'default' => 7, 'show_in_rest' => true, ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_TRACKING_HANDLER, [ 'description' => __( 'Tracking Handler', 'web-stories' ), 'type' => 'string', 'default' => 'site-kit', 'show_in_rest' => [ 'schema' => [ 'type' => 'string', 'enum' => [ 'site-kit', 'web-stories', 'both' ], ], ], ] ); register_setting( self::SETTING_GROUP, self::SETTING_NAME_CUSTOMIZER_SETTINGS, [ 'description' => __( 'Customizer settings', 'web-stories' ), 'type' => 'array', 'default' => [], 'show_in_rest' => false, ] ); add_action( 'init', [ $this, 'prime_option_caches' ] ); } /** * Returns the value for a given setting. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.12.0 * * @param string $key Setting key. * @param mixed $default_value Optional. Default value to return if the option does not exist. * @return string|array|bool|int Setting value. */ public function get_setting( string $key, $default_value = false ) { // Distinguish between `false` as a default, and not passing one, just like WordPress. $passed_default = \func_num_args() > 1; if ( $passed_default ) { /** * Setting value. * * @var string|array|bool */ $option = get_option( $key, $default_value ); if ( $option === $default_value ) { return $option; } } else { /** * Setting value. * * @var string|array|bool */ $option = get_option( $key ); } $settings = $this->get_registered_options(); if ( isset( $settings[ $key ] ) ) { $value = rest_sanitize_value_from_schema( $option, $settings[ $key ] ); if ( is_wp_error( $value ) ) { return $option; } /** * Setting value. * * @var string|array|bool */ $option = $value; } return $option; } /** * Updates the given setting with a new value. * * @since 1.12.0 * * @param string $key Setting key. * @param mixed $value Setting value. * @return mixed Setting value. */ public function update_setting( string $key, $value ) { return update_option( $key, $value ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_option( self::SETTING_NAME_ARCHIVE ); delete_option( self::SETTING_NAME_EXPERIMENTS ); delete_option( self::SETTING_NAME_TRACKING_ID ); delete_option( self::SETTING_NAME_USING_LEGACY_ANALYTICS ); delete_option( self::SETTING_NAME_AD_NETWORK ); delete_option( self::SETTING_NAME_ADSENSE_PUBLISHER_ID ); delete_option( self::SETTING_NAME_ADSENSE_SLOT_ID ); delete_option( self::SETTING_NAME_AD_MANAGER_SLOT_ID ); delete_option( self::SETTING_NAME_MGID_WIDGET_ID ); delete_option( self::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ); delete_option( self::SETTING_NAME_PUBLISHER_LOGOS ); delete_option( self::SETTING_NAME_VIDEO_CACHE ); delete_option( self::SETTING_NAME_DATA_REMOVAL ); delete_option( self::SETTING_NAME_ARCHIVE ); delete_option( self::SETTING_NAME_ARCHIVE_PAGE_ID ); delete_option( self::SETTING_NAME_SHOPPING_PROVIDER ); delete_option( self::SETTING_NAME_SHOPIFY_HOST ); delete_option( self::SETTING_NAME_SHOPIFY_ACCESS_TOKEN ); delete_option( self::SETTING_NAME_DEFAULT_PAGE_DURATION ); delete_option( self::SETTING_NAME_AUTO_ADVANCE ); delete_option( self::SETTING_NAME_TRACKING_HANDLER ); } /** * Retrieves all of the registered options for the Settings API. * Inspired by get_registered_options method found in WordPress. But also get settings that are registered without `show_in_rest` property. * * @since 1.28.0 * * @link https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php#L211-L267 * * @return array> Array of registered options. */ protected function get_registered_options(): array { $rest_options = []; foreach ( get_registered_settings() as $name => $args ) { $rest_args = []; if ( ! empty( $args['show_in_rest'] ) && \is_array( $args['show_in_rest'] ) ) { $rest_args = $args['show_in_rest']; } $defaults = [ 'name' => ! empty( $rest_args['name'] ) ? $rest_args['name'] : $name, 'schema' => [], ]; $rest_args = array_merge( $defaults, $rest_args ); $default_schema = [ 'type' => empty( $args['type'] ) ? null : $args['type'], 'description' => empty( $args['description'] ) ? '' : $args['description'], 'default' => $args['default'] ?? null, ]; $schema = array_merge( $default_schema, $rest_args['schema'] ); $schema = rest_default_additional_properties_to_false( $schema ); $rest_options[ (string) $name ] = $schema; } return $rest_options; } } ================================================ FILE: includes/Shopping/Product.php ================================================ $product Array of attributes. */ public function __construct( array $product = [] ) { foreach ( $product as $key => $value ) { if ( property_exists( $this, $key ) && ! \is_null( $value ) ) { $this->$key = $value; } } } /** * Get id. * * @since 1.21.0 */ public function get_id(): string { return $this->id; } /** * Get title. * * @since 1.21.0 */ public function get_title(): string { return $this->title; } /** * Get brand. * * @since 1.21.0 */ public function get_brand(): string { return $this->brand; } /** * Get price. * * @since 1.21.0 */ public function get_price(): float { return $this->price; } /** * Get currency. * * @since 1.21.0 */ public function get_price_currency(): string { return $this->price_currency; } /** * Get images property. * * @since 1.21.0 * * @return array{url: string, alt: string}[] */ public function get_images(): array { return $this->images; } /** * Get details. * * @since 1.21.0 */ public function get_details(): string { return $this->details; } /** * Get url. * * @since 1.21.0 */ public function get_url(): string { return $this->url; } /** * Get rating. * * @since 1.21.0 * * @return array{rating_value?: float, review_count?: int, review_url?: string} */ public function get_aggregate_rating(): ?array { return $this->aggregate_rating; } /** * Retrieves the response data for JSON serialization. * * @since 1.21.0 * * @return mixed Any JSON-serializable value. */ #[\ReturnTypeWillChange] public function jsonSerialize() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $rating = $this->get_aggregate_rating(); $data = [ 'productId' => $this->get_id(), 'productTitle' => $this->get_title(), 'productDetails' => $this->get_details(), 'productBrand' => $this->get_brand(), 'productUrl' => $this->get_url(), 'productImages' => $this->get_images(), 'productPrice' => $this->get_price(), 'productPriceCurrency' => $this->get_price_currency(), ]; if ( $rating ) { $data['aggregateRating'] = [ 'ratingValue' => $rating['rating_value'] ?? null, 'reviewCount' => $rating['review_count'] ?? null, 'reviewUrl' => $rating['review_url'] ?? null, ]; } return $data; } /** * Convert array to object properties. * * @since 1.26.0 * * @param array $product Array of product. * @return Product Product. * * @phpstan-param ProductData $product */ public static function load_from_array( array $product ): Product { $product_object = new self(); $product_object->set_id( $product['productId'] ?? '' ); $product_object->set_title( $product['productTitle'] ?? '' ); $product_object->set_details( $product['productDetails'] ?? '' ); $product_object->set_brand( $product['productBrand'] ?? '' ); $product_object->set_url( $product['productUrl'] ?? '' ); $product_object->set_images( $product['productImages'] ?? [] ); $product_object->set_price( $product['productPrice'] ?? 0 ); $product_object->set_price_currency( $product['productPriceCurrency'] ?? '' ); if ( isset( $product['aggregateRating'] ) ) { $product_object->set_aggregate_rating( [ 'rating_value' => $product['aggregateRating']['ratingValue'] ?? 0, 'review_count' => $product['aggregateRating']['reviewCount'] ?? 0, 'review_url' => $product['aggregateRating']['reviewUrl'] ?? '', ] ); } return $product_object; } /** * Set id. * * @since 1.26.0 * * @param string $id ID. */ protected function set_id( string $id ): void { $this->id = $id; } /** * Set title. * * @since 1.26.0 * * @param string $title Title. */ protected function set_title( string $title ): void { $this->title = $title; } /** * Set brand. * * @since 1.26.0 * * @param string $brand Brand. */ protected function set_brand( string $brand ): void { $this->brand = $brand; } /** * Set price. * * @since 1.26.0 * * @param float $price Price. */ protected function set_price( float $price ): void { $this->price = $price; } /** * Set Price currency. * * @since 1.26.0 * * @param string $price_currency Price Currency. */ protected function set_price_currency( string $price_currency ): void { $this->price_currency = $price_currency; } /** * Set Images. * * @since 1.26.0 * * @param array{url: string, alt: string}[] $images Images. */ protected function set_images( array $images ): void { $this->images = $images; } /** * Set Details. * * @since 1.26.0 * * @param string $details Details. */ protected function set_details( string $details ): void { $this->details = $details; } /** * Set url. * * @since 1.26.0 * * @param string $url URL. */ protected function set_url( string $url ): void { $this->url = $url; } /** * Set aggregate rating. * * @since 1.26.0 * * @param array{rating_value: float, review_count: int, review_url: string} $aggregate_rating Rating data in array. */ protected function set_aggregate_rating( array $aggregate_rating ): void { $this->aggregate_rating = $aggregate_rating; } } ================================================ FILE: includes/Shopping/Product_Meta.php ================================================ story_post_type = $story_post_type; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.22.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Init. * * @since 1.22.0 */ public function register(): void { $this->register_meta(); } /** * Register meta * * @since 1.22.0 */ public function register_meta(): void { register_post_meta( $this->story_post_type::POST_TYPE_SLUG, self::PRODUCTS_POST_META_KEY, [ 'type' => 'object', 'description' => __( 'Products', 'web-stories' ), 'show_in_rest' => [ 'schema' => [ 'type' => 'object', 'properties' => [], 'additionalProperties' => true, ], ], 'default' => [], 'single' => true, 'revisions_enabled' => true, ] ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::PRODUCTS_POST_META_KEY ); } } ================================================ FILE: includes/Shopping/Shopify_Query.php ================================================ settings = $settings; } /** * Get products by search term. * * @since 1.21.0 * * @param string $search_term Search term. * @param int $page Number of page for paginated requests. * @param int $per_page Number of products to be fetched. * @param string $orderby Sort retrieved products by parameter. Default 'date'. * @param string $order Whether to order products in ascending or descending order. * Accepts 'asc' (ascending) or 'desc' (descending). Default 'desc'. * @return array{products: array, has_next_page: bool}|WP_Error */ public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' ) { $result = $this->fetch_remote_products( $search_term, $page, $per_page, $orderby, $order ); if ( is_wp_error( $result ) ) { return $result; } $products = []; $has_next_page = $result['data']['products']['pageInfo']['hasNextPage']; foreach ( $result['data']['products']['edges'] as $edge ) { $product = $edge['node']; $images = []; foreach ( $product['images']['edges'] as $image_edge ) { $image = $image_edge['node']; $images[] = [ 'url' => $image['url'], 'alt' => $image['altText'] ?? '', ]; } // URL is null if the resource is currently not published to the Online Store sales channel, // or if the shop is password-protected. // In this case, we can fall back to a manually constructed product URL. $product_url = $product['onlineStoreUrl'] ?? \sprintf( 'https://%1$s/products/%2$s/', $this->get_host(), $product['handle'] ); $products[] = new Product( [ 'id' => $product['id'], 'title' => $product['title'], 'brand' => $product['vendor'], // TODO: Maybe eventually provide full price range. // See https://github.com/ampproject/amphtml/issues/37957. 'price' => (float) $product['priceRange']['minVariantPrice']['amount'], 'price_currency' => $product['priceRange']['minVariantPrice']['currencyCode'], 'images' => $images, 'details' => $product['description'], // URL is null if the resource is currently not published to the Online Store sales channel, // or if the shop is password-protected. 'url' => $product_url, ] ); } return compact( 'products', 'has_next_page' ); } /** * Returns the Shopify host name. * * @since 1.21.0 * * @return string Shopify host. */ protected function get_host(): string { /** * Host name. * * @var string $host */ $host = $this->settings->get_setting( Settings::SETTING_NAME_SHOPIFY_HOST ); return $host; } /** * Returns the Shopify access token. * * @since 1.21.0 * * @return string Shopify access token. */ protected function get_access_token(): string { /** * Access token. * * @var string $access_token */ $access_token = $this->settings->get_setting( Settings::SETTING_NAME_SHOPIFY_ACCESS_TOKEN ); return $access_token; } /** * Remotely executes a GraphQL query. * * @since 1.21.0 * * @param string $query GraphQL query to execute. * @return string|WP_Error Query result or error object on failure. */ protected function execute_query( string $query ) { $host = $this->get_host(); $access_token = $this->get_access_token(); if ( empty( $host ) || empty( $access_token ) ) { return new WP_Error( 'rest_missing_credentials', __( 'Missing API credentials.', 'web-stories' ), [ 'status' => 400 ] ); } if ( ! preg_match( '/^[\w-]+\.myshopify\.com$/i', $host ) ) { return new WP_Error( 'rest_invalid_hostname', __( 'Invalid Shopify hostname.', 'web-stories' ), [ 'status' => 400 ] ); } $url = esc_url_raw( \sprintf( 'https://%1$s/api/%2$s/graphql.json', $host, self::API_VERSION ) ); $response = wp_remote_post( $url, [ 'headers' => [ 'Content-Type' => 'application/graphql', 'X-Shopify-Storefront-Access-Token' => $access_token, ], 'body' => $query, ] ); $status_code = wp_remote_retrieve_response_code( $response ); if ( WP_Http::UNAUTHORIZED === $status_code || WP_Http::NOT_FOUND === $status_code ) { return new WP_Error( 'rest_invalid_credentials', __( 'Invalid API credentials.', 'web-stories' ), [ 'status' => $status_code ] ); } if ( WP_Http::OK !== $status_code ) { return new WP_Error( 'rest_unknown', __( 'Error fetching products', 'web-stories' ), [ 'status' => $status_code ] ); } return wp_remote_retrieve_body( $response ); } /** * Returns the GraphQL query for getting all products from the store. * * @since 1.21.0 * * @param string $search_term Search term to filter products by. * @param string $after The cursor to retrieve nodes after in the connection. * @param int $per_page Number of products to be fetched. * @param string $orderby Sort collection by product attribute. * @param string $order Order sort attribute ascending or descending. * @return string The assembled GraphQL query. */ protected function get_products_query( string $search_term, string $after, int $per_page, string $orderby, string $order ): string { $search_string = empty( $search_term ) ? '*' : '*' . $search_term . '*'; $sortkey = 'date' === $orderby ? 'CREATED_AT' : strtoupper( $orderby ); $reverse = 'asc' === $order ? 'false' : 'true'; $after = empty( $after ) ? 'null' : \sprintf( '"%s"', $after ); return <<get_cache_key( $search_term, $after, $per_page, $orderby, $order ); $data = get_transient( $cache_key ); if ( \is_string( $data ) && ! empty( $data ) ) { /** * Cached response. * * @phpstan-var ShopifyGraphQLResponse $cached_result */ $cached_result = (array) json_decode( $data, true ); return $cached_result; } $query = $this->get_products_query( $search_term, $after, $per_page, $orderby, $order ); $body = $this->execute_query( $query ); if ( is_wp_error( $body ) ) { return $body; } /** * Shopify GraphQL API response. * * @var array $result * @phpstan-var ShopifyGraphQLResponse $result */ $result = json_decode( $body, true ); if ( isset( $result['errors'] ) ) { $wp_error = new WP_Error(); foreach ( $result['errors'] as $error ) { $error_code = $error['extensions']['code']; // https://shopify.dev/api/storefront#status_and_error_codes. switch ( $error_code ) { case 'THROTTLED': $wp_error->add( 'rest_throttled', __( 'Shopify API rate limit exceeded. Try again later.', 'web-stories' ), [ 'status' => 429 ] ); break; case 'ACCESS_DENIED': $wp_error->add( 'rest_invalid_credentials', __( 'Invalid Shopify API credentials provided.', 'web-stories' ), [ 'status' => 401 ] ); break; case 'SHOP_INACTIVE': $wp_error->add( 'rest_inactive_shop', __( 'Inactive Shopify shop.', 'web-stories' ), [ 'status' => 403 ] ); break; case 'INTERNAL_SERVER_ERROR': $wp_error->add( 'rest_internal_error', __( 'Shopify experienced an internal server error.', 'web-stories' ), [ 'status' => 500 ] ); break; default: $wp_error->add( 'rest_unknown', __( 'Error fetching products from Shopify.', 'web-stories' ), [ 'status' => 500 ] ); } } return $wp_error; } // TODO: Maybe cache errors too? set_transient( $cache_key, $body, $cache_ttl ); return $result; } /** * Get cache key for properties. * * @since 1.22.0 * * @param string $search_term Search term to filter products by. * @param string $after The cursor to retrieve nodes after in the connection. * @param int $per_page Number of products to be fetched. * @param string $orderby Sort retrieved products by parameter. * @param string $order Whether to order products in ascending or descending order. * Accepts 'asc' (ascending) or 'desc' (descending). */ protected function get_cache_key( string $search_term, string $after, int $per_page, string $orderby, string $order ): string { $cache_args = (string) wp_json_encode( compact( 'search_term', 'after', 'per_page', 'orderby', 'order' ) ); return 'web_stories_shopify_data_' . md5( $cache_args ); } /** * Remotely fetches all products from the store. * * @since 1.22.0 * * @param string $search_term Search term to filter products by. * @param int $page Number of page for paginated requests. * @param int $per_page Number of products to be fetched. * @param string $orderby Sort retrieved products by parameter. * @param string $order Whether to order products in ascending or descending order. * Accepts 'asc' (ascending) or 'desc' (descending). * @return array|WP_Error Response data or error object on failure. * * @phpstan-return ShopifyGraphQLResponse|WP_Error */ protected function fetch_remote_products( string $search_term, int $page, int $per_page, string $orderby, string $order ) { $after = ''; if ( $page > 1 ) { // Loop around all the pages, getting the endCursor of each page, until you get the last one. for ( $i = 1; $i < $page; $i++ ) { $result = $this->get_remote_products( $search_term, $after, $per_page, $orderby, $order ); if ( is_wp_error( $result ) ) { return $result; } $has_next_page = $result['data']['products']['pageInfo']['hasNextPage']; if ( ! $has_next_page ) { return new WP_Error( 'rest_no_page', __( 'Error fetching products from Shopify.', 'web-stories' ), [ 'status' => 404 ] ); } $after = (string) $result['data']['products']['pageInfo']['endCursor']; } } return $this->get_remote_products( $search_term, $after, $per_page, $orderby, $order ); } } ================================================ FILE: includes/Shopping/Shopping_Vendors.php ================================================ injector = $injector; } /** * Get an instance of product query class by vendor's name. * * @since 1.21.0 * * @param string $name Name of vendor. */ public function get_vendor_class( string $name ): ?Product_Query { $vendors = $this->get_vendors(); if ( ! isset( $vendors[ $name ]['class'] ) || ! class_exists( $vendors[ $name ]['class'] ) ) { return null; } $query = $this->injector->make( $vendors[ $name ]['class'] ); if ( ! $query instanceof Product_Query ) { return null; } return $query; } /** * Get an array of registered vendors. * * @since 1.21.0 * * @return array Array of vendors. */ public function get_vendors(): array { $vendors = [ 'none' => [ 'label' => __( 'None', 'web-stories' ), ], 'shopify' => [ 'label' => __( 'Shopify', 'web-stories' ), 'class' => Shopify_Query::class, ], 'woocommerce' => [ 'label' => __( 'WooCommerce', 'web-stories' ), 'class' => WooCommerce_Query::class, ], ]; /** * Filter the array of vendors. * * @since 1.21.0 * * @param array $vendors Associative array of vendor, including label and class. */ $vendors = apply_filters( 'web_stories_shopping_vendors', $vendors ); return $vendors; } } ================================================ FILE: includes/Shopping/WooCommerce_Query.php ================================================ woocommerce = $woocommerce; } /** * Get products by search term. * * @since 1.21.0 * * @param string $search_term Search term. * @param int $page Number of page for paginated requests. * @param int $per_page Number of products to be fetched. * @param string $orderby Sort collection by product attribute. * @param string $order Order sort attribute ascending or descending. * @return array{products: array, has_next_page: bool}|WP_Error */ public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' ) { $status = $this->woocommerce->get_plugin_status(); if ( ! $status['installed'] ) { return new WP_Error( 'rest_woocommerce_not_installed', __( 'WooCommerce is not installed.', 'web-stories' ), [ 'status' => 400 ] ); } if ( ! $status['active'] ) { return new WP_Error( 'rest_woocommerce_not_activated', __( 'WooCommerce is not activated. Please activate it again try again.', 'web-stories' ), [ 'status' => 400 ] ); } $args = [ 'status' => 'publish', 'page' => $page, 'limit' => $per_page, 's' => $search_term, 'orderby' => $orderby, 'order' => $order, 'paginate' => true, ]; if ( 'price' === $orderby ) { $wc_query = new WC_Query(); $wc_args = $wc_query->get_catalog_ordering_args( $orderby, strtoupper( $order ) ); $args = array_merge( $args, $wc_args ); } /** * Product query object. * * @var \stdClass $product_query */ $product_query = wc_get_products( $args ); $has_next_page = ( $product_query->max_num_pages > $page ); /** * Products. * * @var WC_Product[] $wc_products */ $wc_products = $product_query->products; $product_image_ids = []; foreach ( $wc_products as $product ) { $product_image_ids[] = $this->get_product_image_ids( $product ); } $products_image_ids = array_merge( [], ...$product_image_ids ); /** * Warm the object cache with post and meta information for all found * images to avoid making individual database calls. */ _prime_post_caches( $products_image_ids, false, true ); $products = []; foreach ( $wc_products as $product ) { $images = array_map( [ $this, 'get_product_image' ], $this->get_product_image_ids( $product ) ); $product_object = new Product( [ // amp-story-shopping requires non-numeric IDs. 'id' => 'wc-' . $product->get_id(), 'title' => $product->get_title(), 'brand' => '', // TODO: Figure out how to best provide that. 'price' => (float) $product->get_price(), 'price_currency' => get_woocommerce_currency(), 'images' => $images, 'aggregate_rating' => [ 'rating_value' => (float) $product->get_average_rating(), 'review_count' => $product->get_rating_count(), 'review_url' => $product->get_permalink(), ], 'details' => wp_strip_all_tags( $product->get_short_description() ), 'url' => $product->get_permalink(), ] ); $products[] = $product_object; } return compact( 'products', 'has_next_page' ); } /** * Get all product image ids (feature image + gallery_images). * * @since 1.21.0 * * @param WC_Product $product Product. * @return int[] */ protected function get_product_image_ids( WC_Product $product ): array { $product_image_ids = $product->get_gallery_image_ids(); array_unshift( $product_image_ids, $product->get_image_id() ); $product_image_ids = array_map( 'absint', $product_image_ids ); return array_unique( array_filter( $product_image_ids ) ); } /** * Get product image, url and alt. * * @since 1.21.0 * * @param int $image_id Attachment ID. * @return array{url?: string, alt?: string} */ protected function get_product_image( int $image_id ): array { $url = wp_get_attachment_image_url( $image_id, 'large' ); if ( ! $url ) { return []; } /** * Alt text. * * @var string $alt */ $alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true ); if ( empty( $alt ) ) { $alt = ''; } return compact( 'url', 'alt' ); } } ================================================ FILE: includes/Shortcode/Embed_Shortcode.php ================================================ |string $attributes Shortcode attributes. * @param string|null $content Shortcode content. * @return string Rendered Shortcode */ public function render_shortcode( $attributes, ?string $content ): string { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable // Initialize '$attrs' when not an array OR is an empty string. if ( empty( $attributes ) || ! \is_array( $attributes ) ) { $attributes = []; } $attributes = shortcode_atts( $this->default_attrs(), $attributes, self::SHORTCODE_NAME ); $attributes['class'] = 'wp-shortcode-web-stories-embed'; return $this->render( $attributes ); } } ================================================ FILE: includes/Shortcode/Stories_Shortcode.php ================================================ |string $attrs Shortcode attributes. * @return string Story markup. */ public function render_stories( $attrs ): string { $default_pairs = [ 'view' => 'circles', 'number_of_columns' => 1, 'title' => 'false', 'excerpt' => 'false', 'author' => 'false', 'date' => 'false', 'archive_link' => 'false', 'archive_link_label' => __( 'View all stories', 'web-stories' ), 'image_alignment' => 'left', 'class' => '', 'circle_size' => 150, 'number_of_stories' => 10, 'order' => 'DESC', 'orderby' => 'post_date', 'sharp_corners' => 'false', ]; $taxonomies = get_object_taxonomies( Story_Post_Type::POST_TYPE_SLUG ); foreach ( $taxonomies as $taxonomy ) { $default_pairs[ $taxonomy ] = ''; } // Initialize '$attrs' when not an array OR is an empty string. if ( empty( $attrs ) || ! \is_array( $attrs ) ) { $attrs = []; } $attributes = shortcode_atts( $default_pairs, $attrs, self::SHORTCODE_NAME ); $stories = new Stories( $this->prepare_story_attrs( $attributes ), $this->prepare_story_args( $attributes ) ); return $stories->render(); } /** * Prepare story attributes. * * @since 1.5.0 * * @param array $attributes Shortcode attributes. * @return array Attributes to pass to Story_Query class. * * @phpstan-return StoryAttributes */ private function prepare_story_attrs( array $attributes ): array { return [ 'view_type' => (string) $attributes['view'], 'number_of_columns' => (int) $attributes['number_of_columns'], 'show_title' => ( 'true' === $attributes['title'] ), 'show_author' => ( 'true' === $attributes['author'] ), 'show_date' => ( 'true' === $attributes['date'] ), 'show_excerpt' => ( 'true' === $attributes['excerpt'] ), 'show_archive_link' => ( 'true' === $attributes['archive_link'] ), 'archive_link_label' => (string) $attributes['archive_link_label'], 'image_alignment' => (string) $attributes['image_alignment'], 'class' => (string) $attributes['class'], 'circle_size' => (int) $attributes['circle_size'], 'sharp_corners' => ( 'true' === $attributes['sharp_corners'] ), ]; } /** * Prepare story arguments. * * @since 1.5.0 * * @param array $attributes Array of arguments for Story Query. * @return array Array of story arguments to pass to Story_Query. */ private function prepare_story_args( array $attributes ): array { $args = [ // Show 100 stories at most to avoid 500 errors. 'posts_per_page' => min( (int) $attributes['number_of_stories'], 100 ), // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page 'order' => 'ASC' === $attributes['order'] ? 'ASC' : 'DESC', 'orderby' => $attributes['orderby'], ]; $taxonomies = get_object_taxonomies( Story_Post_Type::POST_TYPE_SLUG ); $should_add_tax_query = false; foreach ( $taxonomies as $taxonomy ) { if ( '' !== $attributes[ $taxonomy ] ) { $should_add_tax_query = true; break; } } if ( $should_add_tax_query ) { $tax_query = [ 'relation' => 'OR', ]; foreach ( $taxonomies as $taxonomy ) { $tax_query[] = [ 'taxonomy' => $taxonomy, 'field' => 'name', 'terms' => $attributes[ $taxonomy ] ? array_map( 'trim', explode( ',', (string) $attributes[ $taxonomy ] ) ) : [], ]; } // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query $args['tax_query'] = $tax_query; } return $args; } } ================================================ FILE: includes/Stories_Script_Data.php ================================================ injector = $injector; } /** * Returns data array for use in inline script. * * @since 1.5.0 * * @return array>> Script data. */ public function get_script_data(): array { $views = $this->get_layouts(); $view_types = []; foreach ( $views as $view_key => $view_label ) { $view_types[] = [ 'label' => $view_label, 'value' => $view_key, ]; } $field_states = $this->fields_states(); return [ 'views' => $view_types, 'fields' => $field_states, ]; } /** * Wrapper function for fetching field states * based on the view types. * * Mainly uses FieldState and Fields classes. * * @since 1.5.0 * * @return array>> Field states. */ public function fields_states(): array { $field_states = []; /** * Factory instance. * * @var Factory */ $factory = $this->injector->make( Factory::class ); $views = $this->get_layouts(); $fields = [ 'title', 'author', 'date', 'image_alignment', 'excerpt', 'sharp_corners', 'archive_link', 'circle_size', 'number_of_columns', ]; foreach ( array_keys( $views ) as $view_type ) { $field_state = $factory->get_field( (string) $view_type ); foreach ( $fields as $field ) { $field_states[ $view_type ][ $field ] = [ 'show' => $field_state->$field()->show(), 'label' => $field_state->$field()->label(), 'hidden' => $field_state->$field()->hidden(), ]; } } return $field_states; } /** * Get supported layouts for web stories. * * @since 1.14.0 * * @return array */ public function get_layouts(): array { return [ 'carousel' => __( 'Box Carousel', 'web-stories' ), 'circles' => __( 'Circle Carousel', 'web-stories' ), 'grid' => __( 'Grid', 'web-stories' ), 'list' => __( 'List', 'web-stories' ), ]; } } ================================================ FILE: includes/Story_Archive.php ================================================ settings = $settings; $this->story_post_type = $story_post_type; } /** * Registers Filters and actions * * @since 1.13.0 */ public function register(): void { add_filter( 'pre_handle_404', [ $this, 'redirect_post_type_archive_urls' ], 10, 2 ); add_action( 'add_option_' . $this->settings::SETTING_NAME_ARCHIVE, [ $this, 'update_archive_setting' ] ); add_action( 'update_option_' . $this->settings::SETTING_NAME_ARCHIVE, [ $this, 'update_archive_setting' ] ); add_action( 'add_option_' . $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID, [ $this, 'update_archive_setting' ] ); add_action( 'update_option_' . $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID, [ $this, 'update_archive_setting' ] ); add_filter( 'display_post_states', [ $this, 'filter_display_post_states' ], 10, 2 ); add_action( 'pre_get_posts', [ $this, 'pre_get_posts' ] ); add_action( 'wp_trash_post', [ $this, 'on_remove_archive_page' ] ); add_action( 'delete_post', [ $this, 'on_remove_archive_page' ] ); } /** * Handles redirects to the post type archive. * * @since 1.13.0 * * @param bool|mixed $bypass Pass-through of the pre_handle_404 filter value. * @param \WP_Query $query The WP_Query object. * @return bool|mixed Whether to pass-through or not. */ public function redirect_post_type_archive_urls( $bypass, WP_Query $query ) { global $wp_rewrite; if ( $bypass || ! \is_string( $this->story_post_type->get_has_archive() ) || ( ! $wp_rewrite instanceof WP_Rewrite || ! $wp_rewrite->using_permalinks() ) ) { return $bypass; } // 'pagename' is for most permalink types, name is for when the %postname% is used as a top-level field. if ( $this->story_post_type::REWRITE_SLUG === $query->get( 'pagename' ) || $this->story_post_type::REWRITE_SLUG === $query->get( 'name' ) ) { $redirect_url = get_post_type_archive_link( $this->story_post_type->get_slug() ); if ( ! $redirect_url ) { return $bypass; } // Only exit if there was actually a location to redirect to. // Allows filtering location in tests to verify behavior. if ( wp_safe_redirect( $redirect_url, 301 ) ) { exit; } } return $bypass; } /** * Clear rewrite rules on update on setting. * * @since 1.13.0 */ public function update_archive_setting(): void { $this->story_post_type->unregister_post_type(); $this->story_post_type->register_post_type(); if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === \WPCOM_IS_VIP_ENV ) { flush_rewrite_rules( false ); } } /** * Modifies the current query to set up the custom archive page. * * @since 1.13.0 * * @param WP_Query $query Current query instance, passed by reference. */ public function pre_get_posts( WP_Query $query ): void { if ( ! \is_string( $this->story_post_type->get_has_archive() ) ) { return; } if ( $query->is_admin || ! $query->is_main_query() ) { return; } if ( ! $query->is_post_type_archive( $this->story_post_type->get_slug() ) ) { return; } $custom_archive_page_id = (int) $this->settings->get_setting( $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID ); $query->set( 'page_id', $custom_archive_page_id ); $query->set( 'post_type', 'page' ); $query->is_post_type_archive = false; $query->is_archive = false; $query->is_singular = true; $query->is_page = true; } /** * Resets archive settings when the custom archive page is trashed. * * @since 1.14.0 * * @param int $postid Post ID. */ public function on_remove_archive_page( int $postid ): void { if ( 'page' !== get_post_type( $postid ) ) { return; } $custom_archive_page_id = (int) $this->settings->get_setting( $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID ); if ( $custom_archive_page_id !== $postid ) { return; } $this->settings->update_setting( $this->settings::SETTING_NAME_ARCHIVE, 'default' ); $this->settings->update_setting( $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID, 0 ); } /** * Filters the default post display states used in the posts list table. * * @since 1.13.0 * * @param string[]|mixed $post_states An array of post display states. * @param WP_Post|null $post The current post object. * @return string[]|mixed Filtered post display states. * * @template T * * @phpstan-return ($post_states is array ? array : mixed) */ public function filter_display_post_states( $post_states, ?WP_Post $post ) { if ( ! \is_array( $post_states ) || ! $post ) { return $post_states; } if ( ! \is_string( $this->story_post_type->get_has_archive() ) ) { return $post_states; } $custom_archive_page_id = (int) $this->settings->get_setting( $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID ); if ( $post->ID === $custom_archive_page_id ) { $post_states['web_stories_archive_page'] = __( 'Web Stories Archive Page', 'web-stories' ); } return $post_states; } } ================================================ FILE: includes/Story_Post_Type.php ================================================ settings = $settings; } /** * Registers the post type for stories. * * @since 1.0.0 * * @todo refactor */ public function register(): void { $this->register_post_type(); $this->register_meta(); add_filter( 'wp_insert_post_data', [ $this, 'change_default_title' ] ); add_filter( 'wp_insert_post_empty_content', [ $this, 'filter_empty_content' ], 10, 2 ); add_filter( 'bulk_post_updated_messages', [ $this, 'bulk_post_updated_messages' ], 10, 2 ); add_action( 'clean_post_cache', [ $this, 'clear_user_posts_count' ], 10, 2 ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Get post type slug. * * @since 1.14.0 */ public function get_slug(): string { return self::POST_TYPE_SLUG; } /** * Register post meta. * * @since 1.12.0 */ public function register_meta(): void { $active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO, 0 ) ); register_post_meta( $this->get_slug(), self::PUBLISHER_LOGO_META_KEY, [ 'sanitize_callback' => 'absint', 'type' => 'integer', 'description' => __( 'Publisher logo ID.', 'web-stories' ), 'show_in_rest' => true, 'default' => $active_publisher_logo_id, 'single' => true, 'revisions_enabled' => true, ] ); register_post_meta( $this->get_slug(), self::POSTER_META_KEY, [ 'type' => 'object', 'description' => __( 'Poster object', 'web-stories' ), 'show_in_rest' => [ 'schema' => [ 'type' => 'object', 'properties' => [ 'needsProxy' => [ 'description' => __( 'If poster needs to be proxied', 'web-stories' ), 'type' => 'boolean', ], 'height' => [ 'type' => 'integer', 'description' => __( 'Poster height', 'web-stories' ), ], 'url' => [ 'description' => __( 'Poster URL.', 'web-stories' ), 'type' => 'string', 'format' => 'uri', ], 'width' => [ 'description' => __( 'Poster width.', 'web-stories' ), 'type' => 'integer', ], ], ], ], 'default' => [], 'single' => true, 'revisions_enabled' => true, ] ); } /** * Filters the bulk action updated messages. * * @since 1.1.0 * * @param array[]|mixed $bulk_messages Arrays of messages, each keyed by the corresponding post type. Messages are * keyed with 'updated', 'locked', 'deleted', 'trashed', and 'untrashed'. * @param array $bulk_counts Array of item counts for each message, used to build internationalized * strings. * @return array|mixed Bulk counts. * * @template T * * @phpstan-return ($bulk_messages is array ? array : mixed) */ public function bulk_post_updated_messages( $bulk_messages, array $bulk_counts ) { if ( ! \is_array( $bulk_messages ) ) { return $bulk_messages; } $bulk_messages[ $this->get_slug() ] = [ /* translators: %s: Number of stories. */ 'updated' => _n( '%s story updated.', '%s stories updated.', $bulk_counts['updated'], 'web-stories' ), 'locked' => 1 === $bulk_counts['locked'] ? __( 'Story not updated, somebody is editing it.', 'web-stories' ) : /* translators: %s: Number of stories. */ _n( '%s story not updated, somebody is editing it.', '%s stories not updated, somebody is editing them.', $bulk_counts['locked'], 'web-stories' ), /* translators: %s: Number of stories. */ 'deleted' => _n( '%s story permanently deleted.', '%s stories permanently deleted.', $bulk_counts['deleted'], 'web-stories' ), /* translators: %s: Number of stories. */ 'trashed' => _n( '%s story moved to the Trash.', '%s stories moved to the Trash.', $bulk_counts['trashed'], 'web-stories' ), /* translators: %s: Number of stories. */ 'untrashed' => _n( '%s story restored from the Trash.', '%s stories restored from the Trash.', $bulk_counts['untrashed'], 'web-stories' ), ]; return $bulk_messages; } /** * Reset default title to empty string for auto-drafts. * * @since 1.0.0 * * @param array|mixed $data Array of data to save. * @return array|mixed * * @template T * * @phpstan-return ($data is array ? array : mixed) */ public function change_default_title( $data ) { if ( ! \is_array( $data ) ) { return $data; } if ( $this->get_slug() === $data['post_type'] && 'auto-draft' === $data['post_status'] ) { $data['post_title'] = ''; } return $data; } /** * Filters whether the post should be considered "empty". * * Takes into account post_content_filtered for stories. * * @since 1.25.1 * * @param bool|mixed $maybe_empty Whether the post should be considered "empty". * @param array $data Array of post data. * @return bool Whether the post should be considered "empty". * * @phpstan-param array{post_type: string, post_content_filtered: string} $data */ public function filter_empty_content( $maybe_empty, array $data ): bool { if ( $this->get_slug() === $data['post_type'] ) { return $maybe_empty && ! $data['post_content_filtered']; } return (bool) $maybe_empty; } /** * Invalid cache. * * @since 1.10.0 * * @param int $post_id Post ID. * @param WP_Post $post Post object. */ public function clear_user_posts_count( int $post_id, WP_Post $post ): void { if ( $this->get_slug() !== $post->post_type ) { return; } $cache_key = "count_user_{$post->post_type}_{$post->post_author}"; $cache_group = 'user_posts_count'; wp_cache_delete( $cache_key, $cache_group ); } /** * Determines whether the post type should have an archive or not. * * @since 1.12.0 * * @return bool|string Whether the post type should have an archive, or archive slug. */ public function get_has_archive() { $archive_page_option = $this->settings->get_setting( $this->settings::SETTING_NAME_ARCHIVE ); $has_archive = true; if ( 'disabled' === $archive_page_option ) { $has_archive = false; } elseif ( 'custom' === $archive_page_option ) { $custom_archive_page_id = (int) $this->settings->get_setting( $this->settings::SETTING_NAME_ARCHIVE_PAGE_ID ); if ( $custom_archive_page_id && 'publish' === get_post_status( $custom_archive_page_id ) ) { $uri = get_page_uri( $custom_archive_page_id ); if ( $uri ) { $has_archive = urldecode( $uri ); } } } return $has_archive; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_post_meta_by_key( self::POSTER_META_KEY ); delete_post_meta_by_key( self::PUBLISHER_LOGO_META_KEY ); delete_option( self::STYLE_PRESETS_OPTION ); parent::on_plugin_uninstall(); } /** * Register post type. * * @since 1.12.0 * * @return array Post type args. * * @phpstan-return PostTypeArgs */ protected function get_args(): array { return [ 'labels' => [ 'name' => _x( 'Stories', 'post type general name', 'web-stories' ), 'singular_name' => _x( 'Story', 'post type singular name', 'web-stories' ), 'add_new' => __( 'Add New Story', 'web-stories' ), 'add_new_item' => __( 'Add New Story', 'web-stories' ), 'edit_item' => __( 'Edit Story', 'web-stories' ), 'new_item' => __( 'New Story', 'web-stories' ), 'view_item' => __( 'View Story', 'web-stories' ), 'view_items' => __( 'View Stories', 'web-stories' ), 'search_items' => __( 'Search Stories', 'web-stories' ), 'not_found' => __( 'No stories found.', 'web-stories' ), 'not_found_in_trash' => __( 'No stories found in Trash.', 'web-stories' ), 'all_items' => __( 'All Stories', 'web-stories' ), 'archives' => __( 'Story Archives', 'web-stories' ), 'attributes' => __( 'Story Attributes', 'web-stories' ), 'insert_into_item' => __( 'Insert into story', 'web-stories' ), 'uploaded_to_this_item' => __( 'Uploaded to this story', 'web-stories' ), 'featured_image' => _x( 'Featured Image', 'story', 'web-stories' ), 'set_featured_image' => _x( 'Set featured image', 'story', 'web-stories' ), 'remove_featured_image' => _x( 'Remove featured image', 'story', 'web-stories' ), 'use_featured_image' => _x( 'Use as featured image', 'story', 'web-stories' ), 'filter_items_list' => __( 'Filter stories list', 'web-stories' ), 'filter_by_date' => __( 'Filter by date', 'web-stories' ), 'items_list_navigation' => __( 'Stories list navigation', 'web-stories' ), 'items_list' => __( 'Stories list', 'web-stories' ), 'item_published' => __( 'Story published.', 'web-stories' ), 'item_published_privately' => __( 'Story published privately.', 'web-stories' ), 'item_reverted_to_draft' => __( 'Story reverted to draft.', 'web-stories' ), 'item_scheduled' => __( 'Story scheduled', 'web-stories' ), 'item_updated' => __( 'Story updated.', 'web-stories' ), 'menu_name' => _x( 'Stories', 'admin menu', 'web-stories' ), 'name_admin_bar' => _x( 'Story', 'add new on admin bar', 'web-stories' ), 'item_link' => _x( 'Story Link', 'navigation link block title', 'web-stories' ), 'item_link_description' => _x( 'A link to a story.', 'navigation link block description', 'web-stories' ), 'item_trashed' => __( 'Story trashed.', 'web-stories' ), ], 'menu_icon' => $this->get_post_type_icon(), 'supports' => [ 'title', // Used for amp-story[title]. 'author', 'editor', 'excerpt', 'thumbnail', // Used for poster images. 'revisions', // Without this, the REST API will return 404 for an autosave request. 'custom-fields', ], 'rewrite' => [ 'slug' => self::REWRITE_SLUG, 'with_front' => false, 'feeds' => true, ], 'public' => true, 'has_archive' => $this->get_has_archive(), 'exclude_from_search' => true, 'show_ui' => true, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'rest_controller_class' => Stories_Controller::class, 'autosave_rest_controller_class' => Stories_Autosaves_Controller::class, 'capability_type' => [ 'web-story', 'web-stories' ], 'map_meta_cap' => true, ]; } /** * Base64 encoded svg icon. * * @since 1.0.0 * * @return string Base64-encoded SVG icon. */ protected function get_post_type_icon(): string { return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMCAyMGM1LjUyMyAwIDEwLTQuNDc3IDEwLTEwUzE1LjUyMyAwIDEwIDAgMCA0LjQ3NyAwIDEwczQuNDc3IDEwIDEwIDEwek01LjUgNmExIDEgMCAwMTEtMUgxMWExIDEgMCAwMTEgMXY4YTEgMSAwIDAxLTEgMUg2LjVhMSAxIDAgMDEtMS0xVjZ6TTEzIDZhMSAxIDAgMDExIDF2NmExIDEgMCAwMS0xIDFWNnptMi43NSAxLjc1QS43NS43NSAwIDAwMTUgN3Y2YS43NS43NSAwIDAwLjc1LS43NXYtNC41eiIgZmlsbD0iI2EwYTVhYSIvPjwvc3ZnPg=='; } } ================================================ FILE: includes/Story_Query.php ================================================ An array of story attributes. */ protected array $story_attributes = []; /** * Story query arguments. * * @since 1.5.0 * * @var array An array of query arguments. */ protected array $query_args = []; /** * Renderer object. * * @since 1.5.0 */ public Renderer $renderer; /** * Class constructor * * @since 1.5.0 * * @param array $story_attributes { * An array of story attributes. * * @type string $view_type Stories View type. Default circles. * @type int $number_of_columns Number of columns to show in grid view. Default 2. * @type bool $show_title Whether to show story title or not. Default false. * @type bool $show_author Whether to show story author or not. Default false. * @type bool $show_date Whether to show story date or not. Default false. * @type bool $show_archive_link Whether to show view all link or not. Default false. * @type string $archive_link_label The label for view all link. Default 'View all stories'. * @type string $image_alignment The list mode image alignment. Default 'left'. * @type string $class Additional CSS classes for the container. Default empty string. * } * @param array $query_args An array of query arguments for story. {@see WP_Query::parse_query()} for * all available arguments. * * @phpstan-param StoryAttributes $story_attributes */ public function __construct( array $story_attributes = [], array $query_args = [] ) { $this->story_attributes = $story_attributes; $default_query_args = [ 'post_type' => Story_Post_Type::POST_TYPE_SLUG, 'posts_per_page' => 10, 'post_status' => 'publish', 'suppress_filters' => false, 'no_found_rows' => true, ]; $this->query_args = wp_parse_args( $query_args, $default_query_args ); } /** * Retrieves an array of the latest stories, or Stories matching the given criteria. * * @since 1.5.0 * * @return WP_Post[] List of Story posts. */ public function get_stories(): array { $stories_query = new WP_Query(); /** * List of story posts. * * @var WP_Post[] $result */ $result = $stories_query->query( $this->query_args ); update_post_thumbnail_cache( $stories_query ); return $result; } /** * Instantiates the renderer classes based on the view type. * * @since 1.5.0 * * @return Renderer Renderer Instance. */ public function get_renderer(): Renderer { $story_attributes = $this->get_story_attributes(); $view_type = ! empty( $story_attributes['view_type'] ) ? $story_attributes['view_type'] : ''; switch ( $view_type ) { case 'carousel': case 'circles': $renderer = new Carousel_Renderer( $this ); break; case 'list': case 'grid': default: $renderer = new Generic_Renderer( $this ); } $renderer->init(); return $renderer; } /** * Renders the stories output. * * @since 1.5.0 */ public function render(): string { $this->renderer = $this->get_renderer(); return $this->renderer->render(); } /** * Gets an array of story attributes. * * @since 1.5.0 * * @return array An array of story attributes. * * @phpstan-return StoryAttributesWithDefaults */ public function get_story_attributes(): array { $default_attributes = [ 'view_type' => 'circles', 'number_of_columns' => 2, 'show_title' => false, 'show_author' => false, 'show_date' => false, 'show_excerpt' => false, 'show_archive_link' => false, 'sharp_corners' => false, 'archive_link_label' => __( 'View all stories', 'web-stories' ), 'image_alignment' => 'left', 'class' => '', 'circle_size' => 150, ]; /** * Attributes with defaults applied. * * @phpstan-var StoryAttributesWithDefaults $attributes */ $attributes = wp_parse_args( $this->story_attributes, $default_attributes ); return $attributes; } } ================================================ FILE: includes/Story_Revisions.php ================================================ story_post_type = $story_post_type; $this->assets = $assets; } /** * Initialize admin-related functionality. * * @since 1.25.0 */ public function register(): void { $post_type = $this->story_post_type->get_slug(); add_filter( "wp_{$post_type}_revisions_to_keep", [ $this, 'revisions_to_keep' ] ); add_filter( '_wp_post_revision_fields', [ $this, 'filter_revision_fields' ], 10, 2 ); add_filter( 'wp_get_revision_ui_diff', [ $this, 'filter_revision_ui_diff' ], 10, 3 ); add_action( 'admin_print_footer_scripts-revision.php', [ $this, 'enqueue_player_script' ] ); } /** * Force WordPress to only keep 10 revisions for the web stories post type. * * @since 1.25.0 * * @param int|bool|string $num Number of revisions to store. * @return int Number of revisions to store. */ public function revisions_to_keep( $num ): int { $num = (int) $num; return $num >= 0 && $num < 10 ? $num : 10; } /** * Filters the revision fields to ensure that JSON representation gets saved to Story revisions. * * @since 1.25.0 * * @param array|mixed $fields Array of allowed revision fields. * @param array $story Story post array. * @return array|mixed Array of allowed fields. * * @template T * * @phpstan-param PostData $story * @phpstan-return ($fields is array ? array : mixed) */ public function filter_revision_fields( $fields, array $story ) { if ( ! \is_array( $fields ) ) { return $fields; } if ( $this->story_post_type->get_slug() === $story['post_type'] || ( 'revision' === $story['post_type'] && ! empty( $story['post_parent'] ) && get_post_type( $story['post_parent'] ) === $this->story_post_type->get_slug() ) ) { $fields['post_content_filtered'] = __( 'Story data', 'web-stories' ); } return $fields; } /** * Filters the fields displayed in the post revision diff UI. * * @since 1.25.0 * * @param array[]|mixed $fields Array of revision UI fields. Each item is an array of id, name, and diff. * @param WP_Post|false $compare_from The revision post to compare from or false if dealing with the first revision. * @param WP_Post $compare_to The revision post to compare to. * @return array[]|mixed Filtered array of revision UI fields. * * @phpstan-return array|mixed */ public function filter_revision_ui_diff( $fields, $compare_from, WP_Post $compare_to ) { if ( ! \is_array( $fields ) ) { return $fields; } $parent = get_post_parent( $compare_to ); if ( ! $parent instanceof WP_Post || $this->story_post_type->get_slug() !== $parent->post_type ) { return $fields; } $player_from = ''; if ( $compare_from instanceof WP_Post ) { $player_from = $this->get_story_player( $compare_from ); } $player_to = $this->get_story_player( $compare_to ); $args = [ 'show_split_view' => true, 'title_left' => __( 'Removed' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'title_right' => __( 'Added' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain ]; /** This filter is documented in wp-admin/includes/revision.php */ $args = apply_filters( 'revision_text_diff_options', $args, 'post_content', $compare_from, $compare_to ); $fields_to_return = []; /** * Revision field. * * @phpstan-var RevisionField $field * @var array $field */ foreach ( $fields as $field ) { if ( 'post_title' === $field['id'] ) { $fields_to_return[] = $field; } if ( 'post_content' === $field['id'] || 'post_content_filtered' === $field['id'] ) { $field['title'] = __( 'Content', 'web-stories' ); $diff = ''; // In split screen mode, show the title before/after side by side. if ( true === $args['show_split_view'] ) { $diff .= ''; } else { $diff .= ''; } $diff .= ''; $diff .= '
' . $player_from . '' . $player_to . '' . $player_from . '
' . $player_to . '
'; $field['diff'] = $diff; $fields_to_return[] = $field; return $fields_to_return; } } return $fields; } /** * Enqueues amp-story-player assets on the revisions screen. * * @since 1.25.0 */ public function enqueue_player_script(): void { $this->assets->enqueue_style( AMP_Story_Player_Assets::SCRIPT_HANDLE ); $this->assets->enqueue_script( AMP_Story_Player_Assets::SCRIPT_HANDLE ); wp_add_inline_script( AMP_Story_Player_Assets::SCRIPT_HANDLE, <<<'JS' const loadPlayers = () => document.querySelectorAll('amp-story-player').forEach(playerEl => (new AmpStoryPlayer(window, playerEl)).load()); const originalFrame = wp.revisions.view.Frame; wp.revisions.view.Frame = originalFrame.extend({ render: function() { originalFrame.prototype.render.apply(this, arguments); loadPlayers(); this.listenTo( this.model, 'update:diff', () => loadPlayers() ); }, }); JS ); } /** * Returns the story player markup for a given post. * * @since 1.25.0 * * @param WP_Post $post Post instance. * @return string Story player markup. */ protected function get_story_player( WP_Post $post ): string { $url = esc_url( wp_nonce_url( add_query_arg( 'rev_id', $post->ID, get_permalink( $post->post_parent ) ), 'web_stories_revision_for_' . $post->post_parent ) ); $title = esc_html( get_the_title( $post ) ); return <<$title
Player; } } ================================================ FILE: includes/Taxonomy/Category_Taxonomy.php ================================================ taxonomy_post_type = $story_post_type->get_slug(); $this->taxonomy_slug = 'web_story_category'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Category args. * * @since 1.12.0 * * @return array Taxonomy args. * * @phpstan-return TaxonomyArgs */ protected function taxonomy_args(): array { $labels = [ 'name' => _x( 'Categories', 'taxonomy general name', 'web-stories' ), 'singular_name' => _x( 'Category', 'taxonomy singular name', 'web-stories' ), 'search_items' => __( 'Search Categories', 'web-stories' ), 'all_items' => __( 'All Categories', 'web-stories' ), 'parent_item' => __( 'Parent Category', 'web-stories' ), 'parent_item_colon' => __( 'Parent Category:', 'web-stories' ), 'edit_item' => __( 'Edit Category', 'web-stories' ), 'view_item' => __( 'View Category', 'web-stories' ), 'update_item' => __( 'Update Category', 'web-stories' ), 'add_new_item' => __( 'Add New Category', 'web-stories' ), 'new_item_name' => __( 'New Category Name', 'web-stories' ), 'not_found' => __( 'No categories found.', 'web-stories' ), 'no_terms' => __( 'No categories', 'web-stories' ), 'filter_by_item' => __( 'Filter by category', 'web-stories' ), 'items_list_navigation' => __( 'Categories list navigation', 'web-stories' ), 'items_list' => __( 'Categories list', 'web-stories' ), /* translators: Tab heading when selecting from the most used terms. */ 'most_used' => _x( 'Most Used', 'Categories', 'web-stories' ), 'back_to_items' => __( '← Go to Categories', 'web-stories' ), 'item_link' => _x( 'Category Link', 'navigation link block title', 'web-stories' ), 'item_link_description' => _x( 'A link to a category.', 'navigation link block description', 'web-stories' ), ]; return [ 'labels' => $labels, 'hierarchical' => true, 'public' => false, 'publicly_queryable' => true, 'show_ui' => true, 'show_admin_column' => true, 'rewrite' => true, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'capabilities' => self::DEFAULT_CAPABILITIES, 'rest_controller_class' => Stories_Terms_Controller::class, ]; } } ================================================ FILE: includes/Taxonomy/Tag_Taxonomy.php ================================================ taxonomy_post_type = $story_post_type->get_slug(); $this->taxonomy_slug = 'web_story_tag'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Taxonomy args. * * @since 1.12.0 * * @return array Taxonomy args. * * @phpstan-return TaxonomyArgs */ protected function taxonomy_args(): array { $labels = [ 'name' => _x( 'Tags', 'taxonomy general name', 'web-stories' ), 'singular_name' => _x( 'Tag', 'taxonomy singular name', 'web-stories' ), 'search_items' => __( 'Search Tags', 'web-stories' ), 'popular_items' => __( 'Popular Tags', 'web-stories' ), 'all_items' => __( 'All Tags', 'web-stories' ), 'edit_item' => __( 'Edit Tag', 'web-stories' ), 'view_item' => __( 'View Tag', 'web-stories' ), 'update_item' => __( 'Update Tag', 'web-stories' ), 'add_new_item' => __( 'Add New Tag', 'web-stories' ), 'new_item_name' => __( 'New Tag Name', 'web-stories' ), 'separate_items_with_commas' => __( 'Separate tags with commas', 'web-stories' ), 'add_or_remove_items' => __( 'Add or remove tags', 'web-stories' ), 'choose_from_most_used' => __( 'Choose from the most used tags', 'web-stories' ), 'not_found' => __( 'No tags found.', 'web-stories' ), 'no_terms' => __( 'No tags', 'web-stories' ), 'items_list_navigation' => __( 'Tags list navigation', 'web-stories' ), 'items_list' => __( 'Tags list', 'web-stories' ), /* translators: Tab heading when selecting from the most used terms. */ 'most_used' => _x( 'Most Used', 'Tags', 'web-stories' ), 'back_to_items' => __( '← Go to Tags', 'web-stories' ), 'item_link' => _x( 'Tag Link', 'navigation link block title', 'web-stories' ), 'item_link_description' => _x( 'A link to a tag.', 'navigation link block description', 'web-stories' ), ]; return [ 'labels' => $labels, 'hierarchical' => false, 'public' => false, 'publicly_queryable' => true, 'show_ui' => true, 'show_admin_column' => true, 'rewrite' => true, 'show_in_rest' => true, 'rest_namespace' => self::REST_NAMESPACE, 'capabilities' => self::DEFAULT_CAPABILITIES, 'rest_controller_class' => Stories_Terms_Controller::class, ]; } } ================================================ FILE: includes/Taxonomy/Taxonomy_Base.php ================================================ , * _builtin?: bool, * } */ abstract class Taxonomy_Base extends Service_Base implements PluginActivationAware, PluginDeactivationAware, SiteInitializationAware, PluginUninstallAware { public const DEFAULT_CAPABILITIES = [ 'manage_terms' => 'manage_terms_web-stories', 'edit_terms' => 'edit_terms_web-stories', 'delete_terms' => 'delete_terms_web-stories', 'assign_terms' => 'assign_terms_web-stories', ]; /** * Default REST Namespace. */ public const REST_NAMESPACE = 'web-stories/v1'; /** * Taxonomy key, must not exceed 32 characters. */ protected string $taxonomy_slug; /** * Object type which the taxonomy should be associated. */ protected string $taxonomy_post_type; /** * Register taxonomy on register service. * * @since 1.12.0 */ public function register(): void { $this->register_taxonomy(); } /** * Register taxonomy. * * @since 1.12.0 */ public function register_taxonomy(): void { register_taxonomy( $this->taxonomy_slug, $this->taxonomy_post_type, $this->taxonomy_args() ); } /** * Unregister taxonomy. * * @since 1.12.0 */ public function unregister_taxonomy(): void { unregister_taxonomy( $this->taxonomy_slug ); } /** * Act on site initialization. * * @since 1.12.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { $this->register_taxonomy(); } /** * Act on plugin activation. * * @since 1.12.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->register_taxonomy(); } /** * Act on plugin deactivation. * * @since 1.12.0 * * @param bool $network_wide Whether the deactivation was done network-wide. */ public function on_plugin_deactivation( bool $network_wide ): void { $this->unregister_taxonomy(); } /** * Get taxonomy slug. * * @since 1.12.0 */ public function get_taxonomy_slug(): string { return $this->taxonomy_slug; } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { clean_taxonomy_cache( $this->get_taxonomy_slug() ); $term_query = new WP_Term_Query(); $terms = $term_query->query( [ 'taxonomy' => $this->get_taxonomy_slug(), 'hide_empty' => false, ] ); if ( empty( $terms ) || ! \is_array( $terms ) ) { return; } foreach ( $terms as $term ) { if ( $term instanceof WP_Term ) { wp_delete_term( $term->term_id, $term->taxonomy ); } } } /** * Taxonomy args. * * @since 1.12.0 * * @return TaxonomyArgs Taxonomy args. */ abstract protected function taxonomy_args(): array; } ================================================ FILE: includes/Tracking.php ================================================ assets = $assets; $this->experiments = $experiments; $this->site_kit = $site_kit; $this->settings = $settings; $this->preferences = $preferences; $this->woocommerce = $woocommerce; $this->context = $context; } /** * Initializes tracking. * * Registers the script in WordPress. * * @since 1.0.0 */ public function register(): void { // By not passing an actual script src we can print only the inline script. $this->assets->register_script( self::SCRIPT_HANDLE, false, [], WEBSTORIES_VERSION, false ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); } /** * Get the action to use for registering the service. * * @since 1.6.0 * * @return string Registration action to use. */ public static function get_registration_action(): string { return 'init'; } /** * Get the action priority to use for registering the service. * * @since 1.43.0 * * @return int Registration action priority to use. */ public static function get_registration_action_priority(): int { return 1; } /** * Enqueues tracking scripts * * @since 1.43.0 * * @param string $hook_suffix The current admin page. */ public function enqueue_assets( string $hook_suffix ): void { if ( ! $this->context->is_story_editor() && 'web-story' !== $this->context->get_screen_post_type() ) { return; } wp_add_inline_script( self::SCRIPT_HANDLE, 'window.webStoriesTrackingSettings = ' . wp_json_encode( $this->get_settings() ) . ';' ); } /** * Returns tracking settings to pass to the inline script. * * @since 1.0.0 * * @return array|bool|string> Tracking settings. */ public function get_settings(): array { return [ 'trackingAllowed' => $this->is_active(), 'trackingId' => self::TRACKING_ID, 'trackingIdGA4' => self::TRACKING_ID_GA4, // This doesn't seem to be fully working for web properties. // So we send it as both app_version and a user property. // See https://support.google.com/analytics/answer/9268042. 'appVersion' => WEBSTORIES_VERSION, 'userProperties' => $this->get_user_properties(), ]; } /** * Is tracking active for the current user? * * @return bool True if tracking enabled, and False if not. */ public function is_active(): bool { return (bool) $this->preferences->get_preference( get_current_user_id(), Preferences::OPTIN_META_KEY ); } /** * Returns a list of user properties. * * @since 1.4.0 * * @return array User properties. */ private function get_user_properties(): array { /** * Current user. * * @var null|WP_User $current_user */ $current_user = wp_get_current_user(); $roles = $current_user instanceof WP_User ? array_values( $current_user->roles ) : []; $role = ! empty( $roles ) ? array_shift( $roles ) : ''; $experiments = implode( ',', $this->experiments->get_enabled_experiments() ); $active_plugins = []; $woocommerce_status = $this->woocommerce->get_plugin_status(); if ( $woocommerce_status['active'] ) { $active_plugins[] = 'woocommerce'; } $site_kit_status = $this->site_kit->get_plugin_status(); $analytics = $site_kit_status['analyticsActive'] ? 'google-site-kit' : ! empty( $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_ID ) ); if ( $site_kit_status['active'] ) { $active_plugins[] = 'google-site-kit'; } /** * Ad network type. * * @var string $ad_network */ $ad_network = $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ); return [ 'siteLocale' => get_locale(), 'userLocale' => get_user_locale(), 'userRole' => $role, 'enabledExperiments' => $experiments, 'wpVersion' => (string) get_bloginfo( 'version' ), 'phpVersion' => (string) PHP_VERSION, 'isMultisite' => (int) is_multisite(), 'serverEnvironment' => wp_get_environment_type(), 'adNetwork' => $ad_network, 'analytics' => $analytics, 'activePlugins' => implode( ',', $active_plugins ), // This doesn't seem to be fully working for web properties. // So we send it as both app_version and a user property. // See https://support.google.com/analytics/answer/9268042. 'pluginVersion' => WEBSTORIES_VERSION, ]; } } ================================================ FILE: includes/User/Capabilities.php ================================================ story_post_type = $story_post_type; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.29.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Act on plugin activation. * * @since 1.6.0 * * @param bool $network_wide Whether the activation was done network-wide. */ public function on_plugin_activation( bool $network_wide ): void { $this->add_caps_to_roles(); } /** * Act on site initialization. * * @since 1.11.0 * * @param WP_Site $site The site being initialized. */ public function on_site_initialization( WP_Site $site ): void { $this->add_caps_to_roles(); } /** * Act on site removal. * * @since 1.11.0 * * @param WP_Site $site The site being removed. */ public function on_site_removal( WP_Site $site ): void { $this->remove_caps_from_roles(); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { $this->remove_caps_from_roles(); } /** * Adds story capabilities to default user roles. * * This gives WordPress site owners more granular control over story management, * as they can customize this to their liking. * * @since 1.0.0 */ public function add_caps_to_roles(): void { $all_capabilities_raw = $this->get_all_capabilities(); $all_capabilities = array_values( $all_capabilities_raw ); $administrator = get_role( 'administrator' ); $editor = get_role( 'editor' ); $author = get_role( 'author' ); $contributor = get_role( 'contributor' ); if ( $administrator instanceof WP_Role ) { foreach ( $all_capabilities as $cap ) { $administrator->add_cap( $cap ); } } if ( $editor instanceof WP_Role ) { foreach ( $all_capabilities as $cap ) { $editor->add_cap( $cap ); } } if ( $author instanceof WP_Role ) { $author->add_cap( $all_capabilities_raw['edit_posts'] ); $author->add_cap( $all_capabilities_raw['edit_published_posts'] ); $author->add_cap( $all_capabilities_raw['delete_posts'] ); $author->add_cap( $all_capabilities_raw['delete_published_posts'] ); $author->add_cap( $all_capabilities_raw['publish_posts'] ); $author->add_cap( $all_capabilities_raw['assign_terms'] ); } if ( $contributor instanceof WP_Role ) { $contributor->add_cap( $all_capabilities_raw['edit_posts'] ); $contributor->add_cap( $all_capabilities_raw['delete_posts'] ); $contributor->add_cap( $all_capabilities_raw['assign_terms'] ); } /** * Fires when adding the custom capabilities to existing roles. * * Can be used to add the capabilities to other, custom roles. * * @since 1.0.0 * * @param array $all_capabilities List of all post type capabilities, for reference. */ do_action( 'web_stories_add_capabilities', $all_capabilities ); } /** * Removes story capabilities from all user roles. * * @since 1.0.0 */ public function remove_caps_from_roles(): void { $all_capabilities_raw = $this->get_all_capabilities(); $all_capabilities = array_values( $all_capabilities_raw ); $all_capabilities = array_filter( $all_capabilities, static fn( $value ) => 'read' !== $value ); $all_roles = wp_roles(); $roles = array_values( $all_roles->role_objects ); foreach ( $roles as $role ) { foreach ( $all_capabilities as $cap ) { $role->remove_cap( $cap ); } } /** * Fires when removing the custom capabilities from existing roles. * * Can be used to remove the capabilities from other, custom roles. * * @since 1.0.0 * * @param array $all_capabilities List of all post type capabilities, for reference. */ do_action( 'web_stories_remove_capabilities', $all_capabilities ); } /** * Get a array of capability for post types and taxonomies. * * @since 1.12.0 * * @return array Capabilities. */ protected function get_all_capabilities(): array { return array_merge( Taxonomy_Base::DEFAULT_CAPABILITIES, $this->story_post_type->get_caps(), ); } } ================================================ FILE: includes/User/Preferences.php ================================================ register_meta(); } /** * Register meta * * @since 1.15.0 */ public function register_meta(): void { register_meta( 'user', static::OPTIN_META_KEY, [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, 'show_in_rest' => true, 'auth_callback' => [ $this, 'can_edit_current_user' ], 'single' => true, ] ); register_meta( 'user', static::MEDIA_OPTIMIZATION_META_KEY, [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => true, 'show_in_rest' => true, 'auth_callback' => [ $this, 'can_edit_current_user' ], 'single' => true, ] ); register_meta( 'user', static::ONBOARDING_META_KEY, [ 'type' => 'object', 'default' => [], 'show_in_rest' => [ 'schema' => [ 'properties' => [], 'additionalProperties' => true, ], ], 'auth_callback' => [ $this, 'can_edit_current_user' ], 'single' => true, ] ); } /** * Auth callback. * * @since 1.4.0 * * @param bool $allowed Unused. Whether the user can add the object meta. * @param string $meta_key Unused. The meta key. * @param int $user_id ID of the user being edited. * @param int $current_user_id The currently editing user's ID. */ public function can_edit_current_user( bool $allowed, string $meta_key, int $user_id, int $current_user_id ): bool { return user_can( $current_user_id, 'edit_user', $user_id ); } /** * Returns the specific preference for a givern user. * * @since 1.12.0 * * @param int $user_id User ID. * @param string $key Preference key. * @return mixed User preference value. */ public function get_preference( int $user_id, string $key ) { return get_user_meta( $user_id, $key, true ); } /** * Act on plugin uninstall. * * @since 1.26.0 */ public function on_plugin_uninstall(): void { delete_metadata( 'user', 0, self::OPTIN_META_KEY, '', true ); delete_metadata( 'user', 0, self::ONBOARDING_META_KEY, '', true ); delete_metadata( 'user', 0, self::MEDIA_OPTIMIZATION_META_KEY, '', true ); } } ================================================ FILE: includes/Widgets/Stories.php ================================================ * } */ class Stories extends WP_Widget { public const SCRIPT_HANDLE = 'web-stories-widget'; /** * Widget args. * * @var array */ public array $args = [ 'before_title' => '

', 'after_title' => '

', 'before_widget' => '
', 'after_widget' => '
', ]; /** * Assets instance. * * @var Assets Assets instance. */ protected Assets $assets; /** * Story_Post_Type instance. * * @var Story_Post_Type Story_Post_Type instance. */ private Story_Post_Type $story_post_type; /** * Stories_Script_Data instance. * * @var Stories_Script_Data Stories_Script_Data instance. */ protected Stories_Script_Data $stories_script_data; /** * Stories constructor. * * @since 1.5.0 * * @param Assets $assets Assets instance. * @param Story_Post_Type $story_post_type Story_Post_Type instance. * @param Stories_Script_Data $stories_script_data Stories_Script_Data instance. * @return void */ public function __construct( Assets $assets, Story_Post_Type $story_post_type, Stories_Script_Data $stories_script_data ) { $this->assets = $assets; $this->story_post_type = $story_post_type; $this->stories_script_data = $stories_script_data; $id_base = 'web_stories_widget'; $name = __( 'Web Stories', 'web-stories' ); $widget_options = [ 'description' => __( 'Display Web Stories in sidebar section.', 'web-stories' ), 'classname' => 'web-stories-widget', 'show_instance_in_rest' => true, ]; parent::__construct( $id_base, $name, $widget_options ); } /** * Output widget. * * @since 1.5.0 * * @param array $args Widget args. * @param array $instance Widget instance. */ public function widget( $args, $instance ): void { // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped echo $args['before_widget']; $instance = wp_parse_args( $instance, $this->default_values() ); $title = $instance['title']; /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); if ( ! empty( $title ) ) { echo $args['before_title'] . $title . $args['after_title']; } $instance['number_of_columns'] = (int) $instance['number_of_columns']; $instance['number_of_stories'] = (int) $instance['number_of_stories']; $instance['circle_size'] = (int) $instance['circle_size']; $story_attrs = [ 'view_type' => $instance['view_type'], 'show_title' => (bool) $instance['show_title'], 'show_excerpt' => (bool) $instance['show_excerpt'], 'show_author' => (bool) $instance['show_author'], 'show_date' => (bool) $instance['show_date'], 'show_archive_link' => (bool) $instance['show_archive_link'], 'archive_link_label' => (string) $instance['archive_link_label'], 'circle_size' => min( absint( $instance['circle_size'] ), 150 ), 'sharp_corners' => (bool) $instance['sharp_corners'], 'image_alignment' => (string) $instance['image_alignment'], 'number_of_columns' => min( absint( $instance['number_of_columns'] ), 4 ), 'class' => 'web-stories-list--widget', ]; $story_args = [ 'posts_per_page' => min( absint( $instance['number_of_stories'] ), 20 ), // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page 'orderby' => $instance['orderby'], 'order' => $instance['order'], ]; $story_query = new Story_Query( $story_attrs, $story_args ); echo $story_query->render(); echo $args['after_widget']; // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } /** * Display widget form. * * @SuppressWarnings("PHPMD.NPathComplexity") * @SuppressWarnings("PHPMD.ExcessiveMethodLength") * * @since 1.5.0 * * @param array $instance Widget instance. */ public function form( $instance ): string { $this->enqueue_scripts(); $instance = wp_parse_args( $instance, $this->default_values() ); $title = (string) $instance['title']; $view_types = $this->stories_script_data->get_layouts(); $current_view_type = (string) $instance['view_type']; $show_title = ! empty( $instance['show_title'] ); $show_author = ! empty( $instance['show_author'] ); $show_date = ! empty( $instance['show_date'] ); $show_excerpt = ! empty( $instance['show_excerpt'] ); $show_archive_link = ! empty( $instance['show_archive_link'] ); $archive_link_label = (string) $instance['archive_link_label']; $circle_size = (int) $instance['circle_size']; $sharp_corners = (int) $instance['sharp_corners']; $image_alignment = (string) $instance['image_alignment']; $number_of_columns = (int) $instance['number_of_columns']; $number_of_stories = (int) $instance['number_of_stories']; $orderby = (string) $instance['orderby']; $order = (string) $instance['order']; $has_archive = $this->story_post_type->get_has_archive(); $this->input( [ 'id' => 'title', 'name' => 'title', 'label' => __( 'Widget Title', 'web-stories' ), 'type' => 'text', 'value' => $title, 'label_before' => true, ] ); $this->dropdown( [ 'options' => $view_types, 'selected' => $current_view_type, 'name' => 'view_type', 'id' => 'view_type', 'label' => __( 'Select Layout', 'web-stories' ), 'classname' => 'widefat view_type stories-widget-field', ] ); $this->input( [ 'id' => 'number_of_stories', 'name' => 'number_of_stories', 'label' => __( 'Number of Stories', 'web-stories' ), 'type' => 'number', 'classname' => 'widefat number_of_stories stories-widget-field', 'wrapper_class' => 'number_of_stories_wrapper', 'value' => $number_of_stories, 'label_before' => true, 'attributes' => [ 'min' => 1, 'max' => 20, ], ] ); $this->dropdown( [ 'options' => [ 'post_title' => __( 'Title', 'web-stories' ), 'post_date' => __( 'Date', 'web-stories' ), ], 'selected' => $orderby, 'name' => 'orderby', 'id' => 'orderby', 'label' => __( 'Order By', 'web-stories' ), 'classname' => 'widefat orderby stories-widget-field', ] ); $this->dropdown( [ 'options' => [ 'ASC' => __( 'Ascending', 'web-stories' ), 'DESC' => __( 'Descending', 'web-stories' ), ], 'selected' => $order, 'name' => 'order', 'id' => 'order', 'label' => __( 'Order', 'web-stories' ), 'classname' => 'widefat order stories-widget-field', ] ); $this->input( [ 'id' => 'circle-size', 'name' => 'circle_size', 'label' => __( 'Circle Size', 'web-stories' ), 'type' => 'number', 'classname' => 'widefat circle_size stories-widget-field', 'wrapper_class' => 'circle_size_wrapper', 'value' => $circle_size, 'label_before' => true, 'attributes' => [ 'min' => 80, 'max' => 200, 'step' => 5, ], ] ); $this->input( [ 'id' => 'number_of_columns', 'name' => 'number_of_columns', 'label' => __( 'Number of Columns', 'web-stories' ), 'type' => 'number', 'classname' => 'widefat number_of_columns stories-widget-field', 'wrapper_class' => 'number_of_columns_wrapper', 'value' => $number_of_columns, 'label_before' => true, 'attributes' => [ 'min' => 1, 'max' => 4, ], ] ); $this->input( [ 'id' => 'show_title', 'name' => 'show_title', 'label' => __( 'Display Title', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat title stories-widget-field', 'wrapper_class' => 'title_wrapper', 'value' => $show_title, ] ); $this->input( [ 'id' => 'show_excerpt', 'name' => 'show_excerpt', 'label' => __( 'Display Excerpt', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat excerpt stories-widget-field', 'wrapper_class' => 'excerpt_wrapper', 'value' => $show_excerpt, ] ); $this->input( [ 'id' => 'show_author', 'name' => 'show_author', 'label' => __( 'Display Author', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat author stories-widget-field', 'wrapper_class' => 'author_wrapper', 'value' => $show_author, ] ); $this->input( [ 'id' => 'show_date', 'name' => 'show_date', 'label' => __( 'Display Date', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat date stories-widget-field', 'wrapper_class' => 'date_wrapper', 'value' => $show_date, ] ); $this->radio( [ 'options' => [ 'left' => __( 'Left', 'web-stories' ), 'right' => __( 'Right', 'web-stories' ), ], 'selected' => $image_alignment, 'id' => 'image_alignment', 'name' => 'image_alignment', 'label' => __( 'Image Alignment', 'web-stories' ), 'classname' => 'widefat image_alignment stories-widget-field', 'wrapper_class' => 'image_alignment_wrapper', ] ); $this->input( [ 'id' => 'sharp_corners', 'name' => 'sharp_corners', 'label' => __( 'Use Sharp Corners', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat sharp_corners stories-widget-field', 'wrapper_class' => 'sharp_corners_wrapper', 'value' => $sharp_corners, ] ); if ( $has_archive ) { $this->input( [ 'id' => 'show_archive_link', 'name' => 'show_archive_link', 'label' => __( 'Display Archive Link', 'web-stories' ), 'type' => 'checkbox', 'classname' => 'widefat show_archive_link stories-widget-field', 'wrapper_class' => 'archive_link_wrapper', 'value' => $show_archive_link, ] ); $this->input( [ 'id' => 'archive_link_label', 'name' => 'archive_link_label', 'label' => __( 'Archive Link Label', 'web-stories' ), 'type' => 'text', 'classname' => 'widefat archive_link_label stories-widget-field', 'wrapper_class' => 'archive_link_label_wrapper', 'value' => $archive_link_label, 'label_before' => true, ] ); } return ''; } /** * Update widget settings. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.5.0 * * @param array $new_instance New instance. * @param array $old_instance Old instance. * @return array */ public function update( $new_instance, $old_instance ): array { $instance = []; $new_instance = wp_parse_args( $new_instance, $this->default_values() ); $instance['title'] = wp_strip_all_tags( $new_instance['title'] ); $instance['view_type'] = $new_instance['view_type']; $instance['show_title'] = $new_instance['show_title']; $instance['show_excerpt'] = $new_instance['show_excerpt']; $instance['show_author'] = $new_instance['show_author']; $instance['show_date'] = $new_instance['show_date']; $instance['show_archive_link'] = $new_instance['show_archive_link']; $instance['image_alignment'] = $new_instance['image_alignment']; $instance['number_of_columns'] = min( absint( $new_instance['number_of_columns'] ), 4 ); $instance['number_of_stories'] = min( absint( $new_instance['number_of_stories'] ), 20 ); $instance['circle_size'] = min( absint( $new_instance['circle_size'] ), 150 ); $instance['archive_link_label'] = $new_instance['archive_link_label']; $instance['sharp_corners'] = $new_instance['sharp_corners']; $instance['orderby'] = $new_instance['orderby']; $instance['order'] = $new_instance['order']; return $instance; } /** * Enqueue widget script. * * @since 1.5.0 */ public function enqueue_scripts(): void { if ( wp_script_is( self::SCRIPT_HANDLE ) ) { return; } $this->assets->enqueue_style_asset( self::SCRIPT_HANDLE ); $this->assets->enqueue_script_asset( self::SCRIPT_HANDLE, [ 'jquery' ] ); wp_localize_script( self::SCRIPT_HANDLE, 'webStoriesData', $this->stories_script_data->get_script_data() ); } /** * Default values of an instance. * * @since 1.5.0 * * @return array Default values. */ private function default_values(): array { return [ 'title' => esc_html__( 'Web Stories', 'web-stories' ), 'view_type' => 'circles', 'show_title' => '', 'show_excerpt' => '', 'show_author' => '', 'show_date' => '', 'show_archive_link' => '', 'image_alignment' => 'left', 'number_of_columns' => 1, 'number_of_stories' => 5, 'circle_size' => 100, 'archive_link_label' => __( 'View all stories', 'web-stories' ), 'sharp_corners' => '', 'orderby' => 'post_date', 'order' => 'DESC', ]; } /** * Display dropdown. * * @since 1.5.0 * * @param array> $args Field args. */ private function dropdown( array $args ): void { $args = wp_parse_args( $args, [ 'options' => [], 'selected' => '', 'id' => wp_generate_uuid4(), 'name' => wp_generate_uuid4(), 'label' => '', 'classname' => 'widefat', 'wrapper_class' => 'web-stories-field-wrapper', ] ); ?>

label( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

$args Field args. */ private function radio( array $args ): void { $args = wp_parse_args( $args, [ 'options' => [], 'selected' => '', 'id' => wp_generate_uuid4(), 'name' => wp_generate_uuid4(), 'label' => '', 'classname' => 'widefat', 'wrapper_class' => 'web-stories-field-wrapper', ] ); ?>
label( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

$type ) { ?>

'text', 'id' => wp_generate_uuid4(), 'name' => wp_generate_uuid4(), 'label' => '', 'value' => '', 'classname' => 'widefat', 'wrapper_class' => 'web-stories-field-wrapper', 'label_before' => false, 'attributes' => [], ] ); ?>

label( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } $extra_attrs = ''; if ( ! empty( $args['attributes'] ) && \is_array( $args['attributes'] ) ) { /** * Value. * * @var string $attr_val */ foreach ( $args['attributes'] as $attr_key => $attr_val ) { $extra_attrs .= \sprintf( ' %1s=%2s', $attr_key, esc_attr( $attr_val ) ); } } ?> /> label( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } ?>

$args Label args. */ private function label( array $args ): string { $args = wp_parse_args( $args, [ 'id' => '', 'label' => '', ] ); ob_start(); ?> */ protected $extensions = array(); /** * Array of required files. * * @since 1.2.0 * * @var string[] */ protected $required_files = array(); /** * Compatibility constructor. * * @since 1.2.0 * * @param WP_Error $error WP_Error object passed back. */ public function __construct( WP_Error $error ) { // phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedExceptions.NonFullyQualifiedException $this->error = $error; } /** * Check to see if PHP version check passes. * * @since 1.2.0 * * @return bool */ public function check_php_version() { if ( version_compare( PHP_VERSION, $this->get_php_version(), '<' ) ) { /* translators: %s: PHP version number */ $message = esc_html( sprintf( __( 'Web Stories requires PHP %s or higher.', 'web-stories' ), $this->get_php_version() ) ); $data = array( 'title' => $message, ); $this->add_to_error( 'failed_check_php_version', $message, $data ); return false; } return true; } /** * Check to see if WordPress version check passes. * * @since 1.2.0 * * @return bool */ public function check_wp_version() { if ( version_compare( get_bloginfo( 'version' ), $this->get_wp_version(), '<' ) ) { /* translators: %s: WordPress version number */ $message = esc_html( sprintf( __( 'Web Stories requires WordPress %s or higher.', 'web-stories' ), $this->get_wp_version() ) ); $data = array( 'title' => $message, ); $this->add_to_error( 'failed_check_wp_version', $message, $data ); return false; } return true; } /** * Check if required files. * * @since 1.2.0 * * @return bool */ public function check_required_files() { $required_files = $this->get_required_files(); if ( $required_files ) { foreach ( $required_files as $required_file ) { if ( ! is_readable( $required_file ) ) { $message = sprintf( /* translators: %s: build commands. */ __( 'You appear to be running an incomplete version of the plugin. Please run %s to finish installation.', 'web-stories' ), 'composer install && npm install && npm run build' ); $data = array( 'title' => esc_html__( 'Web Stories plugin could not be initialized.', 'web-stories' ), ); $this->add_to_error( 'failed_check_required_files', $message, $data ); return false; } } } return true; } /** * Check to see if all required PHP extensions are installed. * * @since 1.2.0 * * @return bool */ public function check_extensions() { $missing_extensions = array(); foreach ( array_keys( $this->get_extensions() ) as $required_extension ) { if ( ! extension_loaded( $required_extension ) ) { $missing_extensions[] = "$required_extension"; } } if ( count( $missing_extensions ) > 0 ) { $this->add_to_error( 'missing_extension', sprintf( /* translators: %s is list of missing extensions */ _n( 'The following PHP extension is missing: %s. Please contact your host to finish installation.', 'The following PHP extensions are missing: %s. Please contact your host to finish installation.', count( $missing_extensions ), 'web-stories' ), implode( ', ', $missing_extensions ) ) ); return false; } return true; } /** * Check to see if classes exist. * * @since 1.2.0 * * @return bool */ public function check_classes() { $missing_classes = array(); foreach ( $this->get_extensions() as $required_constructs ) { foreach ( $required_constructs as $construct_type => $constructs ) { if ( 'classes' !== $construct_type ) { continue; } foreach ( $constructs as $construct ) { if ( ! class_exists( $construct ) ) { $missing_classes[] = "$construct"; } } } } if ( count( $missing_classes ) > 0 ) { $this->add_to_error( 'missing_class', sprintf( /* translators: %s is list of missing extensions */ _n( 'The following PHP class is missing: %s. Please contact your host to finish installation.', 'The following PHP classes are missing: %s. Please contact your host to finish installation.', count( $missing_classes ), 'web-stories' ), implode( ', ', $missing_classes ) ) ); return false; } return true; } /** * Check to see if all require functions exist. * * @since 1.2.0 * * @return bool */ public function check_functions() { $missing_functions = array(); foreach ( $this->get_extensions() as $required_constructs ) { foreach ( $required_constructs as $construct_type => $constructs ) { if ( 'functions' !== $construct_type ) { continue; } foreach ( $constructs as $construct ) { if ( ! function_exists( $construct ) ) { $missing_functions[] = "$construct"; } } } } if ( count( $missing_functions ) > 0 ) { $this->add_to_error( 'missing_function', sprintf( /* translators: %s is list of missing extensions */ _n( 'The following PHP function is missing: %s. Please contact your host to finish installation.', 'The following PHP functions are missing: %s. Please contact your host to finish installation.', count( $missing_functions ), 'web-stories' ), implode( ', ', $missing_functions ) ) ); return false; } return true; } /** * Run checks in admin. * * @codeCoverageIgnore * * @since 1.2.0 * * @return void */ public function run_checks() { $this->check_php_version(); $this->check_wp_version(); $this->check_required_files(); $this->check_extensions(); $this->check_classes(); $this->check_functions(); } /** * Get min WP version. * * @codeCoverageIgnore * * @return string */ public function get_wp_version() { return $this->wp_version; } /** * Get min PHP version. * * @codeCoverageIgnore * * @since 1.2.0 * * @return string */ public function get_php_version() { return $this->php_version; } /** * Array of extensions. * * @codeCoverageIgnore * * @since 1.2.0 * * @return array */ public function get_extensions() { return $this->extensions; } /** * Get JavaScript path. * * @codeCoverageIgnore * * @since 1.2.0 * * @return string[] */ public function get_required_files() { return $this->required_files; } /** * Getter to get the error object. * * @codeCoverageIgnore * * @since 1.2.0 * * @return WP_Error */ public function get_error() { return $this->error; } /** * Set WordPress version. * * @codeCoverageIgnore * * @since 1.2.0 * * @param string $wp_version WordPress version. * @return void */ public function set_wp_version( $wp_version ) { $this->wp_version = $wp_version; } /** * Set PHP version. * * @codeCoverageIgnore * * @since 1.2.0 * * @param string $php_version PHP version. * @return void */ public function set_php_version( $php_version ) { $this->php_version = $php_version; } /** * Set extensions. * * @codeCoverageIgnore * * @since 1.2.0 * * @param array $extensions Array of extensions. * @return void */ public function set_extensions( array $extensions ) { $this->extensions = $extensions; } /** * Array of require files. * * @codeCoverageIgnore * * @since 1.2.0 * * @param string[] $required_files Array of require files. * @return void */ public function set_required_files( array $required_files ) { $this->required_files = $required_files; } /** * Helper to add error code to WP_Error object. * * @param string|int $code Error code. * @param string $message Error message. * @param mixed $data Optional. Error data. * @return void */ protected function add_to_error( $code, $message, $data = '' ) { if ( ! in_array( $code, $this->error->get_error_codes(), true ) ) { $this->error->add( $code, $message, $data ); } } } ================================================ FILE: includes/compat/amp.php ================================================ L10N_PLACEHOLDER_1_1", "width": 334, "height": 220, "id": "103b0de3-c269-4dbc-bd68-568ef5b7dbd3", "x": 62, "y": 373, "type": "text" }, { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "image", "mimeType": "image/png", "creationDate": "2020-09-22T08:25:46", "src": "https://replaceme.com/images/demo-story/web-stories-logo.png", "width": 194, "height": 194, "posterId": 0, "id": 12757, "title": "", "alt": "Web Stories", "sizes": [], "isExternal": false }, "type": "image", "x": 53, "y": 280, "width": 83, "height": 83, "mask": { "type": "rectangle", "showInLibrary": true, "name": "Rectangle", "path": "M 0,0 1,0 1,1 0,1 0,0 Z", "ratio": 1 }, "id": "1f6c6ae7-01b2-4edb-9626-dac1cb0e5e91" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "cf91578e-22d6-424d-928a-8e0917cfa48d", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "ceeacb28-f8c6-45b4-aa80-46ab655a7c45", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -16, "y": 5, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "ceed60ed-294a-4adb-a28a-2a285b2a320d", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-1.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-1-poster.jpeg", "posterId": 12831, "id": 12830, "length": 4, "lengthFormatted": "0:04", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": false, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "004a2890-04b2-4cea-aa73-961f2450507f", "title": "", "content": "L10N_PLACEHOLDER_2_2", "width": 328, "height": 78, "type": "text", "marginOffset": 0 }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_2_1", "width": 316, "height": 24, "id": "68b0dbfc-c986-4267-b96e-b2bc025d56c9", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "266aff79-1125-4ffd-a4e5-9100541c1b5f", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "d9915c74-6777-4322-aedc-0f5998f82781", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -40, "y": 13, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "20c8b7f3-ef75-4f02-b4b3-6e272167f84b", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-2.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-2-poster.jpeg", "posterId": 12833, "id": 12832, "length": 9, "lengthFormatted": "0:09", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_3_2", "width": 328, "height": 104, "type": "text", "id": "8352409b-8d75-45b1-8a56-bd2f57c81a7f", "x": 42, "y": 501 }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_3_1", "width": 327, "height": 24, "type": "text", "id": "2619d1cc-3328-4683-a5ea-3e6f07f9b5ba", "x": 43, "y": 462 } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "9d8b64c3-5692-4e4c-ae46-785e23613db1", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "e9ddfe89-4c10-43cf-8892-bc49d52a90a5", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -12, "y": 8, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "4b1780d0-1edf-4e1c-a3a0-77d91fb5243d", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-3.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-3-poster.jpeg", "posterId": 12806, "id": 12805, "length": 9, "lengthFormatted": "0:09", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": false, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "f8b4a07c-1f1b-4140-945c-66a729bd77ae", "title": "", "content": "L10N_PLACEHOLDER_4_2", "width": 328, "height": 104, "type": "text", "marginOffset": 0 }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_4_1", "width": 310, "height": 24, "id": "00b5e9e3-1134-4407-974c-8f0f0770eb19", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 74, "g": 64, "b": 177 } }, "type": "page", "id": "f3d54fcf-e8ae-4232-b400-465c81953282", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "e8eb4129-84a9-4e15-b9f9-611e2ef238da", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -33, "y": 8, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "c412199f-d6f5-45cc-8ad8-c4020c3968ad", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-4.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-4-poster.jpeg", "posterId": 12809, "id": 12808, "length": 6, "lengthFormatted": "0:06", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "8abc8e46-48f4-4a57-8396-3a74d5fa0790", "title": "", "content": "L10N_PLACEHOLDER_5_2", "width": 326, "height": 104, "type": "text" }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_5_1", "width": 230, "height": 24, "id": "8f0b5de9-1252-4dab-9596-d1bc7dd45e1c", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "6728de08-eedb-40a2-9a3a-604ff4c87214", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "f9a4110f-8c26-41cc-b36f-3144ae5570c7", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -29, "y": 15, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "c412199f-d6f5-45cc-8ad8-c4020c3968ad", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-5.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-5-poster.jpeg", "posterId": 12813, "id": 12812, "length": 3, "lengthFormatted": "0:03", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "3cdc3e2c-80b9-4946-b0c6-5d473ffe127d", "title": "", "content": "L10N_PLACEHOLDER_6_2", "width": 322, "height": 104, "type": "text" }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_6_1", "width": 281, "height": 24, "id": "7d453d81-a14d-4420-b143-6a39854dceea", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "87eb274d-a2dd-495f-add0-ad681cf757f5", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "e730f3df-a4f3-4415-9dea-820aa8cde222", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -3, "y": 15, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "c412199f-d6f5-45cc-8ad8-c4020c3968ad", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-6.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-6-poster.jpeg", "posterId": 12816, "id": 12815, "length": 22, "lengthFormatted": "0:22", "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 20, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "0108f423-121e-41e7-8fb9-68da265caaba", "title": "", "content": "L10N_PLACEHOLDER_7_2", "width": 318, "height": 104, "type": "text" }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_7_1", "width": 282, "height": 24, "id": "bee50053-183a-43dc-95cd-dcfa6817f4e6", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "f79a3e34-81ab-4165-a2aa-83da192b1e9d", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "a5b02ad2-9692-4794-a206-0da280de8e11", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": -16, "y": 11, "width": 330, "height": 586, "mask": { "type": "rectangle" }, "isBackground": true, "id": "1c6ec285-92e7-41f8-b140-722e3a2fab3b", "type": "video", "scale": 100, "focalX": 50, "focalY": 50, "resource": { "type": "video", "mimeType": "video/mp4", "src": "https://replaceme.com/images/demo-story/video-7.mp4", "width": 824, "height": 1464, "poster": "https://replaceme.com/images/demo-story/video-7-poster.jpeg", "posterId": 12819, "id": 12818, "length": 10, "lengthFormatted": "0:10", "title": "", "alt": "", "sizes": [], "isExternal": false }, "overlay": null }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 19, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "x": 42, "y": 501, "id": "5542ba59-9388-49fc-b7e1-f2ec50c047a9", "title": "", "content": "L10N_PLACEHOLDER_8_2", "width": 330, "height": 96, "type": "text" }, { "opacity": 84, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 24, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "left", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "title": "", "content": "L10N_PLACEHOLDER_8_1", "width": 321, "height": 24, "id": "86baa049-1a48-44ae-a82c-08ba93244754", "x": 42, "y": 461, "type": "text" } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "4cbf7ec8-cf12-4ff2-bfb0-3bef7f3d76cb", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "d82bbd0e-7539-48df-9cc5-158424a4a3d1", "type": "shape" } }, { "elements": [ { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 48, "y": 0, "width": 330, "height": 538, "mask": { "type": "rectangle" }, "isBackground": true, "id": "c412199f-d6f5-45cc-8ad8-c4020c3968ad", "type": "image", "scale": 124, "focalX": 38.51180131399874, "focalY": 51.981874424803884, "resource": { "type": "image", "mimeType": "image/png", "creationDate": "2020-09-22T04:07:58", "src": "https://replaceme.com/images/demo-story/image-2.png", "width": 330, "height": 538, "posterId": 0, "id": 12730, "title": "", "alt": "", "sizes": [], "isExternal": false } }, { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [0, 200], [0, 300], [0, 400], [0, 500], [0, 600], [0, 700], [0, 800], [0, 900], [1, 100], [1, 200], [1, 300], [1, 400], [1, 500], [1, 600], [1, 700], [1, 800], [1, 900] ] }, "fontSize": 38, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1, "textAlign": "initial", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "type": "text", "title": "", "content": "L10N_PLACEHOLDER_9_1", "width": 313, "height": 190, "id": "0106c119-e3d1-416f-92e4-b3f5b7e6c2c5", "x": 49, "y": 292 }, { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": false, "backgroundColor": { "color": { "r": 255, "g": 117, "b": 52 } }, "type": "shape", "width": 30, "height": 4, "mask": { "type": "rectangle" }, "id": "9acf6b79-00a8-4264-854d-63a465f0a8c7", "x": 48.5, "y": 518 }, { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundTextMode": "NONE", "font": { "family": "Poppins", "service": "fonts.google.com", "fallbacks": ["sans-serif"], "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], "styles": ["italic", "regular"], "variants": [ [0, 100], [1, 100], [0, 200], [1, 200], [0, 300], [1, 300], [0, 400], [1, 400], [0, 500], [1, 500], [0, 600], [1, 600], [0, 700], [1, 700], [0, 800], [1, 800], [0, 900], [1, 900] ] }, "fontSize": 18, "backgroundColor": { "color": { "r": 196, "g": 196, "b": 196 } }, "lineHeight": 1.3, "textAlign": "initial", "padding": { "locked": true, "horizontal": 0, "vertical": 0 }, "type": "text", "id": "e59aa680-9a49-439a-98fd-18660d8dfa21", "content": "bit.ly/storybestpractices", "x": 49, "y": 536, "width": 226, "height": 23, "link": { "url": "L10N_PLACEHOLDER_9_2", "icon": "https://amp.dev/static/img/sharing/docs-guide-600x314.png", "desc": "L10N_PLACEHOLDER_9_3" } } ], "animations": [], "backgroundColor": { "color": { "r": 21, "g": 22, "b": 22 } }, "type": "page", "id": "17fc30f7-8968-41a2-aaef-c7a1be1b753c", "defaultBackgroundElement": { "opacity": 100, "flip": { "vertical": false, "horizontal": false }, "rotationAngle": 0, "lockAspectRatio": true, "backgroundColor": { "color": { "r": 255, "g": 255, "b": 255 } }, "x": 1, "y": 1, "width": 1, "height": 1, "mask": { "type": "rectangle" }, "isBackground": true, "isDefaultBackground": true, "id": "b37b1843-756e-48e4-b497-2680412c1381", "type": "shape" } } ], "autoAdvance": false, "defaultPageDuration": 7 } ================================================ FILE: includes/functions.php ================================================ $attrs Arguments for displaying stories. * @param array $query_args Query arguments for stories. * * @phpstan-param array{view_type?: string, number_of_columns?: int, show_title?: bool, show_author?: bool, show_date?: bool, show_archive_link?: bool|string, show_excerpt?: bool, image_alignment?: string, class?: string, archive_link_label?: string, circle_size?: int, sharp_corners?: bool, order?: string, orderby?: string} $attrs */ function render_stories( array $attrs = [], array $query_args = [] ): void { $stories_obj = new Story_Query( $attrs, $query_args ); //phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped echo $stories_obj->render(); } /** * Returns list of stories based on the arguments passed to it. * * @since 1.5.0 * * @param array $attrs Arguments for displaying stories. * @param array $query_args Query arguments for stories. * @return WP_Post[] * * @phpstan-param array{view_type?: string, number_of_columns?: int, show_title?: bool, show_author?: bool, show_date?: bool, show_archive_link?: bool|string, show_excerpt?: bool, image_alignment?: string, class?: string, archive_link_label?: string, circle_size?: int, sharp_corners?: bool, order?: string, orderby?: string} $attrs */ function get_stories( array $attrs = [], array $query_args = [] ): array { return ( new Story_Query( $attrs, $query_args ) )->get_stories(); } /** * Render stories based on customizer settings. * * @since 1.5.0 */ function render_theme_stories(): void { $injector = Services::get_injector(); /** * Customizer instance. * * @var Customizer $customizer Customizer instance. */ $customizer = $injector->make( Customizer::class ); //phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped echo $customizer->render_stories(); } ================================================ FILE: includes/namespace.php ================================================ on_plugin_activation( $network_wide ); /** * Fires after plugin activation. * * @param bool $network_wide Whether to activate network-wide. */ do_action( 'web_stories_activation', $network_wide ); } register_activation_hook( WEBSTORIES_PLUGIN_FILE, __NAMESPACE__ . '\activate' ); /** * Hook into new site creation on Multisite. * * @since 1.0.0 * * @param int|WP_Site $site Site ID or object. */ function new_site( $site ): void { if ( ! is_multisite() ) { return; } $site = get_site( $site ); if ( ! $site ) { return; } // Runs all SiteInitializationAware services. // This will also flush rewrite rules. PluginFactory::create()->on_site_initialization( $site ); } add_action( 'wp_initialize_site', __NAMESPACE__ . '\new_site', PHP_INT_MAX ); /** * Hook into site removal on Multisite. * * @since 1.1.0 * * @param WP_Error $error Unused. * @param int|WP_Site $site Site ID or object. */ function remove_site( WP_Error $error, $site ): void { if ( ! is_multisite() ) { return; } $site = get_site( $site ); if ( ! $site ) { return; } PluginFactory::create()->on_site_removal( $site ); } add_action( 'wp_validate_site_deletion', __NAMESPACE__ . '\remove_site', PHP_INT_MAX, 2 ); /** * Handles plugin deactivation. * * @SuppressWarnings("PHPMD.BooleanArgumentFlag") * * @since 1.0.0 * * @param bool $network_wide Whether to deactivate network-wide. */ function deactivate( ?bool $network_wide = false ): void { $network_wide = (bool) $network_wide; // Runs all PluginDeactivationAware services. // This will also flush rewrite rules. PluginFactory::create()->on_plugin_deactivation( $network_wide ); /** * Fires after plugin deactivation. * * @param bool $network_wide Whether to deactivate network-wide. */ do_action( 'web_stories_deactivation', $network_wide ); } register_deactivation_hook( WEBSTORIES_PLUGIN_FILE, __NAMESPACE__ . '\deactivate' ); /** * Initializes functionality to improve compatibility with the AMP plugin. * * Loads a separate PHP file that allows defining functions in the global namespace. * * Runs on the 'wp' hook to ensure the WP environment has been fully set up, */ function load_amp_plugin_compat(): void { require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/compat/amp.php'; } add_action( 'wp', __NAMESPACE__ . '\load_amp_plugin_compat' ); /** * Load functions for use by plugin developers. * * @todo Move to autoloader */ function load_functions(): void { require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/functions.php'; } add_action( 'init', __NAMESPACE__ . '\load_functions' ); /** * Append result of internal request to REST API for purpose of preloading data to be attached to a page. * Expected to be called in the context of `array_reduce`. * * Like rest_preload_api_request() in core, but embeds links and removes trailing slashes. * * @SuppressWarnings("PHPMD.NPathComplexity") * * @since 1.2.0 * * @link https://core.trac.wordpress.org/ticket/51722 * @link https://core.trac.wordpress.org/ticket/51636 * @see \rest_preload_api_request * * @param array}|array}>> $memo Reduce accumulator. * @param string|array $path REST API path to preload. * @return array}|array}>> Modified reduce accumulator. */ function rest_preload_api_request( array $memo, $path ): array { if ( empty( $path ) ) { return $memo; } $method = 'GET'; if ( \is_array( $path ) ) { if ( 2 !== \count( $path ) ) { return $memo; } $method = end( $path ); $path = (string) reset( $path ); if ( ! \in_array( $method, [ 'GET', 'OPTIONS' ], true ) ) { $method = 'GET'; } } /** * URL parts. * * @var array{path:string, query?: string}|false $path_parts */ $path_parts = wp_parse_url( (string) $path ); if ( ! \is_array( $path_parts ) ) { return $memo; } // This line is different from code. Adds untrailingslashit. See https://core.trac.wordpress.org/ticket/57048. $request = new WP_REST_Request( $method, untrailingslashit( $path_parts['path'] ) ); if ( ! empty( $path_parts['query'] ) ) { $query_params = []; parse_str( $path_parts['query'], $query_params ); $request->set_query_params( $query_params ); } $response = rest_do_request( $request ); if ( 200 === $response->status ) { $server = rest_get_server(); /** * Embed directive. * * @var string|string[] $embed */ $embed = $request['_embed'] ?? false; $embed = $embed ? rest_parse_embed_param( $embed ) : false; $data = $server->response_to_data( $response, $embed ); if ( 'OPTIONS' === $method ) { $response = rest_send_allow_header( $response, $server, $request ); $memo[ $method ][ $path ] = [ 'body' => $data, 'headers' => $response->headers, ]; } else { $memo[ $path ] = [ 'body' => $data, 'headers' => $response->headers, ]; } } return $memo; } /** * Returns the Web stories plugin instance. * * Can be used by other plugins to integrate with the plugin * or to simply detect whether the plugin is active. */ function get_plugin_instance(): Plugin { return PluginFactory::create(); } /** * Bootstrap the plugin. * * @since 1.11.0 */ function bootstrap_plugin(): void { PluginFactory::create()->register(); } add_action( 'plugins_loaded', __NAMESPACE__ . '\bootstrap_plugin' ); ================================================ FILE: includes/polyfills/mbstring.php ================================================ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ // This is is a copy of symfony/polyfill-mbstring/bootstrap.php. // The file is not used directly because after running through PHP-Scoper // it won't be in the global scope anymore. use Google\Web_Stories_Dependencies\Symfony\Polyfill\Mbstring as Google_Web_Stories_Mbstring; if ( ! function_exists( 'mb_convert_encoding' ) ) { function mb_convert_encoding( $s, $to, $from = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_convert_encoding( $s, $to, $from ); } } if ( ! function_exists( 'mb_decode_mimeheader' ) ) { function mb_decode_mimeheader( $s ) { return Google_Web_Stories_Mbstring\Mbstring::mb_decode_mimeheader( $s ); } } if ( ! function_exists( 'mb_encode_mimeheader' ) ) { function mb_encode_mimeheader( $s, $charset = null, $transferEnc = null, $lf = null, $indent = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_encode_mimeheader( $s, $charset, $transferEnc, $lf, $indent ); } } if ( ! function_exists( 'mb_decode_numericentity' ) ) { function mb_decode_numericentity( $s, $convmap, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_decode_numericentity( $s, $convmap, $enc ); } } if ( ! function_exists( 'mb_encode_numericentity' ) ) { function mb_encode_numericentity( $s, $convmap, $enc = null, $is_hex = false ) { return Google_Web_Stories_Mbstring\Mbstring::mb_encode_numericentity( $s, $convmap, $enc, $is_hex ); } } if ( ! function_exists( 'mb_convert_case' ) ) { function mb_convert_case( $s, $mode, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_convert_case( $s, $mode, $enc ); } } if ( ! function_exists( 'mb_internal_encoding' ) ) { function mb_internal_encoding( $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_internal_encoding( $enc ); } } if ( ! function_exists( 'mb_language' ) ) { function mb_language( $lang = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_language( $lang ); } } if ( ! function_exists( 'mb_list_encodings' ) ) { function mb_list_encodings() { return Google_Web_Stories_Mbstring\Mbstring::mb_list_encodings(); } } if ( ! function_exists( 'mb_encoding_aliases' ) ) { function mb_encoding_aliases( $encoding ) { return Google_Web_Stories_Mbstring\Mbstring::mb_encoding_aliases( $encoding ); } } if ( ! function_exists( 'mb_check_encoding' ) ) { function mb_check_encoding( $var = null, $encoding = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_check_encoding( $var, $encoding ); } } if ( ! function_exists( 'mb_detect_encoding' ) ) { function mb_detect_encoding( $str, $encodingList = null, $strict = false ) { return Google_Web_Stories_Mbstring\Mbstring::mb_detect_encoding( $str, $encodingList, $strict ); } } if ( ! function_exists( 'mb_detect_order' ) ) { function mb_detect_order( $encodingList = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_detect_order( $encodingList ); } } if ( ! function_exists( 'mb_parse_str' ) ) { function mb_parse_str( $s, &$result = [] ) { parse_str( $s, $result ); } } if ( ! function_exists( 'mb_strlen' ) ) { function mb_strlen( $s, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strlen( $s, $enc ); } } if ( ! function_exists( 'mb_strpos' ) ) { function mb_strpos( $s, $needle, $offset = 0, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strpos( $s, $needle, $offset, $enc ); } } if ( ! function_exists( 'mb_strtolower' ) ) { function mb_strtolower( $s, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strtolower( $s, $enc ); } } if ( ! function_exists( 'mb_strtoupper' ) ) { function mb_strtoupper( $s, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strtoupper( $s, $enc ); } } if ( ! function_exists( 'mb_substitute_character' ) ) { function mb_substitute_character( $char = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_substitute_character( $char ); } } if ( ! function_exists( 'mb_substr' ) ) { function mb_substr( $s, $start, $length = 2147483647, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_substr( $s, $start, $length, $enc ); } } if ( ! function_exists( 'mb_stripos' ) ) { function mb_stripos( $s, $needle, $offset = 0, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_stripos( $s, $needle, $offset, $enc ); } } if ( ! function_exists( 'mb_stristr' ) ) { function mb_stristr( $s, $needle, $part = false, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_stristr( $s, $needle, $part, $enc ); } } if ( ! function_exists( 'mb_strrchr' ) ) { function mb_strrchr( $s, $needle, $part = false, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strrchr( $s, $needle, $part, $enc ); } } if ( ! function_exists( 'mb_strrichr' ) ) { function mb_strrichr( $s, $needle, $part = false, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strrichr( $s, $needle, $part, $enc ); } } if ( ! function_exists( 'mb_strripos' ) ) { function mb_strripos( $s, $needle, $offset = 0, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strripos( $s, $needle, $offset, $enc ); } } if ( ! function_exists( 'mb_strrpos' ) ) { function mb_strrpos( $s, $needle, $offset = 0, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strrpos( $s, $needle, $offset, $enc ); } } if ( ! function_exists( 'mb_strstr' ) ) { function mb_strstr( $s, $needle, $part = false, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strstr( $s, $needle, $part, $enc ); } } if ( ! function_exists( 'mb_get_info' ) ) { function mb_get_info( $type = 'all' ) { return Google_Web_Stories_Mbstring\Mbstring::mb_get_info( $type ); } } if ( ! function_exists( 'mb_http_output' ) ) { function mb_http_output( $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_http_output( $enc ); } } if ( ! function_exists( 'mb_strwidth' ) ) { function mb_strwidth( $s, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_strwidth( $s, $enc ); } } if ( ! function_exists( 'mb_substr_count' ) ) { function mb_substr_count( $haystack, $needle, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_substr_count( $haystack, $needle, $enc ); } } if ( ! function_exists( 'mb_output_handler' ) ) { function mb_output_handler( $contents, $status ) { return Google_Web_Stories_Mbstring\Mbstring::mb_output_handler( $contents, $status ); } } if ( ! function_exists( 'mb_http_input' ) ) { function mb_http_input( $type = '' ) { return Google_Web_Stories_Mbstring\Mbstring::mb_http_input( $type ); } } if ( ! function_exists( 'mb_convert_variables' ) ) { function mb_convert_variables( $toEncoding, $fromEncoding, &$a = null, &$b = null, &$c = null, &$d = null, &$e = null, &$f = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_convert_variables( $toEncoding, $fromEncoding, $a, $b, $c, $d, $e, $f ); } } if ( ! function_exists( 'mb_ord' ) ) { function mb_ord( $s, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_ord( $s, $enc ); } } if ( ! function_exists( 'mb_chr' ) ) { function mb_chr( $code, $enc = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_chr( $code, $enc ); } } if ( ! function_exists( 'mb_scrub' ) ) { function mb_scrub( $s, $enc = null ) { $enc = $enc ?? mb_internal_encoding(); return mb_convert_encoding( $s, $enc, $enc ); } } if ( ! function_exists( 'mb_str_split' ) ) { function mb_str_split( $string, $split_length = 1, $encoding = null ) { return Google_Web_Stories_Mbstring\Mbstring::mb_str_split( $string, $split_length, $encoding ); } } if ( extension_loaded( 'mbstring' ) ) { return; } if ( ! defined( 'MB_CASE_UPPER' ) ) { define( 'MB_CASE_UPPER', 0 ); } if ( ! defined( 'MB_CASE_LOWER' ) ) { define( 'MB_CASE_LOWER', 1 ); } if ( ! defined( 'MB_CASE_TITLE' ) ) { define( 'MB_CASE_TITLE', 2 ); } ================================================ FILE: includes/templates/admin/activation-notice.php ================================================
================================================ FILE: includes/templates/admin/dashboard.php ================================================ get_dashboard_settings(); $init_script = <<<'JS' wp.domReady( function() { webStories.initializeStoryDashboard( 'web-stories-dashboard', %s ); } ); JS; $script = sprintf( $init_script, wp_json_encode( $dashboard_settings ) ); wp_add_inline_script( Dashboard::SCRIPT_HANDLE, $script ); ?>

================================================ FILE: includes/templates/admin/edit-story.php ================================================ rest_base ) ? $post_type_object->rest_base : $post_type_object->name; $initial_edits = [ 'story' => null ]; // Preload common data. // Important: keep in sync with usage & definition in React app. $preload_paths = [ '/web-stories/v1/media/?' . build_query( [ 'context' => 'view', 'per_page' => 50, 'page' => 1, '_web_stories_envelope' => 'true', '_fields' => rawurlencode( implode( ',', [ 'id', 'date_gmt', 'media_details', 'mime_type', 'featured_media', 'featured_media_src', 'alt_text', 'source_url', 'meta', 'web_stories_media_source', 'web_stories_is_muted', // _web_stories_envelope will add these fields, we need them too. 'body', 'status', 'headers', ] ) ), ] ), '/web-stories/v1/media/?' . build_query( [ 'context' => 'view', 'per_page' => 10, '_fields' => 'source_url', ] ), '/web-stories/v1/users/me/', '/web-stories/v1/taxonomies/?' . build_query( [ 'context' => 'edit', 'show_ui' => 'true', 'type' => $post_type_object->name, ] ), ]; $story_initial_path = "/web-stories/v1/$stories_rest_base/{$post->ID}/?"; $story_query_params = [ '_embed' => rawurlencode( implode( ',', [ 'wp:lock', 'author', 'wp:publisherlogo', 'wp:term' ] ) ), 'context' => 'edit', '_fields' => rawurlencode( implode( ',', [ 'id', 'title', 'status', 'slug', 'date', 'modified', 'excerpt', 'link', 'story_poster', 'story_data', 'preview_link', 'edit_link', 'embed_post_link', 'permalink_template', 'style_presets', 'password', '_links', '_embedded', ] ) ), ]; /* * Ensure the global $post remains the same after API data is preloaded. * Because API preloading can call the_content and other filters, plugins * can unexpectedly modify $post. */ $backup_global_post = $post; if ( empty( $_GET['web-stories-demo'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $preload_paths[] = $story_initial_path . build_query( $story_query_params ); } else { $story_query_params['web_stories_demo'] = 'true'; $story_path = $story_initial_path . build_query( $story_query_params ); $story_data = rest_preload_api_request( [], $story_path ); $initial_edits['story'] = ! empty( $story_data[ $story_path ]['body'] ) ? $story_data[ $story_path ]['body'] : []; } /** * Preload common data by specifying an array of REST API paths that will be preloaded. * * Filters the array of paths that will be preloaded. * * @param string[] $preload_paths Array of paths to preload. * @param WP_Post $post Post being edited. */ $preload_paths = apply_filters( 'web_stories_editor_preload_paths', $preload_paths, $post ); $preload_data = array_reduce( $preload_paths, '\Google\Web_Stories\rest_preload_api_request', [] ); // Restore the global $post as it was before API preloading. $post = $backup_global_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited // In order to duplicate classic meta box behaviour, we need to run the classic meta box actions. require_once ABSPATH . 'wp-admin/includes/meta-boxes.php'; register_and_do_post_meta_boxes( $post ); $editor_settings = Services::get( 'editor' )->get_editor_settings(); wp_add_inline_script( 'wp-api-fetch', sprintf( 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', wp_json_encode( $preload_data ) ), 'after' ); $init_script = <<<'JS' wp.domReady( function() { webStories.initializeStoryEditor( 'web-stories-editor', %s, %s ); } ); JS; $script = sprintf( $init_script, wp_json_encode( $editor_settings ), wp_json_encode( $initial_edits ) ); wp_add_inline_script( Editor::SCRIPT_HANDLE, $script ); require_once ABSPATH . 'wp-admin/admin-header.php'; // TODO: Use custom version of the_block_editor_meta_boxes() without the block editor specifics? ?>

================================================ FILE: includes/templates/admin/experiments.php ================================================

load_from_post( $current_post ); $renderer = new Image( $story ); echo $renderer->render(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } else { get_template_part( 'embed', 'content' ); } endwhile; else : get_template_part( 'embed', '404' ); endif; get_footer( 'embed' ); ================================================ FILE: includes/templates/frontend/single-web-story.php ================================================ ID ) ) { $rev_id = absint( sanitize_text_field( (string) wp_unslash( $_GET['rev_id'] ) ) ); $revision_post = get_post( $rev_id ); if ( $revision_post instanceof WP_Post && $revision_post->post_parent === $current_post->ID ) { $current_post = $revision_post; } } $story->load_from_post( $current_post ); $renderer = new HTML( $story ); echo $renderer->render(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } // Some themes like the Sage theme override the WordPress template hierarchy in an unusual way, // which the Single Renderer tries to work around with filters. // However, that means this template potentially gets loaded twice when using such a theme, causing duplicate markup. // Exiting here avoids that, while still guaranteeing the output buffer to function properly. exit; ================================================ FILE: jest-puppeteer.config.cjs ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Environment variables */ const { PUPPETEER_DEVTOOLS = false, PUPPETEER_HEADLESS = true, PUPPETEER_PRODUCT = 'chrome', PUPPETEER_SLOWMO = 0, } = process.env; module.exports = { launch: { devtools: PUPPETEER_DEVTOOLS === 'true', headless: Boolean(PUPPETEER_HEADLESS), slowMo: Number(PUPPETEER_SLOWMO) || 0, product: PUPPETEER_PRODUCT, args: ['--window-size=1600,1000'], // Same as in percy.config.yml. }, exitOnPageError: false, }; ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "paths": { "@googleforcreators/*": ["./packages/*/src"], "@web-stories-wp/*": ["./packages/*/src"] } }, "exclude": [ "dist", "dist-module", "node_modules", "packages/e2e-tests/src/plugins", "vendor" ] } ================================================ FILE: karma-dashboard.config.cjs ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; /** * External dependencies */ const { readFileSync, existsSync } = require('fs'); /** * Internal dependencies */ const getWebpackConfig = require('./webpack.config.test.cjs'); module.exports = function (config) { let specsToRetry; if ( config.retryFailed && existsSync('build/karma-dashboard-failed-tests.txt') ) { // Loads names of failed specs and prepares them for use in a regex. specsToRetry = readFileSync( 'build/karma-dashboard-failed-tests.txt', 'utf-8' ) .replace(/\s+$/g, '') .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') .replace(/-/g, '\\x2d') .split('\n') .join('|'); } config.set({ plugins: [ 'karma-chrome-launcher', 'karma-jasmine', 'karma-sourcemap-loader', 'karma-webpack', 'karma-coverage-istanbul-reporter', require('@web-stories-wp/karma-puppeteer-launcher'), require('@web-stories-wp/karma-puppeteer-client'), require('@web-stories-wp/karma-failed-tests-reporter'), ], // Frameworks to use. // Available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: [ 'jasmine', '@web-stories-wp/karma-puppeteer-client', 'webpack', ], // list of files / patterns to load in the browser files: [ { pattern: 'packages/dashboard/src/**/karma/**/*.js', watched: false }, { pattern: 'packages/karma-fixture/src/init.js', watched: false }, { pattern: '__static__/**/*', watched: false, included: false, served: true, nocache: false, }, 'node_modules/axe-core/axe.js', ], // list of files / patterns to exclude exclude: ['**/test/**/*.js', '**/*.test.js'], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { 'packages/dashboard/src/**/karma/**/*.js': ['webpack', 'sourcemap'], }, proxies: { '/__static__/': '/base/__static__/', }, webpack: getWebpackConfig('web-stories-dashboard', config), webpackMiddleware: { // webpack-dev-middleware configuration // i. e. stats: 'errors-only', }, webpackServer: { noInfo: true, }, // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter reporters: [ 'dots', '@web-stories-wp/karma-failed-tests-reporter', config.coverage && 'coverage-istanbul', ].filter(Boolean), // web server port port: 9876, // enable / disable colors in the output (reporters and logs) colors: true, // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_INFO, // enable / disable watching file and executing tests whenever any file changes autoWatch: true, // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher browsers: ['karma-puppeteer-launcher'], // @web-stories-wp/karma-puppeteer-launcher package puppeteerLauncher: { puppeteer: { headless: Boolean(config.headless), slowMo: config.slowMo || 0, devtools: config.devtools || false, snapshots: config.snapshots || false, defaultViewport: getViewport(config.viewport), }, }, client: { args: [ specsToRetry && '--grep', specsToRetry && `/${specsToRetry}/`, ].filter(Boolean), jasmine: { timeoutInterval: 20000, }, useIframe: false, runInParent: true, }, coverageIstanbulReporter: { dir: 'build/logs/karma-coverage/dashboard', reports: ['text-summary', 'lcovonly'], }, failedTestsReporter: { outputFile: 'build/karma-dashboard-failed-tests.txt', }, // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: false, // Concurrency level // how many browsers should be started simultaneously concurrency: Infinity, // Allow not having any tests failOnEmptyTestSuite: false, // Prevent duplicate logging to console browserConsoleLogOptions: { terminal: false, }, // Bump browserNoActivityTimeout to 100s to prevent Github Actions timeout browserNoActivityTimeout: 100000, // Wait a bit longer for browser to reconnect. browserDisconnectTimeout: 10000, // Custom context file. customClientContextFile: 'packages/karma-fixture/src/client_with_context.html', }); }; /** * Returns a viewport object for a given flag. * * The following viewports are supported: * - default: no special viewport is used. * - 1600:1000: empirical laptop size. Also used for screenshots. * * A custom W:H viewport is intentionally not supported to reduce number of * test variations. * * Todo: Support the viewport sizes from Figma: * - 1920:1080: the canonical desktop size. * - 1024:680: the canonical iPad size. * * @param {string} flag Viewport flag. * @return {{width: number, height: number}|null} Viewport. */ function getViewport(flag) { if (!flag) { // @todo: switch to 1600:1000 default once Percy is fully launched. return null; } if (flag === '1600:1000') { return { width: 1600, height: 1000 }; } throw new Error(`Unsupported viewport: "${flag}"`); } ================================================ FILE: karma-story-editor.config.cjs ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; /** * External dependencies */ const { readFileSync } = require('fs'); /** * Internal dependencies */ const getWebpackConfig = require('./webpack.config.test.cjs'); module.exports = function (config) { let specsToRetry; if (config.retryFailed) { // Loads names of failed specs and prepares them for use in a regex. specsToRetry = readFileSync( 'build/karma-story-editor-failed-tests.txt', 'utf-8' ) .replace(/\s+$/g, '') .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') .replace(/-/g, '\\x2d') .split('\n') .join('|'); } let totalShards = 1; let shardNumber = 1; if (config.shard) { [shardNumber, totalShards] = config.shard.split('/'); } const shardIndex = shardNumber - 1; const enableParallelRuns = config.shard || config.parallel; // Default is number of CPU cores minus 1. const parallelExecutors = config.parallel && true !== config.parallel ? Number(config.parallel) : undefined; config.set({ plugins: [ 'karma-chrome-launcher', 'karma-jasmine', 'karma-sourcemap-loader', 'karma-webpack', 'karma-coverage-istanbul-reporter', 'karma-parallel', require('@web-stories-wp/karma-puppeteer-launcher'), require('@web-stories-wp/karma-puppeteer-client'), require('@web-stories-wp/karma-failed-tests-reporter'), ], // Frameworks to use. // Available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: [ enableParallelRuns && 'parallel', 'jasmine', '@web-stories-wp/karma-puppeteer-client', 'webpack', ].filter(Boolean), // list of files / patterns to load in the browser files: [ { pattern: 'packages/story-editor/src/**/karma/**/*.js', watched: false }, { pattern: 'packages/karma-fixture/src/init.js', watched: false }, { pattern: '__static__/**/*', watched: false, included: false, served: true, nocache: false, }, 'node_modules/axe-core/axe.js', ], // list of files / patterns to exclude exclude: ['**/test/**/*.js', '**/*.test.js'], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { 'packages/story-editor/src/**/karma/**/*.js': ['webpack', 'sourcemap'], }, proxies: { '/__static__/': '/base/__static__/', }, webpack: getWebpackConfig('web-stories-editor', config), webpackMiddleware: { // webpack-dev-middleware configuration // i. e. stats: 'errors-only', }, webpackServer: { noInfo: true, }, // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter reporters: [ 'dots', '@web-stories-wp/karma-failed-tests-reporter', config.coverage && 'coverage-istanbul', ].filter(Boolean), // web server port port: 9876, // enable / disable colors in the output (reporters and logs) colors: true, // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_INFO, // enable / disable watching file and executing tests whenever any file changes autoWatch: true, // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher browsers: ['karma-puppeteer-launcher'], // @web-stories-wp/karma-puppeteer-launcher package puppeteerLauncher: { puppeteer: { headless: Boolean(config.headless), slowMo: config.slowMo || 0, devtools: config.devtools || false, snapshots: config.snapshots || false, snapshotsDir: '.test_artifacts/karma_snapshots', defaultViewport: getViewport(config.viewport), args: config.headless ? undefined : ['--window-size=1600,1000'], }, }, client: { args: [ specsToRetry && '--grep', specsToRetry && `/${specsToRetry}/`, ].filter(Boolean), jasmine: { timeoutInterval: 20000, }, useIframe: false, runInParent: true, }, coverageIstanbulReporter: { dir: 'build/logs/karma-coverage/story-editor', reports: ['text-summary', 'lcovonly'], }, failedTestsReporter: { outputFile: 'build/karma-story-editor-failed-tests.txt', }, // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: false, // Concurrency level // how many browsers should be started simultaneously concurrency: Infinity, // Sharding configuration for CI. // We trick karma-parallel into using only 1 browser (parallelOptions.executors === 1) // while using a custom strategy that splits up the tests into multiple shards (config.executors > 1). // This allows us to run only a subset of the tests like so: // npm run test:karma:story-editor -- --headless --shard=1/3 # Run the first of 3 desired shards. parallelOptions: { shardStrategy: config.shard ? 'custom' : 'round-robin', // If we're using custom sharding, just spin up 1 browser, // but do the splitting in the custom strategy below. executors: config.shard ? 1 : parallelExecutors, // Re-implements a round-robin strategy, but with a custom shardIndex. // Need to use the Function constructor here so we have access to the outer shardIndex and totalShards vars, // because karma-parallel serializes this function. // eslint-disable-next-line no-new-func -- karma-test customShardStrategy: new Function( 'parallelOptions', ` window.parallelDescribeCount = window.parallelDescribeCount || 0; window.parallelDescribeCount++; const shouldRunThisTest = (window.parallelDescribeCount % ${totalShards} === ${shardIndex}); return shouldRunThisTest; ` ), }, // Allow not having any tests failOnEmptyTestSuite: false, // Prevent duplicate logging to console browserConsoleLogOptions: { terminal: false, }, // When a browser crashes,try to relaunch more than just 2 times (which is the default) retryLimit: 5, // Bump browserNoActivityTimeout to 100s to prevent Github Actions timeout browserNoActivityTimeout: 100000, // Wait a bit longer for browser to reconnect. browserDisconnectTimeout: 10000, // Custom context file. customClientContextFile: 'packages/karma-fixture/src/client_with_context.html', }); }; /** * Returns a viewport object for a given flag. * * The following viewports are supported: * - default: no special viewport is used. * - 1600:1000: empirical laptop size. Also used for screenshots. * * A custom W:H viewport is intentionally not supported to reduce number of * test variations. * * Todo: Support the viewport sizes from Figma: * - 1920:1080: the canonical desktop size. * - 1024:680: the canonical iPad size. * * @param {string} flag Viewport flag. * @return {{width: number, height: number}|null} Viewport. */ function getViewport(flag) { if (!flag) { // @todo: switch to 1600:1000 default once Percy is fully launched. return null; } if (flag === '1600:1000') { return { width: 1600, height: 1000 }; } throw new Error(`Unsupported viewport: "${flag}"`); } ================================================ FILE: package.json ================================================ { "name": "web-stories-wp", "description": "Visual storytelling for WordPress.", "private": true, "author": { "name": "Google", "url": "https://creators.google/" }, "license": "Apache-2.0", "keywords": [ "amp", "stories", "storytelling", "wordpress" ], "homepage": "https://github.com/googleforcreators/web-stories-wp/", "repository": { "type": "git", "url": "https://github.com/GoogleForCreators/web-stories-wp.git" }, "bugs": { "url": "https://github.com/googleforcreators/web-stories-wp/issues" }, "engines": { "node": ">= 24", "npm": ">= 10" }, "type": "module", "workspaces": { "packages": [ "packages/*" ] }, "devDependencies": { "@ampproject/toolbox-optimizer": "^2.10.1", "@babel/core": "^7.29.0", "@babel/eslint-parser": "^7.28.6", "@babel/eslint-plugin": "^7.27.1", "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0", "@googleforcreators/dashboard": "*", "@googleforcreators/design-system": "*", "@googleforcreators/fonts": "*", "@googleforcreators/media": "*", "@googleforcreators/migration": "*", "@googleforcreators/moveable": "*", "@googleforcreators/story-editor": "*", "@jest/types": "^30.2.0", "@jsdevtools/coverage-istanbul-loader": "^3.0.5", "@manypkg/get-packages": "^2.2.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@prettier/plugin-xml": "^3.4.2", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-dynamic-import-vars": "^2.1.5", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-url": "^8.0.2", "@storybook/addon-a11y": "^9.1.10", "@storybook/addon-docs": "^9.1.10", "@storybook/addon-links": "^9.1.10", "@storybook/addon-webpack5-compiler-babel": "^3.0.6", "@storybook/csf": "^0.1.13", "@storybook/react-webpack5": "^9.1.10", "@stylelint/postcss-css-in-js": "^0.38.0", "@svgr/rollup": "^8.0.1", "@svgr/webpack": "^8.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.6.1", "@types/jest": "^29.5.14", "@types/jsdom": "^27.0.0", "@types/node": "^24.7.1", "@types/styled-components": "^5.1.26", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", "@web-stories-wp/e2e-tests": "*", "@web-stories-wp/eslint-import-resolver": "*", "@web-stories-wp/jest-amp": "*", "@web-stories-wp/jest-resolver": "*", "@web-stories-wp/karma-failed-tests-reporter": "*", "@web-stories-wp/karma-puppeteer-client": "*", "@web-stories-wp/karma-puppeteer-launcher": "*", "@wordpress/babel-plugin-import-jsx-pragma": "^5.43.0", "@wordpress/dependency-extraction-webpack-plugin": "^6.43.0", "@wordpress/eslint-plugin": "^24.5.0", "ajv-cli": "^5.0.0", "ajv-formats": "^3.0.1", "babel-jest": "^30.3.0", "babel-loader": "^10.1.1", "babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-styled-components": "^2.1.4", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "browserslist": "^4.27.0", "circular-dependency-plugin": "^5.2.2", "core-js": "^3.46.0", "cross-env": "^10.1.0", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^8.0.0", "cssnano": "^7.1.2", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-header": "^3.1.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jasmine": "^4.2.2", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-jest-dom": "^5.5.0", "eslint-plugin-jest-extended": "^3.0.1", "eslint-plugin-jsdoc": "^61.0.1", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-markdown": "^5.1.0", "eslint-plugin-n": "^17.23.1", "eslint-plugin-oxlint": "^1.48.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-security": "^3.0.1", "eslint-plugin-styled-components-a11y": "^2.2.1", "eslint-plugin-testing-library": "^7.16.0", "html-webpack-plugin": "^5.6.6", "husky": "^9.1.7", "jest": "^29.7.0", "jest-axe": "^9.0.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.3.0", "jest-extended": "^6.0.0", "jest-fetch-mock": "^3.0.3", "jest-matcher-deep-close-to": "^3.0.2", "jest-silent-reporter": "^0.6.0", "jest-styled-components": "^7.2.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage-istanbul-reporter": "^3.0.3", "karma-jasmine": "^5.1.0", "karma-parallel": "^0.3.1", "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", "lint-staged": "^16.2.3", "markdownlint-cli": "^0.45.0", "mini-css-extract-plugin": "^2.9.4", "mockdate": "^3.0.5", "npm-package-json-lint": "^9.0.0", "npm-run-all": "^4.1.5", "oxlint": "^1.48.0", "patch-package": "^8.0.1", "postcss-jsx": "^0.36.4", "postcss-styled-syntax": "^0.7.1", "postcss-syntax": "^0.36.2", "prettier": "^3.8.1", "puppeteer": "^24.37.3", "react-compiler-runtime": "19.1.0-rc.3", "react-refresh": "^0.18.0", "react-test-renderer": "^17.0.2", "rollup": "^2.80.0", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-delete": "^3.0.2", "rollup-plugin-web-worker-loader": "^1.7.0", "rtlcss-webpack-plugin": "^4.0.7", "source-map-loader": "^5.0.0", "storybook": "^9.1.20", "styled-components": "^5.3.11", "stylelint": "^16.25.0", "stylelint-config-recommended": "^16.0.0", "stylelint-prettier": "^5.0.3", "stylis-plugin-rtl": "^1.0.0", "terser-webpack-plugin": "^5.4.0", "typescript": "^5.9.3", "webpack": "^5.99.7", "webpack-bundle-analyzer": "^5.3.0", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3", "webpackbar": "^7.0.0", "worker-loader": "^3.0.8" }, "overrides": { "@axe-core/puppeteer": { "puppeteer": "$puppeteer" }, "@typescript-eslint/parser": "$@typescript-eslint/parser", "@typescript-eslint/eslint-plugin": "$@typescript-eslint/eslint-plugin", "braces": "3.0.3", "date-fns": "^2.30.0", "eslint-plugin-jsdoc": "$eslint-plugin-jsdoc", "eslint-plugin-prettier": "$eslint-plugin-prettier", "fast-json-patch": "3.1.1", "terser": "5.37.0", "ua-parser-js": "0.7.38", "postcss": "8.5.1", "webpack": "$webpack", "storybook": "$storybook" }, "scripts": { "build": "npm-run-all build:*", "prebuild:js": "rm -rf assets/css/* assets/js/*", "build:js": "cross-env NODE_ENV=production webpack --config webpack.config.cjs", "postbuild:js": "rm -rf assets/js/web-stories-list-styles* assets/js/web-stories-theme-style*", "predev": "rm -rf assets/css/* assets/js/*", "dev": "webpack --config webpack.config.cjs --watch", "postdev": "rm -rf assets/js/web-stories-list-styles* assets/js/web-stories-theme-style*", "preserve": "rm -rf assets/css/* assets/js/*", "serve": "webpack serve --config webpack.config.cjs", "postserve": "rm -rf assets/js/web-stories-list-styles* assets/js/web-stories-theme-style*", "env:start": "bash ./bin/local-env/start.sh", "env:stop": "bash ./bin/local-env/stop.sh", "env:reset-site": "bash ./bin/local-env/install-wordpress.sh --reset-site", "format": "npm-run-all --parallel format:*", "format:css": "npm run lint:css:css:fix", "format:css-in-js": "npm run lint:css:js:fix", "format:js": "npm run lint:js:fix", "format:md": "prettier --write '**/*.md'", "format:php": "npm run lint:php:fix", "format:xml": "prettier --write '**/*.{xml,xml.dist}'", "format:yml": "prettier --write '**/*.yml'", "lint": "npm-run-all --parallel lint:*", "lint:css": "npm-run-all --parallel --continue-on-error lint:css:*", "lint:css:js": "stylelint \"**/*.js\"", "lint:css:js:fix": "stylelint \"**/*.js\" --fix", "lint:css:css": "stylelint \"**/*.css\"", "lint:css:css:fix": "stylelint \"**/*.css\" --fix", "lint:js": "oxlint && eslint .", "lint:js:fix": "oxlint --fix && eslint --fix .", "lint:js:report": "oxlint -c=.oxlintrc.json --tsconfig=tsconfig.json --ignore-pattern=@types --react-perf-plugin && eslint --output-file build/lint-js-report.json --format json .", "lint:package-json": "npmPkgJsonLint .", "lint:php": "composer phpcs", "lint:php:fix": "composer phpcbf", "lint:phpstan": "composer phpstan", "lint:phpmd": "composer phpmd", "lint:md": "markdownlint .", "postinstall": "patch-package", "storybook": "storybook dev --quiet", "storybook:build": "storybook build -c .storybook -o build/storybook --quiet", "test": "npm-run-all --parallel test:*", "pretest:js": "if [ -z $AMP_VALIDATOR_FILE ]; then curl --output-dir $TMPDIR -O -f https://cdn.ampproject.org/v0/validator_wasm.js; fi", "test:js": "jest --config=tests/js/jest.config.js", "test:js:coverage": "npm run test:js -- --collectCoverage; open build/logs/lcov-report/index.html", "test:js:debug": "node --inspect-brk node_modules/.bin/jest --config=tests/js/jest.config.js", "test:js:help": "npm run test:js -- --help", "test:js:watch": "npm run test:js -- --watch", "test:karma": "npm-run-all --parallel test:karma:*", "pretest:karma:story-editor": "rm -rf build/karma-story-editor-failed-tests.txt", "test:karma:story-editor": "karma start karma-story-editor.config.cjs --single-run", "test:karma:story-editor:retry-failed": "karma start karma-story-editor.config.cjs --single-run --retry-failed", "test:karma:story-editor:watch": "karma start karma-story-editor.config.cjs", "pretest:karma:dashboard": "rm -rf build/karma-dashboard-failed-tests.txt", "test:karma:dashboard": "karma start karma-dashboard.config.cjs --single-run", "test:karma:dashboard:retry-failed": "karma start karma-dashboard.config.cjs --single-run --retry-failed", "test:karma:dashboard:watch": "karma start karma-dashboard.config.cjs --debug", "test:php": "npm-run-all test:php:*", "test:php:unit": "vendor/bin/phpunit", "test:php:integration": "npm-run-all test:php:integration:*", "test:php:integration:single": "vendor/bin/phpunit -c phpunit-integration.xml.dist", "test:php:integration:multisite": "vendor/bin/phpunit -c phpunit-integration-multisite.xml.dist", "test:php:unit:help": "npm run test:php -- --help", "pretest:e2e": "if [ -z $AMP_VALIDATOR_FILE ]; then curl --output-dir $TMPDIR -O -f https://cdn.ampproject.org/v0/validator_wasm.js; fi", "test:e2e": "cross-env JEST_PUPPETEER_CONFIG=jest-puppeteer.config.cjs jest --runInBand --config=packages/e2e-tests/src/jest.config.js", "test:e2e:help": "npm run test:e2e -- --help", "test:e2e:percy": "percy exec --quiet --parallel --config=percy.config.yml -- npm run test:e2e", "test:e2e:watch": "npm run test:e2e -- --watch", "test:e2e:interactive": "cross-env PUPPETEER_HEADLESS=false PUPPETEER_SLOWMO=80 npm run test:e2e", "test:e2e:debug": "cross-env PUPPETEER_HEADLESS=false PUPPETEER_DEVTOOLS=true JEST_PUPPETEER_CONFIG=jest-puppeteer.config.cjs node --inspect-brk node_modules/.bin/jest --runInBand --config=packages/e2e-tests/src/jest.config.js", "test:e2e:firefox": "cross-env PUPPETEER_PRODUCT=firefox npm run test:e2e", "test:e2e:firefox:interactive": "cross-env PUPPETEER_PRODUCT=firefox npm run test:e2e:interactive", "test:e2e:firefox:debug": "cross-env PUPPETEER_PRODUCT=firefox PUPPETEER_HEADLESS=false PUPPETEER_DEVTOOLS=true node --inspect-brk node_modules/.bin/jest --runInBand --config=packages/e2e-tests/src/jest.config.js", "test:schema": "npm-run-all --parallel test:schema:*", "test:schema:templates": "ls -1 packages/templates/src/raw/*/template.json | xargs -I% npx ajv validate -s bin/schemas/story.json -d % -c ajv-formats", "test:schema:text-sets": "ls -1 packages/text-sets/src/raw/*.json | xargs -I% npx ajv validate -s bin/schemas/story.json -d % -c ajv-formats", "test:schema:ftue-story": "npx ajv validate -s bin/schemas/story.json -d includes/data/stories/demo.json -c ajv-formats", "percy": "percy snapshot --config=percy.config.yml .test_artifacts/karma_snapshots/", "local-registry:start": "bash ./bin/setup-local-npm-registry.sh", "local-registry:stop": "bash ./bin/stop-local-npm-registry.sh", "workflow:assets-version": "commander assets-version", "workflow:version": "commander version", "workflow:build-plugin": "commander build-plugin", "workflow:bundle-packages": "npm-run-all --parallel workflow:bundle-packages:*", "workflow:bundle-packages:code": "rollup -c", "workflow:bundle-packages:types": "tsc --build", "workflow:fonts": "npm run update-fonts --workspace packages/fonts", "postworkflow:fonts": "npx prettier --write packages/fonts/src/fonts.json; cp packages/fonts/src/fonts.json includes/data/fonts/fonts.json", "workflow:render-text-sets": "npm run render-text-sets --workspace packages/text-sets", "workflow:render-template-posters": "npm run render-template-posters --workspace=packages/templates", "preworkflow:migrate": "[ -f packages/migration/scripts/module.js ] || npx rollup --config packages/migration/rollup.config.js", "workflow:migrate": "npm-run-all --parallel workflow:migrate:*", "workflow:migrate:text-sets": "npm run migration --workspace packages/migration packages/text-sets/src/raw", "postworkflow:migrate:text-sets": "npx prettier --write packages/text-sets/src/raw", "workflow:migrate:templates": "npm run migration --workspace packages/migration packages/templates/src/raw", "postworkflow:migrate:templates": "npx prettier --write packages/templates/src/raw", "workflow:migrate:ftue-story": "npm run migration --workspace packages/migration includes/data/stories", "postworkflow:migrate:ftue-story": "npx prettier --write includes/data/stories", "workflow:normalize-path": "commander normalize-path" }, "lint-staged": { "*.(cjs|js|ts|tsx)": [ "eslint --fix" ], "*.(js|tsx)": [ "stylelint --fix --allow-empty-input" ], "*.json": [ "prettier --write" ], "*.css": "stylelint --fix --allow-empty-input", "*.md": [ "eslint --fix", "markdownlint -f" ], "*.php": "npm run lint:php:fix", "*.(xml|xml.dist)": "prettier --write", "*.yml": "prettier --write" }, "changelog": { "labels": { "Type: Enhancement": "🚀 Features", "Type: Bug": "🐛 Bug Fixes", "Type: Infrastructure": "🧰 Maintenance", "Type: Documentation": ":memo: Documentation" }, "ignoreCommitters": [ "dependabot", "github-actions" ] } } ================================================ FILE: packages/activation-notice/README.md ================================================ # Activation Notice WordPress plugin activation notice. ================================================ FILE: packages/activation-notice/package.json ================================================ { "name": "@web-stories-wp/activation-notice", "description": "Web Stories for WordPress plugin activation notice", "private": true, "author": { "name": "Google", "url": "https://creators.google/" }, "license": "Apache-2.0", "keywords": [ "web stories", "wordpress" ], "homepage": "https://github.com/GoogleForCreators/web-stories-wp/blob/main/packages/activation-notice/README.md", "repository": { "type": "git", "url": "https://github.com/GoogleForCreators/web-stories-wp.git", "directory": "packages/activation-notice" }, "bugs": { "url": "https://github.com/googleforcreators/web-stories-wp/issues" }, "engines": { "node": ">= 20 || >= 22 || >= 24", "npm": ">= 7.3" }, "main": "./src/index.tsx", "types": "dist-types/index.d.ts", "dependencies": { "@emotion/is-prop-valid": "^1.4.0", "@googleforcreators/tracking": "*", "@wordpress/element": "^6.32.0", "@wordpress/i18n": "^6.16.0", "flagged": "^2.0.10", "polished": "^4.3.1", "styled-components": "^6.0.7", "stylis-plugin-rtl": "^2.1.1" }, "devDependencies": { "@testing-library/react": "^16.3.0" } } ================================================ FILE: packages/activation-notice/src/app/components/dismiss.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * WordPress dependencies */ import { useEffect } from '@wordpress/element'; /** * Renders a Dismiss button as required/used by WordPress. * * Does not actually *render* the button itself, but emits an event so that * WordPress detects the notice and registers a click handler. * * @return Rendered component. */ function Dismiss() { useEffect(() => { document.dispatchEvent(new Event('wp-updates-notice-added')); }, []); return null; } export default Dismiss; ================================================ FILE: packages/activation-notice/src/app/components/image.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; /** * Internal dependencies */ import { useConfig } from '../config'; interface ImgProps { $rotationAngle: string; } interface ImageProps { name: string; name2x: string; width: number; height: number; } const Img = styled.img` transform: rotate(${(props) => props.$rotationAngle}); margin-top: 60px; `; function Image({ name, name2x, ...props }: ImageProps) { const { cdnURL, isRTL } = useConfig(); return ( ); } export default Image; ================================================ FILE: packages/activation-notice/src/app/components/link.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; const Link = styled.a` font-family: ${({ theme }) => theme.fonts.body.family}; font-size: ${({ theme }) => theme.fonts.body.size}; line-height: ${({ theme }) => theme.fonts.body.lineHeight}; font-weight: ${({ theme }) => theme.fonts.body.fontWeight}; color: ${({ theme }) => theme.colors.link.fg}; text-decoration: none; cursor: pointer; display: block; &:focus, &:hover { color: ${({ theme }) => theme.colors.link.hover.fg}; } `; export default Link; ================================================ FILE: packages/activation-notice/src/app/components/messageContent.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { rgba } from 'polished'; /** * Internal dependencies */ import SuccessMessage from './successMessage'; import Dismiss from './dismiss'; import Step1 from './step1'; import Step2 from './step2'; import Step3 from './step3'; const Wrapper = styled.div` font-family: ${({ theme }) => theme.fonts.body.family}; font-size: ${({ theme }) => theme.fonts.body.size}; line-height: ${({ theme }) => theme.fonts.body.lineHeight}; font-weight: ${({ theme }) => theme.fonts.body.fontWeight}; color: ${({ theme }) => theme.colors.primary}; background: ${({ theme }) => `linear-gradient( 115.54deg, ${rgba(theme.colors.bg.start, 0.2)} 9.27%, ${rgba(theme.colors.bg.end, 0.2)} 47.82%, ${rgba(theme.colors.bg.end, 0)} 66.64% ), linear-gradient( 158.59deg, ${rgba(theme.colors.bg.start, 0.3)} 13.24%, ${rgba(theme.colors.bg.end, 0.3)} 86.01% ), linear-gradient( 115.54deg, ${rgba(theme.colors.bg.start, 0.2)} 9.27%, ${rgba(theme.colors.bg.end, 0.2)} 47.82%, ${rgba(theme.colors.bg.end, 0)} 66.64% ), linear-gradient(70.23deg, #010218 -28.03%, #1b0418 95.56%)`}; box-sizing: border-box; display: flex; overflow: hidden; padding: 0 30px 0 45px; justify-content: center; @media ${({ theme }) => theme.breakpoint.tabletSmall} { max-height: 245px; } @media ${({ theme }) => theme.breakpoint.tabletLarge} { justify-content: space-around; } `; function MessageContent() { return ( ); } export default MessageContent; ================================================ FILE: packages/activation-notice/src/app/components/number.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; const Number = styled.span` font-family: ${({ theme }) => theme.fonts.stepNumber.family}; font-size: ${({ theme }) => theme.fonts.stepNumber.size}; line-height: ${({ theme }) => theme.fonts.stepNumber.lineHeight}; font-weight: ${({ theme }) => theme.fonts.stepNumber.fontWeight}; color: ${({ theme }) => theme.colors.tertiary}; `; export default Number; ================================================ FILE: packages/activation-notice/src/app/components/paragraph.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; interface ParagraphProps { $secondary?: boolean; } const Paragraph = styled.p` font-family: ${({ theme }) => theme.fonts.body.family}; font-size: ${({ theme }) => theme.fonts.body.size}; line-height: ${({ theme }) => theme.fonts.body.lineHeight}; font-weight: ${({ theme }) => theme.fonts.body.fontWeight}; color: ${({ theme, $secondary }) => $secondary ? theme.colors.secondary : theme.colors.primary}; margin: 0 !important; `; export default Paragraph; ================================================ FILE: packages/activation-notice/src/app/components/step1.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { trackClick } from '@googleforcreators/tracking'; import type { MouseEventHandler, MouseEvent } from 'react'; /** * WordPress dependencies */ import { createInterpolateElement, useCallback } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; /** * Internal dependencies */ import { useConfig } from '../config'; import Paragraph from './paragraph'; import Link from './link'; import Number from './number'; import Image from './image'; const Wrapper = styled.div` display: none; min-width: 300px; justify-content: center; @media ${({ theme }) => theme.breakpoint.tabletSmall} { display: flex; } `; const ParagraphWrapper = styled.div` align-self: flex-start; margin: 20px 0 0 70px; text-align: right; min-width: 100px; @media ${({ theme }) => theme.breakpoint.desktop} { margin-left: 30px; } `; function Step1() { const { demoStoryURL } = useConfig(); const onClick: MouseEventHandler = useCallback( (evt: MouseEvent) => { void trackClick(evt, 'open_demo_story'); }, [] ); // createInterpolateElement doesn't support br tags. const translatedString = createInterpolateElement( __('Read the Get Started story', 'web-stories'), { a: , } ); return ( { /* translators: Number of the step displayed in plugin activation message. */ _x('1', 'Step number', 'web-stories') } {translatedString} ); } export default Step1; ================================================ FILE: packages/activation-notice/src/app/components/step2.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { trackClick } from '@googleforcreators/tracking'; import type { MouseEventHandler, MouseEvent } from 'react'; /** * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; import { createInterpolateElement, useCallback } from '@wordpress/element'; /** * Internal dependencies */ import { useConfig } from '../config'; import Paragraph from './paragraph'; import Link from './link'; import Number from './number'; import Image from './image'; const Wrapper = styled.div` display: none; margin-left: 20px; justify-content: center; @media ${({ theme }) => theme.breakpoint.tabletLarge} { display: flex; } `; const ParagraphWrapper = styled.div` align-self: flex-start; margin: 20px 0 0 -50px; min-width: 100px; `; function Step2() { const { dashboardURL } = useConfig(); const onClick: MouseEventHandler = useCallback( (evt: MouseEvent) => { void trackClick(evt, 'open_dashboard'); }, [] ); // createInterpolateElement doesn't support br tags. const translatedString = createInterpolateElement( __('Head to the Dashboard', 'web-stories'), { a: , } ); return ( { /* translators: Number of the step displayed in plugin activation message. */ _x('2', 'Step number', 'web-stories') } {translatedString} ); } export default Step2; ================================================ FILE: packages/activation-notice/src/app/components/step3.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { trackClick } from '@googleforcreators/tracking'; /** * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; import { useCallback, createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ import type { MouseEventHandler, MouseEvent } from 'react'; import { useConfig } from '../config'; import Paragraph from './paragraph'; import Link from './link'; import Number from './number'; import Image from './image'; const Wrapper = styled.div` display: none; margin-left: 30px; justify-content: center; @media ${({ theme }) => theme.breakpoint.desktop} { display: flex; } `; const ParagraphWrapper = styled.div` align-self: flex-start; margin: 20px 0 0 -50px; `; function Step3() { const { newStoryURL } = useConfig(); const onClick: MouseEventHandler = useCallback( (event: MouseEvent) => { void trackClick(event, 'open_story_editor'); }, [] ); // createInterpolateElement doesn't support br tags. const translatedString = createInterpolateElement( __('Jump into the Editor', 'web-stories'), { a: , } ); return ( { /* translators: Number of the step displayed in plugin activation message. */ _x('3', 'Step number', 'web-stories') } {translatedString} ); } export default Step3; ================================================ FILE: packages/activation-notice/src/app/components/successMessage.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { trackClick } from '@googleforcreators/tracking'; import type { MouseEventHandler, MouseEvent } from 'react'; /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ import { useConfig } from '../config'; import Paragraph from './paragraph'; import SecondaryLink from './link'; const Message = styled.div` min-width: 260px; padding: 30px 0 30px; ${SecondaryLink} { @media ${({ theme }) => theme.breakpoint.tabletSmall} { display: none; } } `; const Title = styled.h2` font-family: ${({ theme }) => theme.fonts.title.family}; font-size: ${({ theme }) => theme.fonts.title.size}; line-height: ${({ theme }) => theme.fonts.title.lineHeight}; font-weight: ${({ theme }) => theme.fonts.title.fontWeight}; color: ${({ theme }) => theme.colors.primary}; margin: 0 0 10px; `; const PrimaryLink = styled.a` font-family: ${({ theme }) => theme.fonts.button.family}; font-size: ${({ theme }) => theme.fonts.button.size}; line-height: ${({ theme }) => theme.fonts.button.lineHeight}; font-weight: ${({ theme }) => theme.fonts.button.fontWeight}; background: ${({ theme }) => theme.colors.action.bg}; color: ${({ theme }) => theme.colors.action.fg}; padding: 5px 8px; cursor: pointer; text-decoration: none; border-radius: 2px; &:focus, &:hover { background: ${({ theme }) => theme.colors.action.hover.bg}; color: ${({ theme }) => theme.colors.action.fg}; } `; const ParagraphWithSpace = styled(Paragraph)` margin-bottom: 15px !important; `; function SuccessMessage() { const { dashboardURL, demoStoryURL } = useConfig(); const onClickPrimary: MouseEventHandler = useCallback( (evt: MouseEvent) => { void trackClick(evt, 'open_dashboard'); }, [] ); const onClickSecondary: MouseEventHandler = useCallback( (evt: MouseEvent) => { void trackClick(evt, 'open_demo_story'); }, [] ); return ( {__("You're all set!", 'web-stories')} <br /> {__('Tell some stories.', 'web-stories')} {__('Welcome to Web Stories for WordPress.', 'web-stories')} {__('Go to Stories Dashboard', 'web-stories')} {__('Read the Get Started story', 'web-stories')} ); } export default SuccessMessage; ================================================ FILE: packages/activation-notice/src/app/components/test/dismiss.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import { renderWithTheme } from '../../../testUtils'; import Dismiss from '../dismiss'; describe('Dismiss', () => { it('should render nothing', () => { const { container } = renderWithTheme(); expect(container).toBeEmptyDOMElement(); }); }); ================================================ FILE: packages/activation-notice/src/app/components/test/step1.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { screen } from '@testing-library/react'; /** * Internal dependencies */ import { renderWithTheme } from '../../../testUtils'; import Step1 from '../step1'; import { ConfigProvider } from '../../config'; function render() { const config = { demoStoryURL: 'bar', }; return renderWithTheme( ); } describe('Step 1', () => { it('should render', () => { render(); expect(screen.getByText(/Get Started story/i)).toBeInTheDocument(); }); }); ================================================ FILE: packages/activation-notice/src/app/components/test/step2.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { screen } from '@testing-library/react'; /** * Internal dependencies */ import { renderWithTheme } from '../../../testUtils'; import Step2 from '../step2'; import { ConfigProvider } from '../../config'; function render() { const config = { dashboardURL: 'foo', }; return renderWithTheme( ); } describe('Step 2', () => { it('should render', () => { render(); expect(screen.getByText(/Dashboard/i)).toBeInTheDocument(); }); }); ================================================ FILE: packages/activation-notice/src/app/components/test/step3.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { screen } from '@testing-library/react'; /** * Internal dependencies */ import { renderWithTheme } from '../../../testUtils'; import Step3 from '../step3'; import { ConfigProvider } from '../../config'; function render() { const config = { newStoryURL: 'foo', demoStoryURL: 'bar', }; return renderWithTheme( ); } describe('Step 3', () => { it('should render', () => { render(); expect(screen.getByText(/Jump into/i)).toBeInTheDocument(); }); }); ================================================ FILE: packages/activation-notice/src/app/components/test/successMessage.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { screen } from '@testing-library/react'; /** * Internal dependencies */ import { renderWithTheme } from '../../../testUtils'; import SuccessMessage from '../successMessage'; import { ConfigProvider } from '../../config'; function render() { const config = { dashboardURL: 'foo', demoStoryURL: 'bar', }; return renderWithTheme( ); } describe('SuccessMessage', () => { it('should render', () => { render(); expect(screen.getByText(/Tell some stories/i)).toBeInTheDocument(); expect(screen.getByText(/Go to Stories Dashboard/i)).toBeInTheDocument(); }); }); ================================================ FILE: packages/activation-notice/src/app/config/configProvider.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { ReactNode } from 'react'; /** * Internal dependencies */ import Context, { type ContextState } from './context'; interface ConfigProviderProps { config: ContextState; children: ReactNode; } function ConfigProvider({ config, children }: ConfigProviderProps) { return {children}; } export default ConfigProvider; ================================================ FILE: packages/activation-notice/src/app/config/context.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * WordPress dependencies */ import { createContext } from '@wordpress/element'; const INITIAL_STATE = {}; export interface ContextState { isRTL: boolean; cdnURL: string; demoStoryURL: string; newStoryURL: string; dashboardURL: string; } export default createContext(INITIAL_STATE); ================================================ FILE: packages/activation-notice/src/app/config/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { default as ConfigProvider } from './configProvider'; export { default as useConfig } from './useConfig'; ================================================ FILE: packages/activation-notice/src/app/config/useConfig.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * WordPress dependencies */ import { useContext } from '@wordpress/element'; /** * Internal dependencies */ import Context, { type ContextState } from './context'; function useConfig(): ContextState { return useContext(Context) as ContextState; } export default useConfig; ================================================ FILE: packages/activation-notice/src/app/index.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import isPropValid from '@emotion/is-prop-valid'; import { StyleSheetManager, ThemeProvider } from 'styled-components'; import stylisRTLPlugin from 'stylis-plugin-rtl'; Object.defineProperty(stylisRTLPlugin, 'name', { value: 'stylisRTLPlugin' }); /** * Internal dependencies */ import { theme, GlobalStyle } from '../theme'; import MessageContent from './components/messageContent'; import { ConfigProvider } from './config'; import type { ContextState } from './config/context'; interface AppProps { config: ContextState; } function App({ config }: AppProps) { const { isRTL } = config; return ( ); } export default App; ================================================ FILE: packages/activation-notice/src/index.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { FlagsProvider } from 'flagged'; import { initializeTracking } from '@googleforcreators/tracking'; /** * WordPress dependencies */ import { StrictMode, createRoot } from '@wordpress/element'; /** * Internal dependencies */ import App from './app'; import type { ContextState } from './app/config/context'; interface ActivationNoticeSettings { publicPath: string; id: string; config: ContextState; flags: Record; } declare global { let __webpack_public_path__: string; interface Window { webStoriesActivationSettings: ActivationNoticeSettings; } } __webpack_public_path__ = window.webStoriesActivationSettings.publicPath; /** * Initializes the Web Stories dashboard screen. * * @param id ID of the root element to render the screen in. * @param config Story editor settings. * @param flags The flags for the application. */ const initialize = ( id: string, config: ContextState, flags: Record ) => { const appElement = document.getElementById(id); if (!appElement) { return; } void initializeTracking('Plugin Activation'); const root = createRoot(appElement); root.render( ); }; const initializeWithConfig = () => { const { id, config, flags } = window.webStoriesActivationSettings; initialize(id, config, flags); }; if ('loading' === document.readyState) { document.addEventListener('DOMContentLoaded', initializeWithConfig); } else { initializeWithConfig(); } ================================================ FILE: packages/activation-notice/src/stories/index.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import App from '../app'; export default { title: 'WordPress/Plugin Activation', args: { cdnURL: 'https://wp.stories.google/static/main/', demoStoryURL: 'https://example:com', dashboardURL: 'https://example:com', isRTL: false, }, }; // TODO (#10380): Support RTL using something like @pxblue/storybook-rtl-addon; export const _default = { render: function Render(args) { return ; }, }; ================================================ FILE: packages/activation-notice/src/testUtils/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { default as renderWithTheme } from './renderWithTheme'; ================================================ FILE: packages/activation-notice/src/testUtils/renderWithTheme.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { render } from '@testing-library/react'; import { ThemeProvider } from 'styled-components'; /** * Internal dependencies */ import { theme } from '../theme'; const WithThemeProvider = ({ children }) => { return {children}; }; const renderWithTheme = (ui, options) => render(ui, { wrapper: WithThemeProvider, ...options, }); export default renderWithTheme; ================================================ FILE: packages/activation-notice/src/theme.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createGlobalStyle, ThemeContext } from 'styled-components'; import type { DefaultTheme } from 'styled-components'; /** * WordPress dependencies */ import { useContext } from '@wordpress/element'; export const GlobalStyle = createGlobalStyle` #web-stories-plugin-activation-notice { padding: 0; border: none; } `; export function useTheme(): DefaultTheme { return useContext(ThemeContext); } export const theme: DefaultTheme = { colors: { bg: { start: '#CBACFF', end: '#79B3FF', }, primary: 'rgba(255, 255, 255, 0.84)', secondary: 'rgba(255, 255, 255, 0.64)', tertiary: 'rgba(255, 255, 255, 0.54)', link: { fg: '#B99DEA', hover: { fg: 'rgba(255, 255, 255, 0.84)', }, }, action: { bg: '#619CE8', fg: '#ffffff', hover: { bg: '#010218', }, }, }, fonts: { body: { family: "'Google Sans', sans-serif", size: '16px', lineHeight: '24px', fontWeight: 400, }, title: { family: "'Google Sans', sans-serif", size: '32px', lineHeight: '40px', fontWeight: 400, }, button: { family: "'Google Sans', sans-serif", size: '16px', lineHeight: '32px', fontWeight: 400, }, stepNumber: { family: "'Google Sans', sans-serif", size: '80px', lineHeight: '80px', fontWeight: 900, }, }, breakpoint: { tabletSmall: 'screen and (min-width: 700px)', tabletLarge: 'screen and (min-width: 1300px)', desktop: 'screen and (min-width: 1800px)', }, }; ================================================ FILE: packages/activation-notice/src/typings/styled-components.d.ts ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import 'styled-components'; interface Font { family: string; size: string; lineHeight: string; fontWeight: number; } declare module 'styled-components' { export interface DefaultTheme { colors: { bg: { [key: string]: string; }; primary: string; secondary: string; tertiary: string; link: { fg: string; hover: { [key: string]: string; }; }; action: { bg: string; fg: string; hover: { [key: string]: string; }; }; }; fonts: { [key: string]: Font; }; breakpoint: { [key: string]: string; }; } } ================================================ FILE: packages/activation-notice/tsconfig.json ================================================ { "extends": "../../tsconfig.shared.json", "compilerOptions": { "rootDir": "src", "declarationDir": "dist-types" }, "references": [{ "path": "../tracking" }], "include": ["src/**/*"] } ================================================ FILE: packages/animation/.npmignore ================================================ src ================================================ FILE: packages/animation/README.md ================================================ # Animation Library for rendering animations in web stories. ================================================ FILE: packages/animation/package.json ================================================ { "name": "@googleforcreators/animation", "description": "Web Stories editor animation library.", "private": false, "version": "0.1.202410011217", "author": { "name": "Google", "url": "https://creators.google/" }, "license": "Apache-2.0", "keywords": [ "web stories", "animations", "web animations" ], "homepage": "https://github.com/GoogleForCreators/web-stories-wp/blob/main/packages/animation/README.md", "repository": { "type": "git", "url": "https://github.com/GoogleForCreators/web-stories-wp.git", "directory": "packages/animation" }, "bugs": { "url": "https://github.com/googleforcreators/web-stories-wp/issues" }, "engines": { "node": ">= 20 || >= 22 || >= 24", "npm": ">= 7.3" }, "customExports": { ".": { "default": "./src/index.ts" } }, "main": "dist/index.js", "module": "dist-module/index.js", "types": "dist-types/index.d.ts", "source": "src/index.ts", "publishConfig": { "access": "public" }, "dependencies": { "@googleforcreators/i18n": "*", "@googleforcreators/media": "*", "@googleforcreators/react": "*", "@googleforcreators/units": "*", "flagged": "^2.0.10", "prop-types": "^15.8.1", "styled-components": "^5.3.11", "uuid": "^11.1.0" }, "devDependencies": { "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@types/styled-components": "^5.1.26", "@types/web-animations-js": "^2.2.16" } } ================================================ FILE: packages/animation/src/components/AMPAnimations.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import useStoryAnimationContext from './useStoryAnimationContext'; function AMPAnimations() { const { state: { providerId, animationTargets }, actions: { getAnimationParts }, } = useStoryAnimationContext(); return ( <> {animationTargets .map((target) => [ getAnimationParts(target).map(({ id, AMPAnimation }) => ( )), ]) .flat(2)} ); } export default AMPAnimations; ================================================ FILE: packages/animation/src/components/AMPKeyframes.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useMemo } from '@googleforcreators/react'; /** * Internal dependencies */ import { KeyframesOutput } from '../outputs'; import useStoryAnimationContext from './useStoryAnimationContext'; import generateKeyframesMap from './generateKeyframesMap'; function AMPKeyframes() { const { state: { providerId, animationTargets }, actions: { getAnimationParts }, } = useStoryAnimationContext(); const keyframesMap = useMemo( () => generateKeyframesMap(animationTargets, getAnimationParts), [animationTargets, getAnimationParts] ); return Object.keys(keyframesMap).map((animationName) => ( )); } export default AMPKeyframes; ================================================ FILE: packages/animation/src/components/AMPWrapper.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useMemo } from '@googleforcreators/react'; import type { FunctionComponent, PropsWithChildren } from 'react'; /** * Internal dependencies */ import type { AnimationPart } from '../parts'; import type { WrapperProps } from './types'; import useStoryAnimationContext from './useStoryAnimationContext'; const fullSizeAbsoluteStyles = { width: '100%', height: '100%', display: 'block', position: 'absolute', top: 0, left: 0, } as const; type ComposableWrapperProps = PropsWithChildren<{ animationParts: AnimationPart[]; }>; type Composed = PropsWithChildren; function ComposableWrapper({ animationParts, children, }: ComposableWrapperProps) { const root: FunctionComponent = (props: Composed) => props.children as React.ReactElement; const ComposedWrapper = useMemo( () => animationParts.reduce( (Composable: FunctionComponent, animationPart: AnimationPart) => { const { AMPTarget } = animationPart; const Composed = (props: Composed) => { return ( {props.children} ); }; return Composed; }, root ), [animationParts] ); // eslint-disable-next-line react-hooks/static-components -- FIXME return {children}; } function AMPWrapper({ target, children }: WrapperProps) { const { actions: { getAnimationParts }, } = useStoryAnimationContext(); return ( {children} ); } export default AMPWrapper; ================================================ FILE: packages/animation/src/components/WAAPIWrapper.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useCallback, useRef, useEffect } from '@googleforcreators/react'; /** * Internal dependencies */ import createKeyframeEffect from '../utils/createKeyframeEffect'; import FullSizeAbsolute from './fullSizeAbsolute'; import type { WAAPIAnimationWrapperProps, WrapperProps } from './types'; import useStoryAnimationContext from './useStoryAnimationContext'; function WAAPIAnimationWrapper({ children, hoistAnimation, keyframes, timings, targetLeafElement = false, }: WAAPIAnimationWrapperProps) { const target = useRef(null); useEffect(() => { if (!keyframes) { return () => undefined; } const targetEl = targetLeafElement ? target.current?.querySelector('[data-leaf-element="true"]') : target.current; if (!targetEl) { return () => undefined; } const effect = createKeyframeEffect(targetEl, keyframes, timings); return hoistAnimation(new Animation(effect, document.timeline)); }, [hoistAnimation, keyframes, targetLeafElement, timings]); return {children}; } function WAAPIWrapper({ children, target }: WrapperProps) { const { hoistWAAPIAnimation, animationParts } = useStoryAnimationContext( ({ actions }) => ({ hoistWAAPIAnimation: actions.hoistWAAPIAnimation, animationParts: actions.getAnimationParts(target), }) ); const WAAPIAnimationParts = animationParts?.map( (anim) => anim.WAAPIAnimation ); const hoistAnimation = useCallback( (animation: Animation) => hoistWAAPIAnimation({ animation, elementId: target }), [target, hoistWAAPIAnimation] ); // Parents/Wrappers need to stay consistent here and only have prop changes // to allow react's reconcilliation algorithm to function properly // and not generate a new subtree of DOM nodes: // https://github.com/facebook/react/issues/3965 // // To Accommodate for this, we're setting a max of 3 levels deep. All stories built recently should // only have 1 animation per element, however this allows for backward compatibility with old stories // created from legacy templates that use stacked animation parts on single elements return ( {children} ); } export default WAAPIWrapper; ================================================ FILE: packages/animation/src/components/animationMachine.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useCallback, useReducer } from '@googleforcreators/react'; /** * Internal dependencies */ import { AnimationMachineState, AnimationMachineTransition } from './types'; const AnimationMachine: Record< AnimationMachineState, Partial> > = { [AnimationMachineState.Idle]: { [AnimationMachineTransition.Complete]: AnimationMachineState.Complete, }, [AnimationMachineState.Complete]: { [AnimationMachineTransition.Reset]: AnimationMachineState.Idle, }, }; const animationStateReducer = ( state: AnimationMachineState, action: AnimationMachineTransition ) => { return AnimationMachine[state][action] || state; }; function useAnimationMachine() { const [animationState, dispatchWAAPIAnimationState] = useReducer( animationStateReducer, AnimationMachineState.Idle ); const reset = useCallback( () => dispatchWAAPIAnimationState(AnimationMachineTransition.Reset), [] ); const complete = useCallback( () => dispatchWAAPIAnimationState(AnimationMachineTransition.Complete), [] ); return { animationState, reset, complete }; } export default useAnimationMachine; ================================================ FILE: packages/animation/src/components/context.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createContext } from '@googleforcreators/react'; /** * Internal dependencies */ import type { AnimationProviderState } from './types'; const AnimationContext = createContext({ state: { providerId: '', animationTargets: [], }, actions: { getAnimationParts: () => [], hoistWAAPIAnimation: () => () => undefined, WAAPIAnimationMethods: { play: () => undefined, pause: () => undefined, setCurrentTime: () => undefined, reset: () => undefined, }, }, }); export default AnimationContext; ================================================ FILE: packages/animation/src/components/fullSizeAbsolute.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled, { css } from 'styled-components'; export default styled.div<{ overflowHidden?: boolean }>` position: absolute; top: 0; right: 0; bottom: 0; left: 0; transform-origin: 50% 50%; ${({ overflowHidden }) => overflowHidden && css` overflow: hidden; `}; `; ================================================ FILE: packages/animation/src/components/generateKeyframesMap.ts ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import type { AnimationProviderState, ElementId } from './types'; function generateKeyframesMap( targets: ElementId[], getAnimationParts: AnimationProviderState['actions']['getAnimationParts'] ) { const allKeyframeEntries = targets .map((target) => getAnimationParts(target).map((part) => Object.entries(part.generatedKeyframes) ) ) .flat(2); const keyframesAsMap = new Map(allKeyframeEntries); return Object.fromEntries(keyframesAsMap); } export default generateKeyframesMap; ================================================ FILE: packages/animation/src/components/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { default as AnimationProvider } from './provider'; export { default as WAAPIWrapper } from './WAAPIWrapper'; export { default as AMPWrapper } from './AMPWrapper'; export { default as AMPKeyframes } from './AMPKeyframes'; export { default as AMPAnimations } from './AMPAnimations'; export { default as useStoryAnimationContext } from './useStoryAnimationContext'; ================================================ FILE: packages/animation/src/components/provider.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useCallback, useEffect, useMemo, useRef, useState, } from '@googleforcreators/react'; import { clamp } from '@googleforcreators/units'; import { v4 as uuidv4 } from 'uuid'; /** * Internal dependencies */ import { type AnimationPart, createAnimationPart } from '../parts'; import type { Element, StoryAnimation } from '../types'; import type { ElementId, ElementMap, ElementAnimationPartsMap, AnimationProviderProps, WAAPIElementAnimation, WAAPIElementAnimationMap, StoryAnimationMap, } from './types'; import Context from './context'; import { AnimationMachineState } from './types'; import useAnimationMachine from './animationMachine'; const createOnFinishPromise = (animation: Animation) => { return new Promise((resolve) => { animation.onfinish = resolve; }); }; const filterWAAPIAnimations = ( animations: WAAPIElementAnimation[], selectedElementIds: ElementId[] ) => selectedElementIds.length > 0 ? animations.filter(({ elementId }) => selectedElementIds.includes(elementId) ) : animations; const STABLE: unknown[] = []; function mapHasReferentialEntry( map: null | Map, [key, val]: [K, V] ) { return map?.has(key) && map.get(key) === val; } function Provider({ animations = STABLE as StoryAnimation[], elements = STABLE as Element[], children, onWAAPIFinish, selectedElementIds = [], }: AnimationProviderProps) { const elementsInstanceMapRef = useRef(null); const elementsInstanceMap: ElementMap = useMemo( () => new Map(elements.map((element) => [element.id, element])), [elements] ); const animationsInstanceMapRef = useRef(null); const animationsInstanceMap: StoryAnimationMap = useMemo( () => new Map(animations.map((animation) => [animation.id, animation])), [animations] ); const animationPartsMapRef = useRef(null); const animationPartsMap = useMemo(() => { // Keeping track of maps from previous renders to be able to persist // referentially stable generated animations that need no update. const { current: oldAnimationsInstanceMap } = animationsInstanceMapRef; const { current: oldElementsInstanceMap } = elementsInstanceMapRef; const { current: oldAnimationPartsMap } = animationPartsMapRef; const _animationPartsMap: ElementAnimationPartsMap = new Map(); for (const [animationId, animation] of animationsInstanceMap.entries()) { // See if animationPart needs an update from last animation update const isAnimationRefentiallyStable = mapHasReferentialEntry( oldAnimationsInstanceMap, [animationId, animation] ); // See if animationPart needs an update from any target elements updating // (animations only have 1 target element, so this may look O(n) but it's // really O(1) in actuality) const areAnimationTargetElementsReferentiallyStable = animation.targets.every((elementId) => mapHasReferentialEntry(oldElementsInstanceMap, [ elementId, elementsInstanceMap.get(elementId), ]) ); // Persist last generated animation if neither animation nor targets have updated if ( isAnimationRefentiallyStable && areAnimationTargetElementsReferentiallyStable && oldAnimationPartsMap !== null ) { animation.targets.forEach((elementId) => { const element = elementsInstanceMap.get(elementId); if (!element?.isHidden) { _animationPartsMap.set( elementId, oldAnimationPartsMap.get(elementId) || [] ); } }); } else { // Generate new animationPart if input has changed. const { targets } = animation; (targets || []).forEach((elementId) => { const generatedParts = _animationPartsMap.get(elementId) || []; const element = elementsInstanceMap.get(elementId); if (!element) { // This should not happen. return; } if (!element.isHidden) { _animationPartsMap.set(elementId, [ ...generatedParts, createAnimationPart(animation, element), ]); } }); } } // Sync up map refs to reference on next generation of animationPartsMap animationsInstanceMapRef.current = animationsInstanceMap; elementsInstanceMapRef.current = elementsInstanceMap; animationPartsMapRef.current = _animationPartsMap; return _animationPartsMap; }, [animationsInstanceMap, elementsInstanceMap]); const providerId = useMemo(() => uuidv4(), []); const animationTargets = useMemo( () => Array.from(animationPartsMap.keys() || []), [animationPartsMap] ); const getAnimationParts = useCallback( (target: ElementId) => animationPartsMap.get(target) || (STABLE as AnimationPart[]), [animationPartsMap] ); /** * WAAPI interface */ const onWAAPIFinishRef = useRef(onWAAPIFinish); const { animationState, reset, complete } = useAnimationMachine(); const WAAPIAnimationRef = useRef(new Map()); const [WAAPIAnimations, setWAAPIAnimations] = useState< WAAPIElementAnimation[] >([]); const filteredWAAPIAnimations = useMemo( () => filterWAAPIAnimations(WAAPIAnimations, selectedElementIds), [selectedElementIds, WAAPIAnimations] ); const hoistWAAPIAnimation = useCallback( ({ animation, elementId }: WAAPIElementAnimation) => { const symbol = Symbol(); WAAPIAnimationRef.current.set(symbol, { animation, elementId, }); setWAAPIAnimations(Array.from(WAAPIAnimationRef.current.values())); return () => { animation?.cancel(); WAAPIAnimationRef.current.delete(symbol); setWAAPIAnimations(Array.from(WAAPIAnimationRef.current.values())); }; }, [] ); const WAAPIAnimationMethods = useMemo(() => { const play = () => filteredWAAPIAnimations.forEach(({ animation }) => { // Sometimes an animation part can get into a // stuck state where executing `play` doesn't // trigger the animation. A workaround to avoid // this is to first `cancel` the animation // before playing. animation?.cancel(); animation?.play(); }); const pause = () => filteredWAAPIAnimations.forEach(({ animation }) => animation?.pause()); const setCurrentTime = (time: number | 'end') => filteredWAAPIAnimations.forEach(({ animation }) => { const { duration = 0, delay = 0 } = animation.effect?.getTiming() ?? {}; // The duration can be "auto", which we must treat as 0 in this instance // @see https://w3c.github.io/csswg-drafts/web-animations-1/#dictdef-effecttiming // Note that we could use `getComputedTiming` above instead, but the typings // in @types/web-animations-js aren't completely correct for that return. const actualDuration = typeof duration === 'string' ? 0 : duration; // TODO: Address case where duration can be of type CSSNumericValue. const animationEndTime = delay + (actualDuration as number); animation.currentTime = time === 'end' ? animationEndTime : clamp(time, { MIN: 0, MAX: animationEndTime }); }); return { play, pause, setCurrentTime, reset: () => { pause(); requestAnimationFrame(() => { setCurrentTime('end'); }); }, }; }, [filteredWAAPIAnimations]); /** * Browser support for `animation.finished` is no good. * https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished * * So we're mimicking the spec by creating a new promise everytime all * animations complete. */ useEffect(() => { let cancel: () => void = () => undefined; if ( AnimationMachineState.Idle === animationState && filteredWAAPIAnimations.length ) { new Promise((resolve, reject) => { cancel = reject; void Promise.all( filteredWAAPIAnimations.map(({ animation }) => createOnFinishPromise(animation) ) ).then(() => { cancel = () => undefined; resolve(); }); }) .then(complete) /* needed if promise gets canceled to swallow the error */ .catch(() => undefined); } return cancel; }, [filteredWAAPIAnimations, animationState, complete]); // eslint-disable-next-line react-hooks/refs -- FIXME onWAAPIFinishRef.current = onWAAPIFinish; useEffect(() => { if (AnimationMachineState.Complete === animationState) { onWAAPIFinishRef.current?.(); reset(); } }, [animationState, reset]); const value = useMemo( () => ({ state: { providerId, animationTargets, }, actions: { getAnimationParts, hoistWAAPIAnimation, WAAPIAnimationMethods, }, }), [ providerId, getAnimationParts, animationTargets, hoistWAAPIAnimation, WAAPIAnimationMethods, ] ); return {children}; } export default Provider; ================================================ FILE: packages/animation/src/components/stories/index.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import PropTypes from 'prop-types'; import { useState } from '@googleforcreators/react'; /** * Internal dependencies */ import * as StoryAnimation from '..'; import { PlayButton } from '../../storybookUtils'; import { AnimationType } from '../../types'; export default { title: 'Dashboard/Components/StoryAnimation', component: StoryAnimation, }; function ColorSquare({ color, ...rest }) { return (
); } ColorSquare.propTypes = { color: PropTypes.string, }; function SquareWrapper({ children }) { return (
{children}
); } SquareWrapper.propTypes = { children: PropTypes.node, }; const animations = [ { id: '1', targets: ['some-id'], type: AnimationType.Fade }, { id: '2', targets: ['some-id'], type: AnimationType.Flip }, { id: '3', targets: ['some-id'], type: AnimationType.Spin }, { id: '4', targets: ['some-id'], type: AnimationType.FloatOn, duration: 1000, }, ]; export const _default = { render: function Render() { const [state, setState] = useState(0); return ( { setState((v) => v + 1); }} > ); }, }; export const AMPStory = { render: function Render() { const pages = [ { id: 'first-page', animations: [ { id: 'ir', targets: ['el1'], type: AnimationType.Bounce, duration: 1000, }, ], }, { id: 'second-page', animations: [ { id: 'a5', targets: ['el2'], type: AnimationType.Bounce, duration: 2000, }, ], }, { id: 'third-page', animations: [ { id: 'a1', targets: ['el3'], type: AnimationType.Bounce }, { id: 'a2', targets: ['el4'], type: AnimationType.Bounce, delay: 100, }, { id: 'a3', targets: ['el5'], type: AnimationType.Bounce, delay: 300, }, { id: 'a4', targets: ['el6'], type: AnimationType.Bounce, delay: 500, }, ], elements: [ { id: 'el3', color: 'red', width: '50px' }, { id: 'el4', color: 'orange', width: '100px' }, { id: 'el5', color: 'blue', width: '200px' }, { id: 'el6', color: 'green', width: '150px' }, ], }, ]; return (
{pages[2].elements.map((element) => (
))}
); }, }; ================================================ FILE: packages/animation/src/components/test/WAAPIWrapper.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useMemo } from '@googleforcreators/react'; import { render, act } from '@testing-library/react'; import type { FunctionComponent } from 'react'; /** * Internal dependencies */ import { AnimationType } from '../../types'; import type { Element, ElementId, StoryAnimation } from '../../types'; import Provider from '../provider'; import WAAPIWrapper from '../WAAPIWrapper'; type Tracker = FunctionComponent<{ target: string }>; type TrackersProps = { ElOneWAAPIInvocationTracker: Tracker; ElTwoWAAPIInvocationTracker: Tracker; }; type ElementsWithWrapperProps = { animations: StoryAnimation[]; elements: Element[]; ElOneWAAPIInvocationTracker: Tracker; ElTwoWAAPIInvocationTracker: Tracker; }; function Trackers({ ElOneWAAPIInvocationTracker, ElTwoWAAPIInvocationTracker, }: TrackersProps) { return useMemo( () => (
), [ElOneWAAPIInvocationTracker, ElTwoWAAPIInvocationTracker] ); } describe('StoryAnimation.WAAPIWrapper', () => { describe('tracking necessary rerenders', () => { // Create mock data const createMockAnim = ( id: string, targets: ElementId[] ): StoryAnimation => ({ type: AnimationType.EffectPulse, delay: 0, duration: 350, iterations: 1, scale: 0.5, id, targets, }); const createMockElement = (partial: Partial): Element => ({ id: '2e04154c-bc58-4969-bb4d-c69d32da0eac', x: 0, y: 0, width: 0, height: 0, rotationAngle: 0, ...partial, }); const initialAnimations = [ createMockAnim('animOne', ['elOne']), createMockAnim('animTwo', ['elTwo']), ]; const initialElements = [ createMockElement({ id: 'elOne' }), createMockElement({ id: 'elTwo' }), ]; const ElementsWithWrapper = ({ animations, elements, ElOneWAAPIInvocationTracker, ElTwoWAAPIInvocationTracker, }: ElementsWithWrapperProps) => ( ); it('doesnt rerender wrappers uneffected by animation updates', () => { // Render with mock methods const ElOneWAAPIInvocationTracker = jest.fn(WAAPIWrapper); const ElTwoWAAPIInvocationTracker = jest.fn(WAAPIWrapper); const { rerender } = render( ); // See that mock methods were called on mount expect(ElOneWAAPIInvocationTracker).toHaveBeenCalledTimes(1); expect(ElTwoWAAPIInvocationTracker).toHaveBeenCalledTimes(1); // Update animations with one new animation and one // previous animation instance act(() => { rerender( ); }); // See that only the element effected by the animation update rerendered // See that mock methods were called on mount expect(ElOneWAAPIInvocationTracker).toHaveBeenCalledTimes(2); expect(ElTwoWAAPIInvocationTracker).toHaveBeenCalledTimes(1); }); it('doesnt rerender wrappers uneffected by element updates', () => { // Render with mock methods const ElOneWAAPIInvocationTracker = jest.fn(WAAPIWrapper); const ElTwoWAAPIInvocationTracker = jest.fn(WAAPIWrapper); const { rerender } = render( ); // See that mock methods were called on mount expect(ElOneWAAPIInvocationTracker).toHaveBeenCalledTimes(1); expect(ElTwoWAAPIInvocationTracker).toHaveBeenCalledTimes(1); // Update animations with one new element and one // previous element instance act(() => { rerender( ); }); // See that only the element effected by the animation update rerendered expect(ElOneWAAPIInvocationTracker).toHaveBeenCalledTimes(2); expect(ElTwoWAAPIInvocationTracker).toHaveBeenCalledTimes(1); }); }); }); ================================================ FILE: packages/animation/src/components/test/animationProvider.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useFeature } from 'flagged'; import { renderHook, act } from '@testing-library/react-hooks'; import type { PropsWithChildren } from 'react'; /** * Internal dependencies */ import { createAnimationPart } from '../../parts'; import { AnimationType, type Element, type StoryAnimation } from '../../types'; import { AnimationProvider, useStoryAnimationContext } from '..'; jest.mock('flagged'); jest.mock('../../parts', () => ({ createAnimationPart: jest.fn().mockImplementation(() => ({ id: '', keyframes: {}, WAAPIAnimation: { keyframes: {}, timings: {} }, AMPTarget: () => null, AMPAnimation: () => null, generatedKeyframes: {}, })), })); const mockedUseFeature = jest.mocked(useFeature); const mockedCreateAnimationPart = jest.mocked(createAnimationPart); function flushPromiseQueue() { return Promise.resolve(); } function createWrapperWithProps( Wrapper: React.FunctionComponent, props: T ) { const WrapperWithProps = ({ children }: PropsWithChildren) => ( {children} ); return WrapperWithProps; } const mockWAAPIAnimation = (overrides = {}) => ({ ...new Animation(), ...overrides, }); type MockAnimation = Animation & { play: (this: void) => void; pause: (this: void) => void; }; describe('AnimationProvider', () => { beforeAll(() => { mockedUseFeature.mockImplementation(() => true); }); afterEach(() => { mockedCreateAnimationPart.mockReset(); }); describe('getAnimationParts(target)', () => { it('gets all generated parts for a target', () => { const target = 'id1'; const otherTarget = 'id2'; const animations = [ { id: '1', targets: [target], type: AnimationType.Move as const, duration: 1000, }, { id: '2', targets: [target], type: AnimationType.Spin as const, duration: 1000, }, { id: '3', targets: [otherTarget], type: AnimationType.Move as const, duration: 1000, }, { id: '4', targets: [target, otherTarget], type: AnimationType.Zoom as const, duration: 1000, }, ]; const elements: Element[] = [ { id: target, x: 0, y: 0, width: 0, height: 0, rotationAngle: 0 }, { id: otherTarget, x: 0, y: 0, width: 0, height: 0, rotationAngle: 0 }, ]; const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations, elements, }), }); const { actions: { getAnimationParts }, } = result.current; expect(getAnimationParts(target)).toHaveLength(3); expect(getAnimationParts(otherTarget)).toHaveLength(2); expect(getAnimationParts('id3')).toHaveLength(0); }); it('calls generators for a target in ascending order', () => { const target = 'id1'; const targets = [target]; const args = { someProp: 1, duration: 1000 }; const types = [ AnimationType.Move as const, AnimationType.Spin as const, AnimationType.Zoom as const, ]; const animations = [ { id: '1', targets, type: types[0], ...args }, { id: '2', targets, type: types[1], ...args }, { id: '3', targets, type: types[2], ...args }, ]; const elements: Element[] = [ { id: target, x: 0, y: 0, width: 0, height: 0, rotationAngle: 0 }, ]; const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations, elements, }), }); const { actions: { getAnimationParts }, } = result.current; getAnimationParts(target); animations.forEach((animation) => { expect(createAnimationPart).toHaveBeenCalledWith( animation, elements[0] ); }); }); it('calls generators for a target with proper args', () => { const target = 'some-target'; const target2 = 'that-target'; const element1 = { id: target, type: 'text', x: 0, y: 0, width: 0, height: 0, rotationAngle: 0, }; const element2 = { id: target2, type: 'text', x: 0, y: 0, width: 0, height: 0, rotationAngle: 0, }; const elements = [element1, element2]; const animType = AnimationType.Move; const args = [ { bounces: 3, duration: 1000 }, { blinks: 2, offset: 20, blarks: 6, duration: 1000 }, { columns: 4, duration: 400 }, ]; const animations: StoryAnimation[] = [ { id: '1', targets: [target], type: animType, ...args[0] }, { id: '2', targets: [target], type: animType, ...args[1] }, { id: '3', targets: [target2], type: animType, ...args[2] }, ]; const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations, elements, }), }); const { actions: { getAnimationParts }, } = result.current; getAnimationParts(target); animations .filter(({ targets }) => targets.includes(target)) .forEach(({ type, ...rest }) => { expect(mockedCreateAnimationPart).toHaveBeenCalledWith( { type, ...rest }, element1 ); }); getAnimationParts(target2); animations .filter(({ targets }) => targets.includes(target2)) .forEach(({ type, ...rest }) => { expect(mockedCreateAnimationPart).toHaveBeenCalledWith( { type, ...rest }, element2 ); }); }); }); describe('hoistWAAPIAnimation(WAAPIAnimation)', () => { it('returns a cleanup function when called', () => { const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], }), }); let unhoist; act(() => { unhoist = result.current.actions.hoistWAAPIAnimation({ animation: mockWAAPIAnimation(), elementId: '', }); }); expect(typeof unhoist).toBe('function'); }); /** * Animation.cancel()** * * Clears all KeyframeEffects caused by this animation * and aborts its playback. * * https://developer.mozilla.org/en-US/docs/Web/API/Animation/cancel */ it('calls Animation.cancel() method on hoisted animation when cleanup performed', () => { const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], }), }); const cancel = jest.fn(); act(() => { const unhoist = result.current.actions.hoistWAAPIAnimation({ animation: mockWAAPIAnimation({ cancel }), elementId: '', }); unhoist(); }); expect(cancel).toHaveBeenCalledWith(); }); }); describe('WAAPIAnimationMethods', () => { it('calls all hoisted Animation methods when called', () => { const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], }), }); const numCalls = 10; const play = jest.fn(); const pause = jest.fn(); const cancel = jest.fn(); const animations = Array.from({ length: numCalls }, () => { const animation = mockWAAPIAnimation({ play, pause, cancel, currentTime: 0, effect: { getTiming: () => ({ duration: 300, delay: 0, }), }, }); act(() => { result.current.actions.hoistWAAPIAnimation({ animation, elementId: '', }); }); return animation; }); act(() => result.current.actions.WAAPIAnimationMethods.play()); act(() => result.current.actions.WAAPIAnimationMethods.pause()); act(() => result.current.actions.WAAPIAnimationMethods.setCurrentTime(200) ); expect(play).toHaveBeenCalledTimes(numCalls); expect(pause).toHaveBeenCalledTimes(numCalls); animations.forEach((animation) => { expect(animation.currentTime).toBe(200); }); }); it('calls all selectedElement hoisted Animation methods when called and passed selected elements', () => { const selectedElementIds = ['a', 'b', 'c']; const allElementIds = [...selectedElementIds, 'd', 'e']; const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], selectedElementIds, }), }); const animationsWithIds = allElementIds.map((elementId) => { const animation: MockAnimation = mockWAAPIAnimation({ play: jest.fn(), pause: jest.fn(), cancel: jest.fn(), currentTime: 0, effect: { getTiming: () => ({ duration: 300, delay: 0, }), }, }); const animationWithElementId = { animation, elementId }; act(() => { result.current.actions.hoistWAAPIAnimation({ animation, elementId }); }); return animationWithElementId; }); act(() => result.current.actions.WAAPIAnimationMethods.play()); act(() => result.current.actions.WAAPIAnimationMethods.pause()); act(() => result.current.actions.WAAPIAnimationMethods.setCurrentTime(200) ); animationsWithIds.forEach(({ animation, elementId }) => { expect(animation.currentTime).toStrictEqual( selectedElementIds.includes(elementId) ? 200 : 0 ); expect(animation.play).toHaveBeenCalledTimes( selectedElementIds.includes(elementId) ? 1 : 0 ); expect(animation.pause).toHaveBeenCalledTimes( selectedElementIds.includes(elementId) ? 1 : 0 ); }); }); it('excludes cleaned up animation methods when called', () => { const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], }), }); const initialTime = 0; const newTime = 200; const numAnims = 10; const unhoistIndex = numAnims / 2; const animations = Array.from( { length: numAnims }, (): MockAnimation => mockWAAPIAnimation({ play: jest.fn(), pause: jest.fn(), cancel: jest.fn(), currentTime: initialTime, effect: { getTiming: () => ({ duration: 300, delay: 0, }), }, }) ); const unhoists = animations.map((animation) => { let unhoist: () => void = () => undefined; act(() => { unhoist = result.current.actions.hoistWAAPIAnimation({ animation, elementId: '', }); }); return unhoist; }); act(() => { unhoists[unhoistIndex](); }); act(() => result.current.actions.WAAPIAnimationMethods.play()); act(() => result.current.actions.WAAPIAnimationMethods.pause()); act(() => result.current.actions.WAAPIAnimationMethods.setCurrentTime(newTime) ); animations.forEach((animation, i) => { if (i === unhoistIndex) { expect(animation.play).toHaveBeenCalledTimes(0); expect(animation.pause).toHaveBeenCalledTimes(0); expect(animation.currentTime).toStrictEqual(initialTime); } else { expect(animation.play).toHaveBeenCalledTimes(1); expect(animation.pause).toHaveBeenCalledTimes(1); expect(animation.currentTime).toStrictEqual(newTime); } }); }); }); describe('events', () => { describe('onWAAPIFinish', () => { it('fires once each time all animations complete', async () => { const onWAAPIFinish = jest.fn(); const { result } = renderHook(() => useStoryAnimationContext(), { wrapper: createWrapperWithProps(AnimationProvider, { animations: [], onWAAPIFinish, }), }); const animations = Array.from({ length: 10 }, () => mockWAAPIAnimation() ); animations.forEach((animation) => { act(() => { result.current.actions.hoistWAAPIAnimation({ animation, elementId: '', }); }); }); const completeAllAnimations = async () => { animations.forEach((animation) => { animation?.onfinish?.(new AnimationPlaybackEvent('finish')); }); /** * Needed to flush all promises and * trigger dispatch from resolved promise */ await act(async () => { await flushPromiseQueue(); }); }; await completeAllAnimations(); expect(onWAAPIFinish).toHaveBeenCalledTimes(1); await completeAllAnimations(); expect(onWAAPIFinish).toHaveBeenCalledTimes(2); await completeAllAnimations(); expect(onWAAPIFinish).toHaveBeenCalledTimes(3); }); }); }); }); ================================================ FILE: packages/animation/src/components/test/generateKeyframesMap.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import type { AnimationPart } from '../../parts'; import type { ElementId, Keyframes } from '../../types'; import generateKeyframesMap from '../generateKeyframesMap'; type AnimationParts = Record; const mockGetAnimationParts = (src: AnimationParts) => (target: ElementId) => src[target]; const mockCreateAnimationPart = ( id: string, keyframes: Keyframes ): AnimationPart => ({ id, keyframes, WAAPIAnimation: { keyframes, timings: {} }, AMPAnimation: () => null, AMPTarget: () => null, generatedKeyframes: { [id]: keyframes }, }); describe('generateKeyframesMap', () => { it('should flatten keyframes into a single map', () => { const targets = ['target1', 'target2', 'target3']; const parts: AnimationParts = { [targets[0]]: [ mockCreateAnimationPart('a', { transform: [1, 3, 4] }), mockCreateAnimationPart('b', { transform: [5, 7, 9] }), ], [targets[1]]: [mockCreateAnimationPart('c', { transform: [2, 5, 8] })], [targets[2]]: [mockCreateAnimationPart('d', { opacity: [0, 1, 0, 1] })], }; const getAnimationParts = mockGetAnimationParts(parts); expect(generateKeyframesMap(targets, getAnimationParts)).toStrictEqual({ a: { transform: [1, 3, 4] }, b: { transform: [5, 7, 9] }, c: { transform: [2, 5, 8] }, d: { opacity: [0, 1, 0, 1] }, }); }); it('should consolidate duplicate keyframes', () => { const targets = ['target1', 'target2', 'target3']; const parts = { [targets[0]]: [ mockCreateAnimationPart('a', { transform: [1, 3, 4] }), mockCreateAnimationPart('b', { transform: [5, 7, 9] }), ], [targets[1]]: [mockCreateAnimationPart('a', { transform: [1, 3, 4] })], [targets[2]]: [ mockCreateAnimationPart('a', { transform: [1, 3, 4] }), mockCreateAnimationPart('c', { opacity: [0, 1, 0, 1] }), ], }; const getAnimationParts = mockGetAnimationParts(parts); expect(generateKeyframesMap(targets, getAnimationParts)).toStrictEqual({ a: { transform: [1, 3, 4] }, b: { transform: [5, 7, 9] }, c: { opacity: [0, 1, 0, 1] }, }); }); }); ================================================ FILE: packages/animation/src/components/types.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { PropsWithChildren } from 'react'; /** * Internal dependencies */ import type { AnimationPart } from '../parts'; import type { AMPEffectTiming, Element, ElementId, Keyframes, StoryAnimation, } from '../types'; // We can't depend on the elements package, but we also don't actually need to. // The only thing we need concerning elements is the fact, that they're an object // with a string id, so this will suffice. export type { ElementId }; export type ElementAnimationHoister = ( elementAnimation: WAAPIElementAnimation ) => () => void; export type AnimationHoister = (animation: Animation) => () => void; export interface AnimationProviderState { state: { providerId: string; animationTargets: ElementId[]; }; actions: { getAnimationParts: (target: ElementId) => AnimationPart[]; hoistWAAPIAnimation: ElementAnimationHoister; WAAPIAnimationMethods: { play: () => void; pause: () => void; setCurrentTime: (time: number | 'end') => void; reset: () => void; }; }; } export type AnimationProviderProps = PropsWithChildren<{ animations?: StoryAnimation[]; elements?: Element[]; onWAAPIFinish?: () => void; selectedElementIds?: string[]; }>; export type StoryAnimationMap = Map; export type ElementAnimationPartsMap = Map; export interface WAAPIElementAnimation { animation: Animation; elementId: ElementId; } export type WAAPIElementAnimationMap = Map; export type ElementMap = Map; export type WAAPIAnimationWrapperProps = PropsWithChildren<{ keyframes: Keyframes; timings: AMPEffectTiming; hoistAnimation: AnimationHoister; targetLeafElement?: boolean; }>; export type WrapperProps = PropsWithChildren<{ target: ElementId; }>; export enum AnimationMachineState { Idle = 'idle', Complete = 'complete', } export enum AnimationMachineTransition { Complete = 'complete', Reset = 'reset', } ================================================ FILE: packages/animation/src/components/useStoryAnimationContext.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { identity, useContextSelector } from '@googleforcreators/react'; /** * Internal dependencies */ import Context from './context'; import type { AnimationProviderState } from './types'; function useStoryAnimationContext(): AnimationProviderState; function useStoryAnimationContext( selector: (state: AnimationProviderState) => T ): T; function useStoryAnimationContext( selector: ( state: AnimationProviderState ) => T | AnimationProviderState = identity ) { return useContextSelector(Context, selector); } export default useStoryAnimationContext; ================================================ FILE: packages/animation/src/constants.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { _x } from '@googleforcreators/i18n'; /** * Internal dependencies */ import { AnimationType, ScaleDirection } from './types'; export const BEZIER = { linear: 'linear', in: 'ease-in', out: 'ease-out', inOut: 'ease-in-out', inQuad: 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', outQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', inOutQuad: 'cubic-bezier(0.455, 0.03, 0.515, 0.955)', inCubic: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)', outCubic: 'cubic-bezier(0.215, 0.61, 0.355, 1)', inOutCubic: 'cubic-bezier(0.645, 0.045, 0.355, 1)', inQuart: 'cubic-bezier(0.895, 0.03, 0.685, 0.22)', outQuart: 'cubic-bezier(0.165, 0.84, 0.44, 1)', inOutQuart: 'cubic-bezier(0.77, 0, 0.175, 1)', inQuint: 'cubic-bezier(0.755, 0.05, 0.855, 0.06)', outQuint: 'cubic-bezier(0.23, 1, 0.32, 1)', inOutQuint: 'cubic-bezier(0.86, 0, 0.07, 1)', inSine: 'cubic-bezier(0.47, 0, 0.745, 0.715)', outSine: 'cubic-bezier(0.39, 0.575, 0.565, 1)', inOutSine: 'cubic-bezier(0.445, 0.05, 0.55, 0.95)', inExpo: 'cubic-bezier(0.95, 0.05, 0.795, 0.035)', outExpo: 'cubic-bezier(0.19, 1, 0.22, 1)', inOutExpo: 'cubic-bezier(1, 0, 0, 1)', inCirc: 'cubic-bezier(0.6, 0.04, 0.98, 0.335)', outCirc: 'cubic-bezier(0.075, 0.82, 0.165, 1)', inOutCirc: 'cubic-bezier(0.785, 0.135, 0.15, 0.86)', default: 'cubic-bezier(0.4, 0.4, 0.0, 1)', } as const; export type BezierType = keyof typeof BEZIER; export const ANIMATION_EFFECTS = { DROP: { value: AnimationType.EffectDrop, name: _x('Drop', 'animation effect', 'web-stories'), }, FADE_IN: { value: AnimationType.EffectFadeIn, name: _x('Fade In', 'animation effect', 'web-stories'), }, FLY_IN: { value: AnimationType.EffectFlyIn, name: _x('Fly In', 'animation effect', 'web-stories'), }, PAN: { value: AnimationType.EffectPan, name: _x('Pan', 'animation effect', 'web-stories'), }, PULSE: { value: AnimationType.EffectPulse, name: _x('Pulse', 'animation effect', 'web-stories'), }, TWIRL_IN: { value: AnimationType.EffectTwirlIn, name: _x('Twirl In', 'animation effect', 'web-stories'), }, WHOOSH_IN: { value: AnimationType.EffectWhooshIn, name: _x('Whoosh In', 'animation effect', 'web-stories'), }, ZOOM: { value: AnimationType.EffectZoom, name: _x('Scale', 'animation effect', 'web-stories'), }, ROTATE_IN: { value: AnimationType.EffectRotateIn, name: _x('Rotate In', 'animation effect', 'web-stories'), }, }; export const BACKGROUND_ANIMATION_EFFECTS = { ZOOM: { value: AnimationType.EffectBackgroundZoom, name: _x('Zoom', 'animation effect', 'web-stories'), }, PAN: { value: AnimationType.EffectBackgroundPan, name: ANIMATION_EFFECTS.PAN.name, }, PAN_AND_ZOOM: { value: AnimationType.EffectBackgroundPanAndZoom, name: _x('Pan and Zoom', 'animation effect', 'web-stories'), }, }; export const ANIMATION_PARTS = { BLINK_ON: { value: AnimationType.BlinkOn, name: _x('Blink On', 'animation effect', 'web-stories'), }, BOUNCE: { value: AnimationType.Bounce, name: _x('Bounce', 'animation effect', 'web-stories'), }, FADE: { value: AnimationType.Fade, name: _x('Fade', 'animation effect', 'web-stories'), }, FLIP: { value: AnimationType.Flip, name: _x('Flip', 'animation effect', 'web-stories'), }, FLOAT_ON: { value: AnimationType.FloatOn, name: _x('Float On', 'animation effect', 'web-stories'), }, MOVE: { value: AnimationType.Move, name: _x('Move', 'animation effect', 'web-stories'), }, PULSE: { value: AnimationType.Pulse, name: _x('Pulse', 'animation effect', 'web-stories'), }, SPIN: { value: AnimationType.Spin, name: _x('Spin', 'animation effect', 'web-stories'), }, ZOOM: { value: AnimationType.Zoom, name: _x('Zoom', 'animation effect', 'web-stories'), }, }; export const SCALE_DIRECTIONS = { IN: [ScaleDirection.ScaleInTopLeft, ScaleDirection.ScaleInBottomRight], OUT: [ScaleDirection.ScaleOutTopRight, ScaleDirection.ScaleOutBottomLeft], } as const; export const BG_MIN_SCALE = 100; export const BG_MAX_SCALE = 400; ================================================ FILE: packages/animation/src/index.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export * from './components'; export * from './constants'; export * from './parts'; export * from './outputs'; export * from './utils'; export * from './types'; ================================================ FILE: packages/animation/src/outputs/animationOutput.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import type { AnimationOutputProps } from './types'; function AnimationOutput({ config }: AnimationOutputProps) { const configs = Array.isArray(config) ? config : [config]; return ( ', get_tag_amp_block_on_consent_attribute(), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped wp_json_encode( $gtag_amp_opt_filtered ) ); } add_action( 'web_stories_print_analytics', __NAMESPACE__ . '\print_amp_gtag' ); /** * Force analytics to be enabled. * * @param $current * * @return string[] */ function mock_enable_active_modules( $current ) { return [ 'analytics' ]; } add_filter( 'pre_option_googlesitekit_active_modules', __NAMESPACE__ . '\mock_enable_active_modules' ); /** * Force analytics snippet to be enabled. * * @param $current * * @return string[] */ function mock_enable_analytics_settings( $current ) { return [ 'useSnippet' => true ]; } add_filter( 'pre_option_googlesitekit_analytics_settings', __NAMESPACE__ . '\mock_enable_analytics_settings' ); /** * Gets the HTML attributes for an AMP tag that may potentially require user consent before loading. * * * @return string HTML attributes to add if the tag requires consent to load, or an empty string. */ function get_tag_amp_block_on_consent_attribute() { /** * Filters whether the tag requires user consent before loading. * * @since 1.18.0 * * @param bool|string $blocked Whether or not the tag requires user consent to load. Alternatively, this can also be one of * the special string values '_till_responded', '_till_accepted', or '_auto_reject'. Default: false. */ $block_on_consent = apply_filters( 'googlesitekit_analytics_tag_amp_block_on_consent', false ); if ( in_array( $block_on_consent, get_allowed_amp_block_on_consent_values(), true ) ) { return sprintf( ' data-block-on-consent="%s"', $block_on_consent ); } if ( filter_var( $block_on_consent, FILTER_VALIDATE_BOOLEAN ) ) { return ' data-block-on-consent'; } return ''; } function get_allowed_amp_block_on_consent_values() { return [ '_till_responded', '_till_accepted', '_auto_reject', ]; } ================================================ FILE: packages/e2e-tests/src/plugins/status-check-200-invalid.php ================================================ get_route() ) { $result->set_status( 200 ); $result->set_data( 'This is some unexpected content before the actual response.{"success":true}' ); } return $result; } add_action( 'rest_post_dispatch', __NAMESPACE__ . '\filter_rest_response', 10, 3 ); ================================================ FILE: packages/e2e-tests/src/plugins/status-check-403.php ================================================ get_route() ) { $result->set_status( 403 ); $result->set_data( 'Forbidden' ); } return $result; } add_action( 'rest_post_dispatch', __NAMESPACE__ . '\filter_rest_response', 10, 3 ); ================================================ FILE: packages/e2e-tests/src/plugins/status-check-500.php ================================================ get_route() ) { $result->set_status( 500 ); $result->set_data( 'Forbidden' ); } return $result; } add_action( 'rest_post_dispatch', __NAMESPACE__ . '\filter_rest_response', 10, 3 ); ================================================ FILE: packages/e2e-tests/src/plugins/web-stories-cors-error.php ================================================ get_data(); $data['source_url'] = 'https://ps.w.org/web-stories/assets/banner-772x250.png'; $data['media_details']['sizes'] = []; $response->set_data( $data ); return $response; } add_filter( 'web_stories_rest_prepare_attachment', __NAMESPACE__ . '\change_response', 20, 3 ); ================================================ FILE: packages/e2e-tests/src/plugins/web-stories-disable-default-templates.php ================================================ 'Stories in AMP - Hello World', 'poster' => 'https://amp.dev/static/samples/img/story_dog2_portrait.jpg', ]; return wp_json_encode( $data ); } add_filter( 'pre_transient_web_stories_embed_data_' . md5( content_url( 'https://wp.stories.google/stories/intro-to-web-stories-storytime/' ) ), __NAMESPACE__ . '\filter_transient', 20 ); ================================================ FILE: packages/e2e-tests/src/plugins/web-stories-hotlink.php ================================================ 'vtt', 'file_name' => 'test.vtt', 'file_size' => '2000', 'mime_type' => 'text/vtt', 'type' => 'caption', ]; return wp_json_encode( $data ); } add_filter( 'pre_transient_web_stories_url_data_' . md5( content_url( '/e2e-assets/test.vtt' ) ), __NAMESPACE__ . '\filter_transient_vtt_file', 20 ); /** * Hotwire the value of transient, so that a real request is not made. * * @return string */ function filter_transient_jpg_file(): string { $data = [ 'ext' => 'png', 'file_name' => 'example-3.png', 'file_size' => '381503', 'mime_type' => 'image/png', 'type' => 'image', ]; return wp_json_encode( $data ); } add_filter( 'pre_transient_web_stories_url_data_' . md5( content_url( '/e2e-assets/example-3.png' ) ), __NAMESPACE__ . '\filter_transient_jpg_file', 20 ); /** * Hotwire the value of transient, so that a real request is not made. * * @return string */ function filter_transient_external_jpg_file(): string { $data = [ 'ext' => 'jpg', 'file_name' => 'example.jpg', 'file_size' => '21171', 'mime_type' => 'image/jpeg', 'type' => 'image', ]; return wp_json_encode( $data ); } add_filter( 'pre_transient_web_stories_url_data_' . md5( 'https://wp.stories.google/e2e-tests/example.jpg' ), __NAMESPACE__ . '\filter_transient_external_jpg_file', 20 ); /** * Hotwire the value of transient, so that a real request is not made. * * @return string */ function filter_transient_audio_file(): string { $data = [ 'ext' => 'mp3', 'file_name' => 'audio.mp3', 'file_size' => '2000', 'mime_type' => 'audio/mpeg', 'type' => 'audio', ]; return wp_json_encode( $data ); } add_filter( 'pre_transient_web_stories_url_data_' . md5( content_url( '/e2e-assets/audio.mp3' ) ), __NAMESPACE__ . '\filter_transient_audio_file', 20 ); ================================================ FILE: packages/e2e-tests/src/plugins/web-stories-meta-box.php ================================================ ID, '_web_stories_test_meta_box_content', true ); ?> cap->edit_post, $post_id ) ) { return; } $value = sanitize_text_field( $_POST['web_stories_test_meta_box_content'] ); update_post_meta( $post_id, '_web_stories_test_meta_box_content', $value ); } add_action( 'save_post', __NAMESPACE__ . '\save_post' ); function render_head() { $value = get_post_meta( get_the_ID(), '_web_stories_test_meta_box_content', true ); ?> 'Story Colors', 'show_in_rest' => true, 'show_ui' => true, 'rest_base' => 'story-colors', 'labels' => [ 'name' => 'Colors', 'singular_name' => 'Color', 'search_items' => 'Search Colors', 'popular_items' => 'Popular Colors', 'all_items' => 'All Colors', 'edit_item' => 'Edit Color', 'view_item' => 'View Color', 'update_item' => 'Update Color', 'add_new_item' => 'Add New Color', 'new_item_name' => 'New Color Name', 'separate_items_with_commas' => 'Separate colors with commas', 'add_or_remove_items' => 'Add or remove colors', 'choose_from_most_used' => 'Choose from the most used colors', 'not_found' => 'No colors found.', 'no_terms' => 'No colors', 'items_list_navigation' => 'Colors list navigation', 'items_list' => 'Colors list', 'most_used' => 'Most Used', 'back_to_items' => '← Go to Colors', 'item_link' => 'Color Color', 'item_link_description' => 'A link to a color.', ], ] ); register_taxonomy( 'story-vertical', 'web-story', [ 'description' => 'Story Verticals', 'hierarchical' => true, 'show_in_rest' => true, 'show_ui' => true, 'rest_base' => 'story-verticals', 'labels' => [ 'name' => 'Verticals', 'singular_name' => 'Vertical', 'search_items' => 'Search Verticals', 'all_items' => 'All Verticals', 'parent_item' => 'Parent Vertical', 'parent_item_colon' => 'Parent Vertical:', 'edit_item' => 'Edit Vertical', 'view_item' => 'View Vertical', 'update_item' => 'Update Vertical', 'add_new_item' => 'Add New Vertical', 'new_item_name' => 'New Vertical Name', 'not_found' => 'No verticals found.', 'no_terms' => 'No verticals', 'filter_by_item' => 'Filter by vertical', 'items_list_navigation' => 'Verticals list navigation', 'items_list' => 'Verticals list', 'most_used' => 'Most Used', 'back_to_items' => '← Go to Verticals', 'item_link' => 'Color Vertical', 'item_link_description' => 'A link to a vertical.', ], ] ); } add_action( 'init', __NAMESPACE__ . '\add_taxonomies' ); ================================================ FILE: packages/e2e-tests/src/puppeteerEnvironment.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { mkdirSync, writeFileSync } from 'fs'; import util from 'node:util'; // eslint-disable-next-line import/no-extraneous-dependencies -- Transitive dependency. import JestPuppeteerEnvironment from 'jest-environment-puppeteer'; const ARTIFACTS_PATH = process.env.E2E_ARTIFACTS_PATH || (process.env.GITHUB_WORKSPACE || process.cwd()) + '/build/e2e-artifacts'; class PuppeteerEnvironment extends JestPuppeteerEnvironment { async setup() { await super.setup(); try { mkdirSync(ARTIFACTS_PATH, { recursive: true }); } catch (err) { if (err.code !== 'EEXIST') { throw err; } } } async handleTestEvent(event, state) { if (event.name === 'test_fn_failure' || event.name === 'hook_failure') { const testName = event.name === 'test_fn_failure' ? `${state.currentlyRunningTest.parent.name} ${state.currentlyRunningTest.name}` : 'before or after hook'; let errorMessages = ''; if (event.test) { const errors = state.currentlyRunningTest?.errors || []; const eventError = util.inspect(event); errorMessages += `========= ${testName} ==========\n\n`; errorMessages += 'started:' + new Date(event.test.startedAt).toLocaleString() + ' ended:' + new Date().toLocaleString(); errorMessages += '============end==========\n\n'; errors.forEach((error) => { errorMessages += `${testName}:${error}\n\n`; }); errorMessages += '=========================\n\n'; errorMessages += eventError; } await this.storeArtifacts(testName, errorMessages); } } async storeArtifacts(testName, errorMessages) { const datetime = new Date().toISOString().split('.')[0]; const fileName = `${testName} ${datetime}`.replaceAll(/[ :"/\\|?*]+/g, '-'); writeFileSync(`${ARTIFACTS_PATH}/${fileName}-errors.txt`, errorMessages); if (this.global.page.isClosed()) { return; } writeFileSync( `${ARTIFACTS_PATH}/${fileName}-snapshot.html`, await this.global.page.content() ); await this.global.page.screenshot({ path: `${ARTIFACTS_PATH}/${fileName}.jpg`, }); } } export default PuppeteerEnvironment; ================================================ FILE: packages/e2e-tests/src/specs/dashboard/adminMenu.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitDashboard } from '@web-stories-wp/e2e-test-utils'; expect.extend({ async toHaveSyncedNav(page) { const activeMenuItem = await page.evaluate( () => document.querySelector('#menu-posts-web-story .current a')?.innerText ); const activePage = await page.evaluate( () => document.querySelector('h2')?.innerText ); const pass = activeMenuItem === activePage; return { pass, message: `Expected active admin menu link "${activeMenuItem}" to match current page "${activePage}"`, }; }, }); jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Admin Menu', () => { it('should sync the WP nav with the dashboard nav', async () => { await visitDashboard(); // Initial visit to `/` makes `Dashboard` link current in WP await page.hover('#menu-posts-web-story'); await expect(page).toHaveSyncedNav(); // Navigating through the application to a new page syncs the WP current page in Nav await Promise.all([ page.waitForNavigation(), expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Explore Templates', }), ]); await expect(page).toHaveSyncedNav(); // Navigating through WP to a new page syncs the WP current page in Nav await page.hover('#menu-posts-web-story'); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 100)); await Promise.all([ page.waitForNavigation(), expect(page).toClick('#menu-posts-web-story a', { text: 'Settings', }), ]); await expect(page).toHaveSyncedNav(); // Navigating through application back to Dashboard from another route await Promise.all([ page.waitForNavigation(), expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Dashboard', }), ]); await expect(page).toHaveSyncedNav(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/dashboard.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, withRTL, visitDashboard, createNewStory, insertStoryTitle, publishStory, trashAllPosts, } from '@web-stories-wp/e2e-test-utils'; const percyCSS = `.dashboard-grid-item-date { display: none; }`; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Stories Dashboard', () => { afterAll(async () => { await trashAllPosts('web-story'); }); it('should be able to open the dashboard', async () => { await visitDashboard(); // If there are no existing stories, the app goes to the templates page instead. // Account for both here, but then force-visit the dashboard. await expect(page).toMatchElement('h2', { text: /(Dashboard|Explore Templates)/, }); await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Dashboard', }); await takeSnapshot(page, 'Stories Dashboard', { percyCSS }); }); it('should be able to skip to main content of Dashboard for keyboard navigation', async () => { await visitDashboard(); // If there are no existing stories, the app goes to the Templates page instead. // Either is fine since we're testing keyboard navigation. await expect(page).toMatchElement('h2', { text: /(Dashboard|Explore Templates)/, }); // When navigating to Dashboard, immediately use keyboard to // tab to WordPress shortcut of "Main Content" await page.keyboard.press('Tab'); // Verify that Main Content skip link is present await expect(page).toMatchElement('a', { text: 'Skip to main content' }); // Use the keyboard to select skip link while it is present (since it's now focused) await page.keyboard.press('Enter'); // Make sure we see the dashboard await expect(page).toMatchElement('h2', { text: /(Dashboard|Explore Templates)/, }); // Now let's make sure that the next focusable element is the link to create a new story await page.keyboard.press('Tab'); const activeElement = await page.evaluate(() => { return { text: document.activeElement.textContent, element: document.activeElement.tagName.toLowerCase(), }; }); await expect(activeElement).toMatchObject({ text: 'Create New Story', element: 'a', }); await takeSnapshot(page, 'Stories Dashboard on Keyboard Navigation', { percyCSS, }); }); it('should choose sort option for display', async () => { // dropdown needs a story for filtering await createNewStory(); await insertStoryTitle('Stories Dashboard test - story'); await publishStory(); await visitDashboard(); await expect(page).toClick('[aria-label="Dashboard (active view)"]', { text: 'Dashboard', }); const sortButtonSelector = 'button[aria-label="Choose sort option for display"]'; await expect(page).toClick(sortButtonSelector, { text: 'Last Modified' }); await takeSnapshot(page, 'Dashboard sort option dropdown', { percyCSS }); await expect(page).toClick('li', { text: 'Created By' }); await expect(page).toMatchElement(sortButtonSelector, { text: 'Created By', }); }); describe('RTL', () => { withRTL(); it('should be able to open the dashboard', async () => { await visitDashboard(); await expect(page).toMatchElement('h2', { text: /(Dashboard|Explore Templates)/, }); await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Dashboard', }); await takeSnapshot(page, 'Stories Dashboard on RTL', { percyCSS }); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/documentTitle.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitDashboard } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Document Title', () => { it('should update the document title during navigation', async () => { await visitDashboard(); // First visit to Dashboard my redirect to Explore Templates. // See https://github.com/googleforcreators/web-stories-wp/pull/7213, needs updating. // Work around this by explicitly clicking on dashboard again. await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Dashboard', }); await expect(page).toMatchElement('h2', { text: 'Dashboard' }); await expect(page.title()).resolves.toStartWith('Dashboard'); await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Explore Templates', }); await expect(page).toMatchElement('h2', { text: 'Explore Templates' }); await expect(page.title()).resolves.toStartWith('Explore Templates'); await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Settings', }); await expect(page).toMatchElement('h2', { text: 'Settings' }); await expect(page.title()).resolves.toStartWith('Settings'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/myStories.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, publishStory, visitDashboard, insertStoryTitle, trashAllPosts, } from '@web-stories-wp/e2e-test-utils'; const storyName = 'Test Story'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Stories Dashboard', () => { beforeEach(async () => { await createNewStory(); await insertStoryTitle(storyName); await publishStory(); await visitDashboard(); }); afterAll(async () => { await trashAllPosts('web-story'); }); it('should delete story', async () => { await expect(page).toMatchElement('h2', { text: 'Dashboard' }); await page.hover( `[role="listitem"][aria-label="Details about ${storyName}"]` ); await expect(page).toClick( `button[aria-label="Context menu for ${storyName}"]` ); await expect(page).toClick('button', { text: 'Delete Story' }); await expect(page).toMatchTextContent(/Are you sure you want to delete/); await expect(page).toMatchElement( '[role="dialog"][aria-label="Dialog to confirm deleting a story"] button', { text: 'Delete', } ); await expect(page).toClick( '[role="dialog"][aria-label="Dialog to confirm deleting a story"] button', { text: 'Delete', } ); await page.waitForResponse((response) => response.url().includes('web-stories/v1/web-story/') ); await expect(page).not.toMatchElement('h3', { text: storyName, }); }); it('should duplicate story', async () => { await expect(page).toMatchElement('h2', { text: 'Dashboard' }); await page.hover( `[role="listitem"][aria-label="Details about ${storyName}"]` ); await expect(page).toClick( `button[aria-label="Context menu for ${storyName}"]` ); await Promise.all([ expect(page).toClick('button', { text: 'Duplicate' }), page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test response.url().includes('web-stories/v1/web-story/') && response.status() === 201 ), ]); await expect(page).toMatchElement('h3', { text: `${storyName} (Copy)`, }); }); it('should rename story', async () => { const newStoryName = 'Renamed story'; await expect(page).toMatchElement('h2', { text: 'Dashboard' }); await page.hover( `[role="listitem"][aria-label="Details about ${storyName}"]` ); await expect(page).toClick( `button[aria-label="Context menu for ${storyName}"]` ); await expect(page).toClick('button', { text: 'Rename' }); await expect(page).toMatchElement(`input[value="${storyName}"]`); await page.type(`input[value="${storyName}"]`, newStoryName); await Promise.all([ page.keyboard.press('Enter'), page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test response.url().includes('web-stories/v1/web-story/') && response.status() === 200 ), ]); await expect(page).toMatchElement('h3', { text: newStoryName, }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/noJS.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, visitDashboard } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Stories Dashboard with disabled JavaScript', () => { it('should display error message', async () => { // Disable javascript for test. await page.setJavaScriptEnabled(false); await visitDashboard(); await expect(page).toMatchElement('.web-stories-wp-no-js'); // Re-enable javascript for snapshots. await page.setJavaScriptEnabled(true); await takeSnapshot(page, 'Dashboard no js'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/analytics.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitSettings } from '@web-stories-wp/e2e-test-utils'; const INPUT_SELECTOR = '[aria-label="Enter your Google Analytics Measurement ID"]'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Analytics', () => { beforeEach(async () => { await visitSettings(); }); it('should render', async () => { const settingsView = await page.$('[data-testid="editor-settings"]'); expect(settingsView).toBeTruthy(); await expect(page).toMatchElement('h2', { text: 'Settings' }); }); it('should update the tracking id when pressing enter and display snackbar confirmation', async () => { await page.click(INPUT_SELECTOR); const inputLength = await page.$eval(INPUT_SELECTOR, (el) => { return el.value.length; }); for (let iter = 0; iter < inputLength; iter++) { // disable eslint to prevent overlapping .act calls // eslint-disable-next-line no-await-in-loop await page.keyboard.press('Backspace'); } await page.keyboard.type('UA-009345-10'); await page.keyboard.press('Enter'); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 400)); await expect(page).toMatchTextContent('Setting saved.'); }); it('should update the tracking id by clicking the save button and display snackbar confirmation', async () => { await page.hover(INPUT_SELECTOR); await page.click(INPUT_SELECTOR); const inputLength = await page.$eval(INPUT_SELECTOR, (el) => { return el.value.length; }); for (let iter = 0; iter < inputLength; iter++) { // disable eslint to prevent overlapping .act calls // eslint-disable-next-line no-await-in-loop await page.keyboard.press('Backspace'); } await page.keyboard.type('UA-009345-11'); await expect(page).toClick('button', { text: 'Save' }); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 400)); await expect(page).toMatchTextContent('Setting saved.'); }); it('should allow the analytics id to be saved as an empty string and display snackbar confirmation', async () => { await page.click(INPUT_SELECTOR); const inputLength = await page.$eval(INPUT_SELECTOR, (el) => { return el.value.length; }); for (let iter = 0; iter < inputLength; iter++) { // disable eslint to prevent overlapping .act calls // eslint-disable-next-line no-await-in-loop await page.keyboard.press('Backspace'); } await expect(page).toClick('button', { text: 'Save' }); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 400)); await expect(page).toMatchTextContent('Setting saved.'); }); it('should not allow an invalid analytics id to be saved', async () => { await page.click(INPUT_SELECTOR); await page.keyboard.type('INVALID'); await expect(page).toClick('button', { text: 'Save' }); await expect(page).toMatchTextContent('Invalid ID format'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/customFonts.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitSettings, addCustomFont, removeAllFonts, getFontList, takeSnapshot, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { addAllowedErrorMessage } from '../../../../config/bootstrap'; const FONT_BASE_URL = `${process.env.WP_BASE_URL}/wp-content/e2e-assets`; const OPEN_SANS_CONDENSED_LIGHT = 'Open Sans Condensed Light'; const OPEN_SANS_CONDENSED_LIGHT_URL = `${FONT_BASE_URL}/OpenSansCondensed-Light.ttf`; const OPEN_SANS_CONDENSED_BOLD = 'Open Sans Condensed Bold'; const OPEN_SANS_CONDENSED_BOLD_URL = `${FONT_BASE_URL}/OpenSansCondensed-Bold.ttf`; const OPEN_SANS_CONDENSED_LIGHT_ITALIC_URL = `${FONT_BASE_URL}/OpenSansCondensed-LightItalic.ttf`; const findByUrl = (arr, val) => arr.find((o) => o.url === val); jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Custom Fonts', () => { let removeResourceErrorMessage; beforeAll(() => { // Ignore resource failing to load. This is only present because of the REST API error. removeResourceErrorMessage = addAllowedErrorMessage( 'Failed to load resource' ); }); beforeEach(async () => { await visitSettings(); await removeAllFonts(); }); afterEach(async () => { await visitSettings(); await removeAllFonts(); }); afterAll(() => { removeResourceErrorMessage(); }); // eslint-disable-next-line jest/no-disabled-tests -- Needs further investigation. it.skip('should add a font and handle keyboard navigation', async () => { await addCustomFont(OPEN_SANS_CONDENSED_LIGHT_URL); await addCustomFont(OPEN_SANS_CONDENSED_BOLD_URL); await addCustomFont(OPEN_SANS_CONDENSED_LIGHT_ITALIC_URL); const fonts = await getFontList(); const font1 = findByUrl(fonts, OPEN_SANS_CONDENSED_LIGHT_URL); expect(font1?.name).toStrictEqual(OPEN_SANS_CONDENSED_LIGHT); const font2 = findByUrl(fonts, OPEN_SANS_CONDENSED_BOLD_URL); expect(font2?.name).toStrictEqual(OPEN_SANS_CONDENSED_BOLD); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowUp'); const listbox = await page.$('[role="listbox"]'); const ariaActiveDescendant = await page.evaluate((el) => { const id = el.getAttribute('aria-activedescendant'); return document.getElementById(id).getAttribute('aria-selected'); }, listbox); expect(ariaActiveDescendant).toBe('true'); await page.keyboard.press('Tab'); await expect(page).toMatchElement( `button[aria-label="Delete ${OPEN_SANS_CONDENSED_LIGHT}"]` ); await takeSnapshot(page, 'Custom Fonts Settings'); }); it('should show error on trying add font twice', async () => { await addCustomFont(OPEN_SANS_CONDENSED_LIGHT_URL); await addCustomFont(OPEN_SANS_CONDENSED_LIGHT_URL); await expect(page).toMatchTextContent( 'A font with the name Open Sans Condensed Light already exists.' ); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/dataRemovalSettings.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, enableCheckbox, visitSettings, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ const checkbox = 'input[data-testid="data-removal-settings-checkbox"]'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Admin User', () => { beforeEach(async () => { await visitSettings(); await enableCheckbox(checkbox); }); it('should let me see and update data removal settings', async () => { // verify that the data removal checkbox can be changed await expect(page).toMatchElement(`${checkbox}:checked`); await disableCheckbox(checkbox); // verify that the data removal checkbox can be changed await expect(page).toMatchElement(`${checkbox}:not(:checked)`); await disableCheckbox(checkbox); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/monetization.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitSettings } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { monetizationDropdownSelector } from '../../../../utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Admin User', () => { it('should let me see and update monetization settings', async () => { await visitSettings(); // Small trick to ensure we scroll to this input. const monetizationDropdown = await page.$(monetizationDropdownSelector); await monetizationDropdown.focus(); await expect(page).toMatchElement(monetizationDropdownSelector); // verify that the monetization settings can be changed await expect(page).toClick(monetizationDropdownSelector, { text: 'None' }); await expect(page).toClick('li', { text: 'Google AdSense' }); await expect(page).toMatchElement(monetizationDropdownSelector, { text: 'Google AdSense', }); // reset monetization setting await expect(page).toClick(monetizationDropdownSelector, { text: 'Google AdSense', }); await expect(page).toClick('li', { text: 'None' }); await expect(page).toMatchElement(monetizationDropdownSelector, { text: 'None', }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/publisherLogo.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitSettings, uploadPublisherLogo, withExperimentalFeatures, deleteAllMedia, } from '@web-stories-wp/e2e-test-utils'; const ERROR_TEXT = 'Sorry, this file type is not supported. Only jpg, png, and static gifs are supported for publisher logos.'; async function deleteAllPublisherLogos() { const publisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); // We can only delete all but one publisher logo. publisherLogos.shift(); /* eslint-disable no-await-in-loop */ for (const item of publisherLogos) { await item.$eval('button', (button) => button.click()); await expect(item).toClick('button[aria-label^="Publisher logo menu for"]'); await page.waitForSelector( '[role="menu"][aria-label="Menu"][aria-expanded="true"]' ); await expect(item).toClick( '[role="menu"][aria-label="Menu"] button[role="menuitem"]', { text: 'Delete', visible: true, } ); await expect(page).toMatchTextContent('Setting saved.'); } /* eslint-enable no-await-in-loop */ } jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Fix flakey test. describe.skip('Publisher Logos', () => { describe('Without SVG support', () => { beforeEach(async () => { await deleteAllMedia(); // Will also delete *all* publisher logos. await visitSettings(); await uploadPublisherLogo('web-stories.png'); await uploadPublisherLogo('wordpress-logo.png'); await uploadPublisherLogo('google.png'); }); afterEach(async () => { await deleteAllMedia(); }); it('should update the default logo', async () => { const publisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); const initialDefault = publisherLogos[0]; const logoToMakeDefault = publisherLogos[1]; await expect(initialDefault).toMatchElement('p', { text: 'Default' }); await expect(logoToMakeDefault).not.toMatchElement('p', { text: 'Default', }); await logoToMakeDefault.hover(); await expect(logoToMakeDefault).toClick( 'button[aria-label^="Publisher logo menu for"]' ); // Flyout animation. // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 100)); await page.waitForSelector( '[role="menu"][aria-label="Menu"][aria-expanded="true"]' ); await Promise.all([ expect(logoToMakeDefault).toClick( '[aria-label="Menu"] button[role="menuitem"]', { text: 'Set as Default', visible: true, } ), page.waitForResponse( (response) => // eslint-disable-next-line jest/no-conditional-in-test response.url().includes('/web-stories/v1/publisher-logos') && response.status() === 200 ), ]); await expect(page).toMatchTextContent('Setting saved.'); const updatedPublisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); const oldDefault = updatedPublisherLogos[0]; const newDefault = updatedPublisherLogos[1]; await expect(oldDefault).not.toMatchElement('p', { text: 'Default' }); await expect(newDefault).toMatchElement('p', { text: 'Default' }); }); it('should update the default logo via keyboard', async () => { const publisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); // eslint-disable-next-line jest/no-conditional-in-test if (1 === (publisherLogos?.length || 0)) { throw new Error('Not enough publisher logos'); } const initialDefault = await publisherLogos[0]; expect(initialDefault).toBeTruthy(); await expect(initialDefault).toMatchElement('p', { text: 'Default' }); await page.focus('[aria-label="Viewing existing publisher logos"]'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); await page.keyboard.press('Enter'); await expect(page).toMatchTextContent('Setting saved.'); const updatedPublisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); const oldDefault = updatedPublisherLogos[0]; const newDefault = updatedPublisherLogos[1]; expect(oldDefault).toBeTruthy(); await expect(oldDefault).not.toMatchElement('p', { text: 'Default' }); expect(newDefault).toBeTruthy(); await expect(newDefault).toMatchElement('p', { text: 'Default' }); }); it('should remove a logo via keyboard', async () => { const publisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); // eslint-disable-next-line jest/no-conditional-in-test const initialPublisherLogosLength = publisherLogos.length || 0; // eslint-disable-next-line jest/no-conditional-in-test if (1 === initialPublisherLogosLength) { throw new Error('Not enough publisher logos'); } await page.focus('[aria-label="Viewing existing publisher logos"]'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await expect(page).toMatchTextContent('Setting saved.'); const updatedPublisherLogos = await page.$$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]' ); // eslint-disable-next-line jest/no-conditional-in-test const updatedPublisherLogosLength = updatedPublisherLogos.length || 0; expect(updatedPublisherLogosLength).toBe(initialPublisherLogosLength - 1); }); it('should not be able to delete the last logo', async () => { await deleteAllPublisherLogos(); const firstLogo = await page.$( '[role="list"][aria-label="Viewing existing publisher logos"] [role="listitem"]:nth-of-type(1)' ); await firstLogo.hover(); await expect(firstLogo).not.toMatchElement( 'button[aria-label^="Publisher logo menu for"]' ); }); }); describe('With SVG support', () => { withExperimentalFeatures(['enableSVG']); it('should not allow using an SVG as a publisher logo', async () => { await visitSettings(); // Upload publisher logo await uploadPublisherLogo('video-play.svg', false); // verify error message await expect(page).toMatchElement('[role="alert"]', { text: ERROR_TEXT, }); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/shoppingProvider.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { visitSettings } from '@web-stories-wp/e2e-test-utils'; const shoppingProviderDropdownSelector = 'button[aria-label="Shopping provider"]'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Shopify settings', () => { it('should let me see and update shopping provider settings', async () => { await visitSettings(); // Small trick to ensure we scroll to this input. const shoppingProviderDropdown = await page.$( shoppingProviderDropdownSelector ); await shoppingProviderDropdown.focus(); await expect(page).toMatchElement(shoppingProviderDropdownSelector); // verify that the shopping provider settings can be changed await expect(page).toClick(shoppingProviderDropdownSelector, { text: /None|WooCommerce|Shopify/i, }); await expect(page).toClick('[role="listbox"] li', { text: 'Shopify' }); await expect(page).toMatchElement(shoppingProviderDropdownSelector, { text: 'Shopify', }); // reset shopping provider setting await expect(page).toClick(shoppingProviderDropdownSelector, { text: 'Shopify', }); await expect(page).toClick('[role="listbox"] li', { text: 'None' }); await expect(page).toMatchElement(shoppingProviderDropdownSelector, { text: 'None', }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/telemetryBanner.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, visitSettings } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Telemetry Banner', () => { beforeEach(async () => { await visitSettings(); }); afterEach(async () => { await page.evaluate(() => { window.location.hash = '#'; localStorage.clear(); }); }); afterAll(async () => { await visitSettings(); await disableCheckbox('[data-testid="telemetry-settings-checkbox"]'); await page.evaluate(() => { localStorage.removeItem('web_stories_tracking_optin_banner_closed'); }); }); it('should render the telemetry settings checkbox', async () => { const settingsView = await page.$('[data-testid="editor-settings"]'); expect(settingsView).toBeTruthy(); const TelemetrySettingsCheckbox = await page.$( '[data-testid="telemetry-settings-checkbox"]' ); expect(TelemetrySettingsCheckbox).toBeTruthy(); }); it('should toggle the value and call the API provider when the tracking opt in box is clicked and display snackbar confirmation', async () => { await disableCheckbox('[data-testid="telemetry-settings-checkbox"]'); const settingsView = await page.$('[data-testid="editor-settings"]'); expect(settingsView).toBeTruthy(); const telemetrySettingsCheckbox = await page.$( '[data-testid="telemetry-settings-checkbox"]' ); const checkboxStatus = await telemetrySettingsCheckbox.evaluate((el) => { return el.checked; }); expect(checkboxStatus).toBeFalse(); await expect(page).toClick('[data-testid="telemetry-settings-checkbox"]'); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 300)); const updatedCheckboxStatus = await telemetrySettingsCheckbox.evaluate( (el) => { return el.checked; } ); expect(updatedCheckboxStatus).toBeTrue(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/adminUser/videoSettings.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, enableCheckbox, visitSettings, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { videoCacheCheckboxSelector, videoOptimizationCheckboxSelector, } from '../../../../utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Admin User', () => { beforeEach(async () => { await visitSettings(); await enableCheckbox(videoOptimizationCheckboxSelector); await enableCheckbox(videoCacheCheckboxSelector); }); afterEach(async () => { await visitSettings(); await enableCheckbox(videoOptimizationCheckboxSelector); await disableCheckbox(videoCacheCheckboxSelector); }); it('should let me see and update video settings', async () => { // verify that the video optimization checkbox can be changed await expect(page).toMatchElement( `${videoOptimizationCheckboxSelector}:checked` ); await disableCheckbox(videoOptimizationCheckboxSelector); // verify that the video cache checkbox can be changed await expect(page).toMatchElement(`${videoCacheCheckboxSelector}:checked`); await disableCheckbox(videoCacheCheckboxSelector); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/authorUser.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, enableCheckbox, visitSettings, withUser, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { monetizationDropdownSelector, telemetryCheckboxSelector, videoCacheCheckboxSelector, videoOptimizationCheckboxSelector, } from '../../../utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Author User', () => { withUser('author', 'password'); beforeEach(async () => { await visitSettings(); await enableCheckbox(telemetryCheckboxSelector); await enableCheckbox(videoOptimizationCheckboxSelector); }); afterEach(async () => { await visitSettings(); await disableCheckbox(telemetryCheckboxSelector); await enableCheckbox(videoOptimizationCheckboxSelector); }); it('should only let me see and update the telemetry and video optimization settings', async () => { // verify that the telemetry checkbox can be changed await expect(page).toMatchElement(`${telemetryCheckboxSelector}:checked`); await disableCheckbox(telemetryCheckboxSelector); // verify that the video optimization checkbox can be changed await expect(page).toMatchElement( `${videoOptimizationCheckboxSelector}:checked` ); await disableCheckbox(videoOptimizationCheckboxSelector); // verify no other settings are showing await expect(page).not.toMatchElement( '[data-testid="publisher-logos-container"]' ); await expect(page).not.toMatchElement(monetizationDropdownSelector); await expect(page).not.toMatchElement(videoCacheCheckboxSelector); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/contributorUser.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, enableCheckbox, visitSettings, withUser, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { monetizationDropdownSelector, telemetryCheckboxSelector, videoCacheCheckboxSelector, videoOptimizationCheckboxSelector, } from '../../../utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Contributor User', () => { withUser('contributor', 'password'); beforeEach(async () => { await visitSettings(); await enableCheckbox(telemetryCheckboxSelector); }); afterEach(async () => { await visitSettings(); await disableCheckbox(telemetryCheckboxSelector); }); it('should only let me see and update the telemetry setting', async () => { // verify that the telemetry checkbox can be changed await expect(page).toMatchElement(`${telemetryCheckboxSelector}:checked`); await disableCheckbox(telemetryCheckboxSelector); // verify no other settings are showing await expect(page).not.toMatchElement( '[data-testid="publisher-logos-container"]' ); await expect(page).not.toMatchElement(monetizationDropdownSelector); await expect(page).not.toMatchElement(videoOptimizationCheckboxSelector); await expect(page).not.toMatchElement(videoCacheCheckboxSelector); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/settings/editorUser.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, enableCheckbox, visitSettings, withUser, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { monetizationDropdownSelector, telemetryCheckboxSelector, videoCacheCheckboxSelector, videoOptimizationCheckboxSelector, } from '../../../utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Editor User', () => { withUser('editor', 'password'); beforeEach(async () => { await visitSettings(); await enableCheckbox(telemetryCheckboxSelector); await enableCheckbox(videoOptimizationCheckboxSelector); }); afterEach(async () => { await visitSettings(); await disableCheckbox(telemetryCheckboxSelector); await enableCheckbox(videoOptimizationCheckboxSelector); }); it('should only let me see and update the telemetry and video optimization settings', async () => { // verify that the telemetry checkbox can be changed await expect(page).toMatchElement(`${telemetryCheckboxSelector}:checked`); await disableCheckbox(telemetryCheckboxSelector); // verify that the video optimization checkbox can be changed await expect(page).toMatchElement( `${videoOptimizationCheckboxSelector}:checked` ); await disableCheckbox(videoOptimizationCheckboxSelector); // verify no other settings are showing await expect(page).not.toMatchElement( '[data-testid="publisher-logos-container"]' ); await expect(page).not.toMatchElement(monetizationDropdownSelector); await expect(page).not.toMatchElement(videoCacheCheckboxSelector); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/telemetryBanner.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { disableCheckbox, visitDashboard, visitSettings, clearLocalStorage, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Telemetry Banner', () => { beforeAll(async () => { await visitSettings(); await disableCheckbox('[data-testid="telemetry-settings-checkbox"]'); await clearLocalStorage(); }); beforeEach(async () => { await visitDashboard(); }); afterEach(async () => { await clearLocalStorage(); }); afterAll(async () => { await visitSettings(); await disableCheckbox('[data-testid="telemetry-settings-checkbox"]'); await page.evaluate(() => { localStorage.removeItem('web_stories_tracking_optin_banner_closed'); }); }); it('should render the telemetry opt in banner', async () => { await expect(page).toMatchTextContent('Help improve the editor!'); }); it('should close the banner when the exit button is closed', async () => { await expect(page).toMatchTextContent('Help improve the editor!'); await expect(page).toClick('[aria-label="Dismiss telemetry banner"]'); await expect(page).not.toMatchTextContent('Help improve the editor!'); }); it('should not display the banner after it has been closed', async () => { await expect(page).toMatchTextContent('Help improve the editor!'); await expect(page).toClick('[aria-label="Dismiss telemetry banner"]'); await page.reload(); await expect(page).not.toMatchTextContent('Help improve the editor!'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/dashboard/templates/useTemplate.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, visitDashboard, withPlugin, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Explore Templates', () => { it('should be able to use existing template for new story', async () => { await visitDashboard(); await expect(page).toMatchElement( '[aria-label="Main dashboard navigation"]' ); await expect(page).toClick('[aria-label="Main dashboard navigation"] a', { text: 'Explore Templates', }); // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 100)); await expect(page).toMatchTextContent(/Viewing all (\d+) templates/); await expect(page).toClick('[data-testid="template-grid-item-1"] button', { text: 'See details', }); // Get count of template colors to compare to 'saved colors' in the editor. const templateDetailsColors = await page.evaluate(() => { const elements = document.querySelectorAll( 'li[data-testid="detail-template-color"]' ); const count = elements.length; const colors = []; for (let i = 0; i < count; i++) { colors.push(window.getComputedStyle(elements[i]).backgroundColor); } return colors; }); await Promise.all([ expect(page).toClick( 'button[aria-label="Use Fresh & Bright template to create new story"]' ), page.waitForNavigation(), ]); // Wait for title input to load before continuing. await page.waitForSelector('input[placeholder="Add title"]'); // Wait for skeleton thumbnails in the carousel to render which gives footer time to also render await page.waitForFunction( () => !document.querySelector( 'li[data-testid^="carousel-page-preview-skeleton"]' ), { timeout: 5000 } // requestIdleCallback in the carousel kicks in after 5s the latest. ); // Click on text element so the 'Saved Colors' panel is present. // Pressing Option/Alt key because it's part of a layer group. await page.keyboard.down('Alt'); await expect(page).toClick( '[data-testid="frameElement"][aria-label^="Element: Fresh"]' ); await page.keyboard.up('Alt'); await takeSnapshot(page, 'Story From Template'); // Open the color picker from the floating menu await expect(page).toClick('button[aria-label="Text color"]'); // Get all saved story colors and subtract 1 button for adding other colors const editorSavedColors = await page.evaluate(() => { const elements = document.querySelectorAll( 'div[data-testid="saved-story-colors"] button > div' ); const count = elements.length; const colors = []; for (let i = 0; i < count; i++) { colors.push(window.getComputedStyle(elements[i]).backgroundColor); } return colors; }); expect(editorSavedColors).toStrictEqual(templateDetailsColors); }); describe('Disabled', () => { withPlugin('e2e-tests-disable-default-templates'); it('should not display Explore Templates screen', async () => { await visitDashboard(); await expect(page).toMatchElement('h2', { text: 'Dashboard' }); await expect(page).not.toMatchElement('a', { text: 'Explore Templates', }); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/authorUser.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, previewStory, insertStoryTitle, withUser, publishStory, trashAllPosts, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. describe.skip('Author User', () => { withUser('author', 'password'); afterAll(async () => { await trashAllPosts('web-story'); }); it('should directly preview a story without markup being stripped', async () => { await createNewStory(); await insertStoryTitle('Previewing without Publishing'); await expect(page).toClick('[data-testid="mediaElement-image"]'); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); const previewPage = await previewStory(); await expect(previewPage).toMatchElement('amp-img'); await page.bringToFront(); await previewPage.close(); }); it('should publish a story without markup being stripped', async () => { await createNewStory(); await insertStoryTitle('Publishing and Previewing'); // Make some changes _before_ publishing the story. await expect(page).toClick('[data-testid="mediaElement-image"]'); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); await publishStory(); const previewPage = await previewStory(); await expect(previewPage).toMatchElement('amp-img'); await previewPage.close(); await page.bringToFront(); }); it('should publish and preview a story without markup being stripped', async () => { await createNewStory(); await insertStoryTitle('Autosaving and Previewing'); await publishStory(); // Make some changes _after_ publishing so previewing will cause an autosave. await expect(page).toClick('[data-testid="mediaElement-image"]'); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); const previewPage = await previewStory(); await expect(previewPage).toMatchElement('amp-img'); await page.bringToFront(); await previewPage.close(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/backgroundAudio.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, uploadFile, deleteMedia, takeSnapshot, withPlugin, insertStoryTitle, publishStory, trashAllPosts, } from '@web-stories-wp/e2e-test-utils'; const VTT_URL = `${process.env.WP_BASE_URL}/wp-content/e2e-assets/test.vtt`; const MP3_URL = `${process.env.WP_BASE_URL}/wp-content/e2e-assets/audio.mp3`; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Background Audio', () => { let uploadedFiles; beforeEach(() => (uploadedFiles = [])); afterEach(async () => { for (const file of uploadedFiles) { // eslint-disable-next-line no-await-in-loop await deleteMedia(file); } }); afterAll(async () => { await trashAllPosts('web-story'); }); describe('Story Background Audio', () => { it('should allow adding background audio', async () => { await createNewStory(); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toMatchTextContent('Background Audio'); // Toggle the panel which is collapsed by default. await expect(page).toClick('[aria-label="Background Audio"]'); await expect(page).toClick('button', { text: 'Upload an audio file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('audio.mp3', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Select audio file' }); await page.waitForSelector('.media-modal', { visible: false, }); await expect(page).toMatchTextContent(fileName); await expect(page).toMatchElement('button[aria-label="Play"]'); await takeSnapshot(page, 'Story Background Audio'); }); }); describe('Page Background Audio', () => { it('should allow adding background audio', async () => { await createNewStory(); await page.focus('[aria-label="Element: Background"]'); await expect(page).toClick('li', { text: /^Style$/i }); await expect(page).toMatchTextContent('Page Background Audio'); await expect(page).toClick('button', { text: 'Upload an audio file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('audio.mp3', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Select audio file' }); await page.waitForSelector('.media-modal', { visible: false, }); await expect(page).toMatchTextContent(fileName); await expect(page).toMatchElement('button[aria-label="Play"]'); await expect(page).toMatchElement('label', { text: 'Loop', }); }); it('should allow adding background audio with captions', async () => { await createNewStory(); await page.focus('[aria-label="Element: Background"]'); await expect(page).toClick('li', { text: /^Style$/i }); await expect(page).toMatchTextContent('Page Background Audio'); await expect(page).toClick('button', { text: 'Upload an audio file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('audio.mp3', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Select audio file' }); await page.waitForSelector('.media-modal', { visible: false, }); await expect(page).toMatchTextContent(fileName); await expect(page).toMatchElement('button[aria-label="Play"]'); await expect(page).toClick('button', { text: 'Upload audio captions' }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileNameCaptions = await uploadFile('test.vtt'); uploadedFiles.push(fileNameCaptions); await expect(page).toClick('button', { text: 'Select caption' }); await expect(page).toMatchTextContent('test.vtt'); }); describe('Hotlink', () => { withPlugin('e2e-tests-hotlink'); describe('Audio file', () => { it('should allow adding background audio', async () => { await createNewStory(); await page.focus('[aria-label="Element: Background"]'); await expect(page).toClick('li', { text: /^Style$/i }); await expect(page).toMatchTextContent('Page Background Audio'); await expect(page).toClick('button', { text: 'Link to audio file' }); await page.waitForSelector('[role="dialog"]'); await expect(page).toMatchTextContent( 'Insert external background audio' ); await expect(page).toMatchElement('[role="dialog"]'); await page.type('[role="dialog"] input[type="url"]', MP3_URL); await expect(page).toClick('[role="dialog"] button', { text: 'Use audio file', }); // Dialog should disappear by now. await expect(page).not.toMatchTextContent( 'Insert external background audio' ); await page.waitForSelector('audio source[src*="audio.mp3"]'); await expect(page).not.toMatchElement('button', { text: 'Link to audio file', }); }); }); describe('Captions', () => { it('should allow adding background audio with captions', async () => { await createNewStory(); await page.focus('[aria-label="Element: Background"]'); await expect(page).toClick('li', { text: /^Style$/i }); await expect(page).toMatchTextContent('Page Background Audio'); await expect(page).toClick('button', { text: 'Upload an audio file', }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('audio.mp3', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Select audio file' }); await page.waitForSelector('.media-modal', { visible: false, }); await expect(page).toMatchTextContent(fileName); await expect(page).toMatchElement('button[aria-label="Play"]'); await expect(page).toClick('button', { text: 'Link to caption file', }); await page.waitForSelector('[role="dialog"]'); await expect(page).toMatchTextContent('Insert external captions'); await expect(page).toMatchElement('[role="dialog"]'); await page.type('[role="dialog"] input[type="url"]', VTT_URL); await Promise.all([ expect(page).toClick('[role="dialog"] button', { text: 'Use caption', }), expect(page).toMatchElement('[role="dialog"] button[disabled]', { text: 'Selecting caption', }), ]); await expect(page).toMatchTextContent('test.vtt'); }); }); }); describe('Add and remove background audio', () => { // see: https://github.com/GoogleForCreators/web-stories-wp/issues/11229 it('should allow saving after deleting audio', async () => { await createNewStory(); await insertStoryTitle('Add and delete background audio'); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); // Toggle the panel which is collapsed by default. await expect(page).toClick('[aria-label="Background Audio"]'); await expect(page).toClick('button', { text: 'Upload an audio file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('audio.mp3', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Select audio file' }); await page.waitForSelector('.media-modal', { visible: false }); await expect(page).toMatchTextContent(fileName); await expect(page).toClick('[aria-label="Remove file"]'); await publishStory(); }); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/contributorUser.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, insertStoryTitle, withUser, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. describe.skip('Contributor User', () => { withUser('contributor', 'password'); it('should only be able to submit a story for review', async () => { await createNewStory(); await insertStoryTitle('Submitting for review'); await expect(page).toMatchElement('button', { text: 'Submit for review' }); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toMatchElement('button[disabled]', { text: /^Public/, }); await expect(page).not.toMatchElement('li[role="option"]', { text: /^Private/, }); await expect(page).not.toMatchElement('li[role="option"]', { text: /^Password Protected/, }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/editor.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, createNewStory, toggleVideoOptimization, withRTL, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Story Editor', () => { it('should be able to create a blank story', async () => { await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await takeSnapshot(page, 'Empty Editor'); }); describe('RTL', () => { withRTL(); it('should be able to create a blank story on RTL', async () => { await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await takeSnapshot(page, 'Empty Editor on RTL'); }); }); it('should have cross-origin isolation enabled', async () => { await createNewStory(); const crossOriginIsolated = await page.evaluate( () => window.crossOriginIsolated ); expect(crossOriginIsolated).toBeTrue(); }); it('should have cross-origin isolation disabled', async () => { await toggleVideoOptimization(); await createNewStory(); const crossOriginIsolated = await page.evaluate( () => window.crossOriginIsolated ); expect(crossOriginIsolated).toBeFalse(); await toggleVideoOptimization(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/floatingMenu.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { addTextElement, createNewStory } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Floating Menu', () => { const floatingMenu = 'section[aria-label="Design menu"]'; beforeEach(async () => { await createNewStory(); }); it('should display text floating menu', async () => { await addTextElement('Paragraph'); await page.waitForSelector(floatingMenu); await expect(page).toMatchElement(floatingMenu); }); it('should display media floating menu', async () => { await expect(page).toClick('[data-testid="mediaElement-image"]'); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); // Floating menu should show up await page.waitForSelector(floatingMenu); await expect(page).toMatchElement(floatingMenu); }); it('should display media3p floating menu', async () => { // Open a media3p tab const media3pSelector = '#library-tab-media3p'; await expect(page).toMatchElement(media3pSelector); await expect(page).toClick(media3pSelector); await expect(page).toMatchElement('button', { text: 'Image' }); await expect(page).toClick('button', { text: 'Image' }); await page.waitForSelector( '#library-pane-media3p [data-testid="mediaElement-image"]' ); // Clicking will only act on the first element. await expect(page).toClick( '#library-pane-media3p [data-testid="mediaElement-image"]' ); const insertButton = await page.waitForSelector( `xpath/.//li//span[contains(text(), 'Insert image')]` ); await insertButton.click(); // Floating menu should show up await page.waitForSelector(floatingMenu); await expect(page).toMatchElement(floatingMenu); }); it('should display shapes floating menu', async () => { // Open a shapes tab await expect(page).toClick('#library-tab-shapes'); // Add a shape await expect(page).toClick( 'div[data-testid="shapes-library-pane"] div:first-child' ); // Floating menu should show up await page.waitForSelector(floatingMenu); await expect(page).toMatchElement(floatingMenu); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/fontCheck.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, editStoryWithTitle, insertStoryTitle, publishStory, takeSnapshot, addCustomFont, removeCustomFont, visitSettings, removeAllFonts, addRequestInterception, addTextElement, } from '@web-stories-wp/e2e-test-utils'; const OPEN_SANS_CONDENSED_LIGHT = 'Open Sans Condensed Light'; const OPEN_SANS_CONDENSED_LIGHT_URL = `${process.env.WP_BASE_URL}/wp-content/e2e-assets/OpenSansCondensed-Light.ttf`; async function changeFont(fontName) { await expect(page).toClick('button[aria-label="Font family"]'); await page.keyboard.type(fontName); await page.waitForResponse( (response) => response.url().includes('web-stories/v1/fonts/?search=') && response.status() === 200 ); await expect(page).toMatchElement('li[role="option"]', { text: fontName, }); await expect(page).toClick('li[role="option"]', { text: fontName, }); await expect(page).toMatchElement('button[aria-label="Font family"]', { text: fontName, }); } async function createStoryWithFont(title, font = OPEN_SANS_CONDENSED_LIGHT) { await createNewStory(); await insertStoryTitle(title); await addTextElement('Title 1'); await changeFont(font); await publishStory(); } async function replaceFontWithFontPicker(fontFamily = '') { await changeFont(fontFamily); await expect(page).toClick('button', { text: 'Replace font' }); await expect(page).toClick('[data-testid="textFrame"]'); } async function replaceFontUsingDefault() { await expect(page).toClick('button', { text: 'Open anyway' }); await expect(page).toClick('[data-testid="textFrame"]'); } async function prepareStoryWithFontCheckDialog(title) { await createStoryWithFont(title); await visitSettings(); await removeCustomFont(OPEN_SANS_CONDENSED_LIGHT); await editStoryWithTitle(title); await page.waitForSelector('[data-testid="textFrame"]'); await page.waitForSelector('[role="dialog"][aria-label="Missing Fonts"]'); await expect(page).toMatchElement('button', { text: 'Open anyway' }); await expect(page).toMatchElement('button', { text: 'Replace font' }); } function isPlatformMacOS() { return page.evaluate(() => { const { platform } = window.navigator; return platform.includes('Mac') || ['iPad', 'iPhone'].includes(platform); }); } async function toggleDevTools() { const areDevToolsOpen = Boolean(await page.$('#web-stories-editor textarea')); // Cancel whatever current action to ensure the below shortcut works. await page.keyboard.press('Escape'); await page.click('#wpadminbar'); await page.click('[aria-label="Web Stories Editor"]'); const isMacOS = await isPlatformMacOS(); const Meta = isMacOS ? 'Meta' : 'Control'; await page.keyboard.down(Meta); await page.keyboard.down('Shift'); await page.keyboard.down('Alt'); await page.keyboard.down('J'); await page.keyboard.up('J'); await page.keyboard.up('Alt'); await page.keyboard.up('Shift'); await page.keyboard.down(Meta); // Dev Tools were not open before, but now they should be open. if (!areDevToolsOpen) { await page.waitForSelector('#web-stories-editor textarea'); } } async function getCurrentStoryData() { await toggleDevTools(); const textareaContent = await page.evaluate( () => document.querySelector('#web-stories-editor textarea').value ); await toggleDevTools(); return JSON.parse(textareaContent); } jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Fix flakey tests. describe.skip('Font Check', () => { beforeEach(async () => { await visitSettings(); await removeAllFonts(); await addCustomFont(OPEN_SANS_CONDENSED_LIGHT_URL); }); afterEach(async () => { await visitSettings(); await removeAllFonts(); }); it('should show dialog & replace font with default font', async () => { const title = 'Test replace missing font with (default) Roboto'; const replacementFont = 'Roboto'; await prepareStoryWithFontCheckDialog(title); await takeSnapshot(page, 'Missing fonts dialog'); await replaceFontUsingDefault(replacementFont); await expect(page).toMatchElement('button[aria-label="Font family"]', { text: replacementFont, }); }); it('should show dialog & replace it with selected font', async () => { const title = 'Test replace missing font with Bungee'; const replacementFont = 'Bungee'; await prepareStoryWithFontCheckDialog(title); await replaceFontWithFontPicker(replacementFont); await expect(page).toClick('[data-testid="textFrame"]'); await expect(page).toMatchElement('button[aria-label="Font family"]', { text: replacementFont, }); }); it('should show dialog & visit settings page', async () => { const title = 'Test visit to settings from dialog'; await prepareStoryWithFontCheckDialog(title); await Promise.all([ expect(page).toClick('[role="dialog"][aria-label="Missing Fonts"] a', { text: 'Settings', }), page.waitForNavigation(), ]); await expect(page).toMatchElement('h2', { text: 'Settings' }); }); it('should show dialog & visit dashboard page', async () => { const title = 'Test back to dashboard from dialog'; await prepareStoryWithFontCheckDialog(title); await Promise.all([ expect(page).toClick('a', { text: 'Back to dashboard' }), page.waitForNavigation(), ]); await expect(page).toMatchElement('h2', { text: 'Dashboard' }); }); it('should redirect to dashboard when clicking outside dialog', async () => { const title = 'Test back to dashboard from dialog'; await prepareStoryWithFontCheckDialog(title); // click outside the dialog await Promise.all([page.mouse.click(100, 100), page.waitForNavigation()]); await expect(page).toMatchElement('h2', { text: 'Dashboard' }); }); it('should redirect to dashboard when pressing ESC', async () => { const title = 'Test back to dashboard from dialog'; await prepareStoryWithFontCheckDialog(title); await Promise.all([ page.keyboard.press('Escape'), page.waitForNavigation(), ]); await expect(page).toMatchElement('h2', { text: 'Dashboard' }); }); it('should receive updated font metrics and not alter history', async () => { const storyTitle = 'Font Check Metrics'; await createStoryWithFont(storyTitle, 'Rock Salt'); const storyData = await getCurrentStoryData(); const fontBefore = storyData.pages[0].elements[1].font; expect(fontBefore.family).toBe('Rock Salt'); expect(fontBefore.fallbacks).toIncludeAllMembers(['cursive']); expect(fontBefore.metrics.upm).toBe(1024); const mockResponse = { status: 200, contentType: 'application/json', body: JSON.stringify([ { family: 'Rock Salt', fallbacks: ['sans-serif'], // Original is "cursive" weights: [900], styles: ['italic'], variants: [[0, 400]], service: 'fonts.google.com', metrics: { upm: 200, // Original is 1024 asc: 1623, des: -788, tAsc: 824, tDes: -240, tLGap: 63, wAsc: 1623, wDes: 788, xH: 833, capH: 1154, yMin: -787, yMax: 1623, hAsc: 1623, hDes: -788, lGap: 32, }, }, ]), }; await page.setRequestInterception(true); const stopRequestInterception = addRequestInterception((request) => { // eslint-disable-next-line jest/no-conditional-in-test if (request.url().includes('web-stories/v1/fonts') && mockResponse) { request.respond(mockResponse); return; } request.continue(); }); await Promise.all([ page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test -- False positive. response.url().includes('web-stories/v1/fonts') && response.status() === 200 ), page.reload(), ]); stopRequestInterception(); await page.setRequestInterception(false); const newStoryData = await getCurrentStoryData(); const fontAfter = newStoryData.pages[0].elements[1].font; expect(fontAfter.family).toBe('Rock Salt'); expect(fontAfter.fallbacks).toIncludeAllMembers(['sans-serif']); expect(fontAfter.metrics.upm).toBe(200); await expect(page).toMatchElement( 'button[aria-label="Undo Changes"]:disabled' ); await expect(page).toMatchElement( 'button[aria-label="Redo Changes"]:disabled' ); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/hotlinking.js ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, takeSnapshot, withPlugin, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { addAllowedErrorMessage } from '../../../config/bootstrap'; const IMAGE_URL_LOCAL = `${process.env.WP_BASE_URL}/wp-content/e2e-assets/example-3.png`; const IMAGE_URL_CORS_PROXY = 'https://wp.stories.google/e2e-tests/example.jpg'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Media Hotlinking', () => { withPlugin('e2e-tests-hotlink'); let removeMessage1; let removeMessage2; beforeAll(() => { // Ignore CORS error, this is present in the test by design. removeMessage1 = addAllowedErrorMessage('has been blocked by CORS policy'); // Ignore resource failing to load. This is only present because of the CORS error. removeMessage2 = addAllowedErrorMessage('Failed to load resource'); }); afterAll(() => { removeMessage1(); removeMessage2(); }); // Uses the existence of the element's frame element as an indicator for successful insertion. it('should insert a local image', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Insert by link"]'); await page.waitForSelector('[role="dialog"]'); await expect(page).toMatchTextContent('Insert external image or video'); await page.type('input[type="url"]', IMAGE_URL_LOCAL); await expect(page).toMatchElement( '[role="dialog"] button:not([disabled])', { text: 'Insert', } ); await Promise.all([ expect(page).toClick('[role="dialog"] button', { text: 'Insert', }), page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test -- False positive. response.url().includes('web-stories/v1/hotlink/validate') && response.status() === 200 ), ]); // Dialog should disappear by now. await expect(page).not.toMatchTextContent('Insert external image or video'); await expect(page).toMatchElement(`img[src="${IMAGE_URL_LOCAL}"]`); }); it('should insert an external image via proxy', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Insert by link"]'); await page.waitForSelector('[role="dialog"]'); await expect(page).toMatchTextContent('Insert external image or video'); await page.type('input[type="url"]', IMAGE_URL_CORS_PROXY); await expect(page).toMatchElement( '[role="dialog"] button:not([disabled])', { text: 'Insert', } ); await Promise.all([ expect(page).toClick('[role="dialog"] button', { text: 'Insert', }), page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test -- False positive. response.url().includes('web-stories/v1/hotlink/validate') && response.status() === 200 ), ]); // Dialog should disappear by now. await expect(page).not.toMatchTextContent('Insert external image or video'); await page.waitForSelector( '[aria-label="Design options for selected element"]' ); await expect(page).toMatchElement( 'img[src*="/web-stories/v1/hotlink/proxy/"]' ); await expect(page).not.toMatchElement(`img[src="${IMAGE_URL_CORS_PROXY}"]`); await takeSnapshot(page, 'Media Hotlinking - with CORS'); }); describe('Story Poster', () => { it('should insert an external poster via proxy', async () => { await createNewStory(); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toMatchElement('button[aria-label="Poster image"]'); await expect(page).toClick('button[aria-label="Poster image"]'); await expect(page).toClick('[role="menuitem"]', { text: 'Link to a file', }); await page.waitForSelector('[role="dialog"]'); await expect(page).toMatchTextContent( 'Use external image as poster image' ); await page.type('input[type="url"]', IMAGE_URL_LOCAL); await expect(page).toMatchElement( '[role="dialog"] button:not([disabled])', { text: 'Use image as poster image', } ); await expect(page).toClick('[role="dialog"] button', { text: 'Use image as poster image', }); await expect(page).toMatchElement(`img[src="${IMAGE_URL_LOCAL}"]`); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/insert3PMedia.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, withPlugin, clearLocalStorage, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { addAllowedErrorMessage } from '../../../config/bootstrap'; const media3pSelector = '#library-tab-media3p'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); async function goToMedia3PTab() { await expect(page).toClick('#library-tab-media3p'); await expect(page).toMatchTextContent( 'Your use of stock content is subject to third party terms' ); await expect(page).toClick('button', { text: 'Dismiss' }); await expect(page).not.toMatchTextContent( 'Your use of stock content is subject to third party terms' ); } describe('Inserting 3P Media', () => { let removeErrorMessage; beforeAll(() => { // Coverr videos served from stream.mux.com don't have any CORS headers. removeErrorMessage = addAllowedErrorMessage( 'NotSameOriginAfterDefaultedToSameOriginByCoep' ); }); afterAll(() => { removeErrorMessage(); }); it('should insert an Unsplash image', async () => { await createNewStory(); await clearLocalStorage(); await goToMedia3PTab(); await expect(page).toMatchElement('button', { text: 'Image' }); await expect(page).toClick('button', { text: 'Image' }); await page.waitForSelector( '#library-pane-media3p [data-testid="mediaElement-image"]' ); // Clicking will only act on the first element. await expect(page).toClick( '#library-pane-media3p [data-testid="mediaElement-image"]' ); const insertButton = await page.waitForSelector( `xpath/.//li//span[contains(text(), 'Insert image')]` ); await insertButton.click(); await page.waitForSelector('[data-testid="imageElement"]'); await expect(page).toMatchElement('[data-testid="imageElement"]'); }); it('should insert a Coverr video', async () => { await createNewStory(); await clearLocalStorage(); await goToMedia3PTab(); await expect(page).toClick('[role="tablist"] [role="tab"] ', { text: 'Video', }); await page.waitForSelector( '#library-pane-media3p [data-testid="mediaElement-video"]' ); // This will click in the center of the element, opening the "+" insertion menu. await expect(page).toClick( '#library-pane-media3p [data-testid="mediaElement-video"]' ); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert video', }); await page.waitForSelector('[data-testid="videoElement"]'); await expect(page).toMatchElement('[data-testid="videoElement"]'); }); it('should insert a Tenor GIF', async () => { await createNewStory(); await clearLocalStorage(); await goToMedia3PTab(); await expect(page).toClick('[role="tablist"] [role="tab"] ', { text: 'GIFs', }); await page.waitForSelector( '#library-pane-media3p [data-testid="mediaElement-gif"]' ); // This will click in the center of the element, opening the "+" insertion menu. await expect(page).toClick( '#library-pane-media3p [data-testid="mediaElement-gif"]' ); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); await page.waitForSelector('[data-testid="videoElement"]'); await expect(page).toMatchElement('[data-testid="videoElement"]'); }); it('should insert a Tenor sticker', async () => { await createNewStory(); await clearLocalStorage(); await goToMedia3PTab(); await expect(page).toClick('[role="tablist"] [role="tab"] ', { text: 'Stickers', }); await page.waitForSelector( '#library-pane-media3p [data-testid="mediaElement-image"]' ); // This will click in the center of the element, opening the "+" insertion menu. await expect(page).toClick( '#library-pane-media3p [data-testid="mediaElement-image"]' ); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); await page.waitForSelector('[data-testid="imageElement"]'); await expect(page).toMatchElement('[data-testid="imageElement"]'); }); describe('Disabled', () => { withPlugin('e2e-tests-disable-3p-media'); it('should not render 3p media tab', async () => { await createNewStory(); await expect(page).not.toMatchElement(media3pSelector); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/insertMediaFromDialog.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, uploadMedia, deleteMedia, withUser, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Inserting Media from Dialog', () => { it('should insert an image by clicking on it', async () => { await createNewStory(); const filename = await uploadMedia('example-1.jpg', false); await expect(page).toClick('button', { text: 'Insert into page' }); await expect(page).toMatchElement('[data-testid="imageElement"]'); await deleteMedia(filename); }); describe('Contributor User', () => { withUser('contributor', 'password'); it('should display permission error dialog', async () => { await createNewStory(); await expect(page).toMatchTextContent('Howdy, contributor'); await expect(page).not.toMatchElement('button[aria-label="Upload"]'); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/insertMediaFromLibrary.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, deleteMedia, uploadMedia, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Inserting Media from Media Library', () => { let uploadedFiles; beforeEach(() => (uploadedFiles = [])); afterEach(async () => { for (const file of uploadedFiles) { // eslint-disable-next-line no-await-in-loop await deleteMedia(file); } }); it('should insert an image by clicking on it', async () => { await createNewStory(); const filename = await uploadMedia('example-1.jpg', true); uploadedFiles.push(filename); await page.waitForSelector('[data-testid="mediaElement-image"]'); // This will click in the center of the element, opening the "+" insertion menu. await expect(page).toClick('[data-testid="mediaElement-image"]'); await expect(page).toClick('[role="menu"] [role="menuitem"]', { text: 'Insert image', }); await page.waitForSelector('[data-testid="imageElement"]'); await expect(page).toMatchElement('[data-testid="imageElement"]'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/insertMovVideo.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, uploadFile, deleteMedia, toggleVideoOptimization, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Inserting MOV video', () => { let uploadedFiles; beforeEach(() => (uploadedFiles = [])); afterEach(async () => { for (const file of uploadedFiles) { // eslint-disable-next-line no-await-in-loop await deleteMedia(file); } }); it('should insert video via media modal', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Upload"]'); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('small-video.mov', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Insert into page' }); await page.waitForSelector('[data-testid="videoElement"]', { visible: false, }); await expect(page).toMatchElement('[data-testid="videoElement"]', { visible: false, }); }); describe('Inserting .mov from dialog', () => { beforeEach(async () => { await toggleVideoOptimization(); }); afterEach(async () => { await toggleVideoOptimization(); }); // Uses the existence of the element's frame element as an indicator for successful insertion. it('should not list the .mov', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Upload"]'); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile('small-video.mov', false); uploadedFiles.push(fileName); await expect(page).toClick( '.attachments-browser .attachments .attachment:first-of-type' ); await expect(page).not.toMatchElement('.type-video.subtype-quicktime'); await page.keyboard.press('Escape'); await page.waitForSelector('.media-modal', { visible: false, }); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/insertWebMVideo.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, previewStory, insertStoryTitle, uploadMedia, deleteMedia, uploadFile, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Inserting WebM Video', () => { let uploadedFiles; beforeEach(() => (uploadedFiles = [])); afterEach(async () => { for (const file of uploadedFiles) { // eslint-disable-next-line no-await-in-loop await deleteMedia(file); } }); async function openPanel(name) { await expect(page).toClick('li', { text: /^Style$/ }); // Open the panel. const panel = await page.$(`button[aria-label="${name}"]`); const isCollapsed = await page.evaluate( (button) => button?.getAttribute('aria-expanded') === 'false', panel ); if (isCollapsed) { await panel.click(); } } async function openA11yPanel() { // Open the Accessibility panel. await openPanel('Accessibility'); } it('should insert a video via media modal', async () => { await createNewStory(); const fileName = await uploadMedia('small-video.webm', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Insert into page' }); await expect(page).toMatchElement('[data-testid="videoElement"]'); // Wait for poster image (inside Accessibility panel) to appear. await openA11yPanel(); await page.waitForSelector('[alt="Preview poster image"]'); await expect(page).toMatchElement('[alt="Preview poster image"]'); }); it('should insert a video via media modal and add captions', async () => { await createNewStory(); const fileName = await uploadMedia('small-video.webm', false); uploadedFiles.push(fileName); await expect(page).toClick('button', { text: 'Insert into page' }); await expect(page).toMatchElement('[data-testid="videoElement"]'); await openPanel('Caption and Subtitles'); await expect(page).toClick('button', { text: 'Upload a file' }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileNameCaptions = await uploadFile('test.vtt'); uploadedFiles.push(fileNameCaptions); await expect(page).toClick('button', { text: 'Select caption' }); await expect(page).toMatchTextContent('test.vtt'); }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. it.skip('should insert a video via media library', async () => { await createNewStory(); const fileName = await uploadMedia('small-video.webm'); uploadedFiles.push(fileName); await page.waitForSelector( `[data-testid="mediaElement-video"] [src*="${fileName}"]` ); // Clicking will only act on the first element. await expect(page).toClick('[data-testid="mediaElement-video"]'); const insertButton = await page.waitForSelector( `xpath/.//li//span[contains(text(), 'Insert video')]` ); await insertButton.click(); await page.waitForSelector('[data-testid="videoElement"]'); await expect(page).toMatchElement('[data-testid="videoElement"]'); // Wait for poster image (inside Accessibility panel) to appear. await openA11yPanel(); await page.waitForSelector('[alt="Preview poster image"]'); await expect(page).toMatchElement('[alt="Preview poster image"]'); }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. it.skip('should insert a video via media library and preview on FE', async () => { await createNewStory(); await insertStoryTitle('Publishing with video'); const fileName = await uploadMedia('small-video.webm'); uploadedFiles.push(fileName); await page.waitForSelector( `[data-testid="mediaElement-video"] [src*="${fileName}"]` ); // Clicking will only act on the first element. await expect(page).toClick('[data-testid="mediaElement-video"]'); const insertButton = await page.waitForSelector( `xpath/.//li//span[contains(text(), 'Insert video')]` ); await insertButton.click(); await page.waitForSelector('[data-testid="videoElement"]'); await expect(page).toMatchElement('[data-testid="videoElement"]'); // Wait for poster image (inside Accessibility panel) to appear. await openA11yPanel(); await page.waitForSelector('[alt="Preview poster image"]'); await expect(page).toMatchElement('[alt="Preview poster image"]'); const editorPage = page; const previewPage = await previewStory(); await expect(previewPage).toMatchElement('amp-video'); const poster = await previewPage.evaluate((selector) => { return document.querySelector(selector).getAttribute('poster'); }, 'amp-video'); expect(poster).not.toBeNull(); expect(poster).toStrictEqual(expect.any(String)); expect(poster).not.toBe(''); await editorPage.bringToFront(); await previewPage.close(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/media/svg.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, withExperimentalFeatures, createNewStory, uploadMedia, deleteMedia, } from '@web-stories-wp/e2e-test-utils'; const MODAL = '.media-modal'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('SVG', () => { withExperimentalFeatures(['enableSVG']); it('should insert an existing SVG from media dialog', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Upload"]'); await expect(page).toMatchTextContent('Upload to Story'); await expect(page).toClick('button', { text: 'Media Library' }); await expect(page).toClick( '.attachments-browser .attachments .attachment[aria-label="video-play"]' ); await expect(page).toClick('button', { text: 'Insert into page' }); await expect(page).toMatchElement('[data-testid="imageElement"]'); await takeSnapshot(page, 'Inserting SVG from Dialog'); }); it('should upload an SVG file via media dialog', async () => { await createNewStory(); const filename = await uploadMedia('close.svg', false); await expect(page).toClick('button', { text: 'Insert into page' }); await expect(page).toMatchElement('[data-testid="imageElement"]'); await takeSnapshot(page, 'Uploading SVG to editor'); await deleteMedia(filename); }); it('should not allow selecting an SVG file as publisher logo', async () => { await createNewStory(); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toClick('[aria-label="Publisher Logo"]'); await expect(page).toClick('[aria-label="Add new"]'); await page.waitForSelector(MODAL, { visible: true, }); await expect(page).toMatchTextContent('Select as publisher logo'); await expect(page).toClick('button', { text: 'Media Library' }); await expect(page).not.toMatchElement( '.attachments-browser .attachments .attachment[aria-label="video-play"]' ); await page.keyboard.press('Escape'); await page.waitForSelector(MODAL, { visible: false, }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/metaBoxes.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, createNewStory, publishStory, withPlugin, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Custom Meta Boxes', () => { describe('Unavailable', () => { it('should not display button to toggle meta boxes', async () => { await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await expect(page).not.toMatchElement( '[aria-label="Third-Party Meta Boxes"]' ); }); }); describe('Available', () => { withPlugin('web-stories-test-plugin-meta-box'); it('should display meta boxes and save their content', async () => { await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await page.type('input[placeholder="Add title"]', 'Meta Box Test'); await expect(page).not.toMatchElement( '#web-stories-editor #web_stories_test_meta_box_field', { visible: false, } ); await expect(page).toClick('[aria-label="Third-Party Meta Boxes"]'); await expect(page).toMatchElement( '#web-stories-editor #web_stories_test_meta_box_field', { visible: false, } ); await page.type( '#web_stories_test_meta_box_field', 'Meta Box Test Value' ); await takeSnapshot(page, 'Custom Meta Boxes'); // Verify that collapsing works via postbox.js from WordPress. await expect(page).toClick('button.handlediv[aria-expanded="true"]'); await expect(page).toMatchElement( 'button.handlediv[aria-expanded="false"]' ); await expect(page).toClick('button.handlediv[aria-expanded="false"]'); await expect(page).toMatchElement( 'button.handlediv[aria-expanded="true"]' ); await publishStory(); // Refresh page to verify that the text has been persisted. await page.reload(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await expect(page).not.toMatchElement( '#web-stories-editor #web_stories_test_meta_box_field', { visible: false, } ); await expect(page).toClick('[aria-label="Third-Party Meta Boxes"]'); await expect(page).toMatchElement( '#web-stories-editor #web_stories_test_meta_box_field', { visible: false, } ); const metaBoxValue = await page.evaluate( () => document.getElementById('web_stories_test_meta_box_field').value ); await expect(metaBoxValue).toBe('Meta Box Test Value'); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/noJS.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, createNewStory } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Story Editor with disabled JavaScript', () => { it('should display error message', async () => { // Disable javascript for test. await page.setJavaScriptEnabled(false); await createNewStory(); await expect(page).toMatchElement('.web-stories-wp-no-js'); // Re-enable javascript for snapshots. await page.setJavaScriptEnabled(true); await takeSnapshot(page, 'Editor no js'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/pageTemplates.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { takeSnapshot, addTextElement, clearLocalStorage, createNewStory, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Page Templates', () => { beforeAll(async () => { // force to load default templates in the page template pane. await clearLocalStorage(); }); it('should be able to load an create custom page templates', async () => { await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); // Use keyboard to open Page Templates panel. await page.focus('ul[aria-label="Element Library Selection"] li'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Enter'); await expect(page).toMatchElement('button[aria-disabled="true"]', { text: 'Save current page as template', }); await expect(page).toMatchElement( 'button[aria-label="Select templates type"]' ); await expect(page).toMatchTextContent('Default templates'); await addTextElement(); // Go back to the templates panel. await page.click('#library-tab-pageTemplates'); await expect(page).toMatchElement('button[aria-disabled="false"]', { text: 'Save current page as template', }); await expect(page).toClick('button', { text: 'Save current page as template', }); await page.waitForSelector('[role="dialog"]'); // Add a name for the template await page.type('input[placeholder="Untitled"]', 'Test template'); // Close dialog await page.keyboard.press('Enter'); // Adding a custom page template automatically switches to the "Saved Templates" view. await expect(page).toMatchTextContent('Page Template saved.'); await expect(page).toMatchTextContent('Saved templates'); await expect(page).toMatchElement( '[aria-label="Page Template Options"] [role="listitem"]' ); await takeSnapshot(page, 'Page Templates'); }); it('should be able search default page templates', async () => { await createNewStory(); await page.click('#library-tab-pageTemplates'); await page.click('[aria-label="Search"]'); await page.keyboard.type('baking'); await page.keyboard.press('Enter'); // Fresh & Bright Cover template should not be on the page await expect(page).not.toMatchElement( 'button[aria-label="Fresh & Bright Cover"]' ); await takeSnapshot(page, 'Search Default Page Templates'); await page.click('[aria-label="Clear Search"]'); // Fresh & Bright Cover template should be on the page await expect(page).toMatchElement( 'button[aria-label="Fresh & Bright Cover"]' ); }); it('should be able search saved page templates', async () => { await createNewStory(); await page.click('#library-tab-pageTemplates'); await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); // Use keyboard to open Page Templates panel. await page.focus('ul[aria-label="Element Library Selection"] li'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Enter'); await expect(page).toMatchElement('button[aria-disabled="true"]', { text: 'Save current page as template', }); await expect(page).toMatchElement( 'button[aria-label="Select templates type"]' ); await expect(page).toMatchTextContent('Default templates'); await addTextElement(); // Go back to the templates panel. await page.click('#library-tab-pageTemplates'); await expect(page).toMatchElement('button[aria-disabled="false"]', { text: 'Save current page as template', }); await expect(page).toClick('button', { text: 'Save current page as template', }); await page.waitForSelector('[role="dialog"]'); // Add a name for the template await page.type('input[placeholder="Untitled"]', 'Test template'); // Close dialog await page.keyboard.press('Enter'); await page.click('[aria-label="Search"]'); await page.keyboard.type('Test Template'); await page.keyboard.press('Enter'); await expect(page).toMatchElement('[aria-label="Test template"]'); }); it('should be able rename saved page templates', async () => { await createNewStory(); await page.click('#library-tab-pageTemplates'); await createNewStory(); await expect(page).toMatchElement('input[placeholder="Add title"]'); // Use keyboard to open Page Templates panel. await page.focus('ul[aria-label="Element Library Selection"] li'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Enter'); await expect(page).toMatchElement('button[aria-disabled="true"]', { text: 'Save current page as template', }); await expect(page).toMatchElement( 'button[aria-label="Select templates type"]' ); await expect(page).toMatchTextContent('Default templates'); await addTextElement(); // Go back to the templates panel. await page.click('#library-tab-pageTemplates'); await expect(page).toMatchElement('button[aria-disabled="false"]', { text: 'Save current page as template', }); await expect(page).toClick('button', { text: 'Save current page as template', }); await page.waitForSelector('[role="dialog"]'); // Add a name for the template await page.type('input[placeholder="Untitled"]', 'template name'); await page.keyboard.press('Enter'); await expect(page).toMatchElement( '[aria-label="template name"][role="listitem"]' ); await page.hover('[aria-label="template name"][role="listitem"]'); await page.click('[aria-label="More"]'); await page.click('li[role="menuitem"]'); await page.click('input[placeholder="template name"]'); await page.keyboard.press('Backspace'); await page.keyboard.type('new template name'); await page.keyboard.press('Enter'); await expect(page).toMatchElement( '[aria-label="new template name"][role="listitem"]' ); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/passwordProtected.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { addTextElement, createNewStory, insertStoryTitle, previewStory, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { addAllowedErrorMessage } from '../../config/bootstrap'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Password protected stories', () => { let removeErrorMessage; beforeAll(() => { // TODO: Address 404 caused by AMP validation being called for protected post. removeErrorMessage = addAllowedErrorMessage( 'the server responded with a status of 404' ); }); afterAll(() => { removeErrorMessage(); }); it('should display password form on frontend', async () => { await createNewStory(); await insertStoryTitle('Password protected story'); await addTextElement(); await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toClick('button', { text: 'Public' }); await expect(page).toClick('li[role="option"]', { text: /^Password Protected/, }); await expect(page).toMatchElement('input[placeholder="Enter a password"]'); await page.type('input[placeholder="Enter a password"]', 'password'); const editorPage = page; const previewPage = await previewStory(false); await expect(previewPage).toMatchTextContent( 'Protected: Password protected story' ); await expect(previewPage).not.toMatchTextContent('Page not found'); await previewPage.waitForSelector('input[name="post_password"]'); await previewPage.focus('input[name="post_password"]'); await expect(previewPage).toMatchTextContent( /This content is password[- ]protected/ ); await expect(previewPage).toFill('input[name="post_password"]', 'password'); // Submitting the form will cause the page to be reloaded. await Promise.all([ previewPage.waitForNavigation(), previewPage.keyboard.press('Enter'), ]); await editorPage.bringToFront(); await previewPage.close(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/pendingStories.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, getCurrentUser, insertStoryTitle, setCurrentUser, editStoryWithTitle, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. describe.skip('Pending Stories', () => { let currentUser; beforeAll(() => { currentUser = getCurrentUser(); }); //eslint-disable-next-line require-await afterAll(async () => setCurrentUser(currentUser.username, currentUser.password) ); it('should be able to publish a pending story', async () => { await setCurrentUser('contributor', 'password'); await createNewStory(); await insertStoryTitle('Submitting for review'); // Contributor can only save or submit for review. await expect(page).toMatchElement('button[aria-label="Save draft"]'); await expect(page).toMatchElement('button', { text: 'Submit for review', }); await expect(page).toClick('button', { text: 'Submit for review' }); // Contributor is now in story details modal await expect(page).toMatchElement('div[aria-label="Story details"]'); await expect(page).toClick('div[aria-label="Story details"] button', { text: 'Submit for review', }); // Modal closes await expect(page).toMatchElement('button:not([disabled]', { text: 'Submit for review', }); await expect(page).toMatchElement('button[aria-label="Switch to Draft"]'); await setCurrentUser('admin', 'password'); await editStoryWithTitle('Submitting for review'); // An admin can take save a pending story (so it stays pending), switch to draft, // or publish. await expect(page).toMatchElement('button[aria-label="Switch to Draft"]'); await expect(page).toMatchElement('button[aria-label="Save as pending"]'); await expect(page).toMatchElement('button', { text: /^Publish$/ }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/prePublishChecklist/adminUser.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, publishStory, triggerHighPriorityChecklistSection, takeSnapshot, addTextElement, insertStoryTitle, uploadFile, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); async function uploadPosterImage(file) { await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toClick('button[aria-label="Poster image"]'); await expect(page).toClick('[role="menuitem"]', { text: 'Upload a file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toClick('.media-modal #menu-item-upload', { text: 'Upload files', visible: true, }); const fileName = await uploadFile(file, false); await expect(page).toClick('button', { text: 'Select as poster image', visible: true, }); await expect(page).toClick('button', { text: 'Crop image', visible: true, }); await page.keyboard.press('Escape'); await page.waitForSelector('.media-modal', { visible: false, }); await page.waitForSelector('[alt="Preview image"]'); await expect(page).toMatchElement('[alt="Preview image"]'); return fileName; } describe('Pre-Publish Checklist : Admin User', () => { const addNewPage = async () => { await expect(page).toClick('button[aria-label="Add New Page"]'); await addTextElement(); }; const addPages = async (number) => { for (let i = 0; i < number; i++) { // eslint-disable-next-line no-await-in-loop await addNewPage(); } }; beforeEach(async () => { await createNewStory(); }); it('should show the checklist', async () => { await expect(page).toClick('button[aria-label="Checklist"]'); await triggerHighPriorityChecklistSection(); await expect(page).toMatchElement( '#pre-publish-checklist[data-isexpanded="true"]' ); }); it('should show that there is no poster attached to the story', async () => { await expect(page).toClick('[data-testid^="mediaElement"]'); const insertButton = await page.waitForSelector( `xpath/.//li//span[contains(text(), 'Insert image')]` ); await insertButton.click(); await expect(page).toMatchElement('[data-testid="imageElement"]'); await insertStoryTitle( 'Prepublish Checklist - admin - missing poster warning' ); await publishStory(); await page.reload(); await expect(page).toMatchElement('input[placeholder="Add title"]'); await expect(page).toClick('button[aria-label^="Checklist: "]'); await expect(page).toMatchElement( '#pre-publish-checklist[data-isexpanded="true"]' ); await expect(page).toMatchTextContent('Add poster image'); await takeSnapshot(page, 'Pre-publish Checklist'); }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Fix flakey test. it.skip('should show cards related to poster image issues', async () => { await addTextElement(); await addPages(3); await expect(page).toClick('button', { text: 'Publish' }); await expect(page).toClick( 'div[aria-label="Story details"] button[aria-label^="Checklist"]' ); await expect(page).toMatchTextContent('Add poster image'); await expect(page).toClick('p', { text: 'Document' }); //find publish panel button const publishPanelButton = await expect(page).toMatchElement( '#sidebar-tab-document button', { text: 'Publishing' } ); const isPublishPanelExpanded = await publishPanelButton.evaluate( (node) => node.getAttribute('aria-expanded') === 'true' ); //open publish panel if not open //eslint-disable-next-line jest/no-conditional-in-test if (!isPublishPanelExpanded) { await publishPanelButton.click(); } await uploadPosterImage('example-1.jpg'); await expect(page).not.toMatchTextContent('Add poster image'); }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Fix flakey test. it.skip('should focus on media button when poster image issue card is clicked', async () => { await addTextElement(); await addPages(3); await expect(page).toClick('button', { text: 'Publish' }); await expect(page).toClick( 'div[aria-label="Story details"] button[aria-label^="Checklist"]' ); const title = await expect(page).toMatchElement('h2', { text: 'Add poster image', }); const button = await title.evaluateHandle((node) => node.parentNode); expect(button).toBeDefined(); await button.click(); const isMediaPickerInFocus = await page.evaluate( () => document.activeElement.getAttribute('aria-label') === 'Poster image' ); expect(isMediaPickerInFocus).toBeTrue(); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/prePublishChecklist/contributorUser.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, withUser } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); // eslint-disable-next-line jest/no-disabled-tests -- TODO: Needs investigation. describe.skip('Pre-Publish Checklist : Contributor User', () => { withUser('contributor', 'password'); it('should not show messages user does not have permission for anyway', async () => { await createNewStory(); await expect(page).toClick('button[aria-label="Checklist"]'); await expect(page).toMatchElement( '#pre-publish-checklist[data-isexpanded="true"]' ); // verify no issues are present await expect(page).toMatchElement('p', { text: 'You are all set for now. Return to this checklist as you build your Web Story for tips on how to improve it.', }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/publishPanel/adminUser.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Publish panel in document tab', () => { const openPublishPanel = async () => { await expect(page).toClick('p', { text: 'Document' }); //find publish panel button const publishPanelButton = await expect(page).toMatchElement( '#sidebar-tab-document button', { text: 'Publishing' } ); const isPublishPanelExpanded = await publishPanelButton.evaluate( (node) => node.getAttribute('aria-expanded') === 'true' ); //open publish panel if not open if (!isPublishPanelExpanded) { await publishPanelButton.click(); } }; beforeEach(async () => { await createNewStory(); await openPublishPanel(); }); it('should allow changing author', async () => { await expect(page).toClick('button[aria-label="Author"]', { text: 'admin', }); await expect(page).toMatchElement('[aria-label="Option List Selector"]', { text: 'author', }); await expect(page).toClick(' [aria-label="Option List Selector"] li', { text: 'author', }); await expect(page).toMatchElement('button[aria-label="Author"]', { text: 'author', }); }); it('should allow searching author', async () => { const authorDropDownButton = await expect(page).toMatchElement( 'button[aria-label="Author"]' ); await expect(authorDropDownButton).toMatchTextContent('admin'); await authorDropDownButton.click(); const authorDropDownOptions = await expect(page).toMatchElement( '[aria-label="Option List Selector"]' ); const optionListBeforeSearch = await authorDropDownOptions.$$eval( 'li[role="option"]', (nodeList) => nodeList.map((node) => node.innerText) ); expect(optionListBeforeSearch).toBeDefined(); expect(optionListBeforeSearch.length).toBeGreaterThan(1); await page.keyboard.type('auth'); // wait for search results await page.waitForResponse( (response) => //eslint-disable-next-line jest/no-conditional-in-test response.url().includes('web-stories/v1/users') && response.url().includes('search=auth') && response.status() === 200 ); // add small delay after we have results // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 400)); const optionListAfterSearch = await authorDropDownOptions.$$eval( 'li[role="option"]', (nodeList) => nodeList.map((node) => node.innerText) ); expect(optionListAfterSearch).toHaveLength(1); await expect(authorDropDownOptions).toClick('li', { text: 'author' }); await expect(authorDropDownButton).toMatchTextContent('author'); }); }); ================================================ FILE: packages/e2e-tests/src/specs/editor/publishingFlow.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { createNewStory, insertStoryTitle, withPlugin, publishStory, loadPostEditor, publishPost, trashAllPosts, } from '@web-stories-wp/e2e-test-utils'; /** * Internal dependencies */ import { addAllowedErrorMessage } from '../../config/bootstrap'; async function addPosterImage() { await expect(page).toClick('li[role="tab"]', { text: 'Document' }); await expect(page).toClick('button[aria-label="Poster image"]'); await expect(page).toClick('[role="menuitem"]', { text: 'Upload a file' }); await page.waitForSelector('.media-modal', { visible: true, }); await expect(page).toMatchElement('.media-toolbar-primary button', { text: 'Select as poster image', }); await expect(page).toClick('button', { text: 'Media Library' }); await expect(page).toClick( '.attachments-browser .attachments .attachment[aria-label="example-3"]' ); await expect(page).toClick('.media-toolbar-primary button', { text: 'Select as poster image', }); await page.waitForSelector('[alt="Preview image"]'); await expect(page).toMatchElement('[alt="Preview image"]'); } jest.retryTimes(3, { logErrorsBeforeRetry: true }); function getEditedPostContent() { return page.evaluate(() => wp.data.select('core/editor').getEditedPostContent() ); } describe('Publishing Flow', () => { let removeCORSErrorMessage; let removeResourceErrorMessage; let removeAMPPreloadErrorMessage; beforeAll(() => { // Ignore CORS errors related to the AMP validator JS. removeCORSErrorMessage = addAllowedErrorMessage( 'has been blocked by CORS policy' ); removeResourceErrorMessage = addAllowedErrorMessage( 'Failed to load resource' ); // From the story embedded in amp-story-player removeAMPPreloadErrorMessage = addAllowedErrorMessage( 'The resource https://cdn.ampproject.org/v0/amp-story-1.0.js was preloaded using link preload' ); }); afterAll(async () => { removeCORSErrorMessage(); removeResourceErrorMessage(); removeAMPPreloadErrorMessage(); await trashAllPosts(); await trashAllPosts('web-story'); }); it('should guide me towards creating a new post to embed my story with poster', async () => { await createNewStory(); await insertStoryTitle('Publishing Flow Test'); await addPosterImage(); await publishStory(false); // Create new post and embed story. await Promise.all([ expect(page).toClick('a', { text: 'Add to new post' }), page.waitForNavigation(), ]); await loadPostEditor(); await expect(getEditedPostContent()).resolves.toMatch( ' `; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Web Stories Block', () => { withPlugin('e2e-tests-embed'); let removeErrorMessage; beforeAll(() => { removeErrorMessage = addAllowedErrorMessage( 'Failed to load resource: the server responded with a status of 404' ); }); afterAll(async () => { removeErrorMessage(); await trashAllPosts(); }); it('should insert a new web stories block', async () => { await createNewPost({ showWelcomeGuide: false, }); await insertBlock(); await page.waitForSelector('.web-stories-block-configuration-panel'); await expect(page).toClick('button', { text: 'Single Story' }); await expect(page).toMatchTextContent( 'Select an existing story from your site, or add one with a URL.' ); await expect(page).toClick('button', { text: 'Insert from URL' }); await page.type( 'input[aria-label="Story URL"]', 'https://wp.stories.google/stories/intro-to-web-stories-storytime/' ); await expect(page).toClick('button[aria-label="Embed"]'); await expect(page).not.toMatchTextContent( 'Sorry, this content could not be embedded.' ); // Wait a little longer for embed REST API request to come back. await page.waitForSelector('amp-story-player'); await expect(page).toMatchElement('amp-story-player'); await expect(page).toMatchTextContent('Embed Settings'); }); it('should insert a new web stories block and select story', async () => { await createNewPost({ showWelcomeGuide: false, }); await insertBlock(); await page.waitForSelector('.web-stories-block-configuration-panel'); await expect(page).toClick('button', { text: 'Selected Stories' }); await expect(page).toClick('button', { text: 'Box Carousel' }); await expect(page).toClick('button', { text: 'Select Stories' }); await page.waitForSelector('.components-modal__screen-overlay'); await expect(page).toMatchElement('.components-modal__screen-overlay'); await page.waitForFunction( () => !document.querySelector('.components-spinner') ); await takeSnapshot(page, 'Story select modal'); }); describe('AMP validation', () => { withDisabledToolbarOnFrontend(); withPlugin('amp'); let removeMessage1; let removeMessage2; beforeAll(() => { // Some CORS errors when trying to load scripts from AMP CDN. removeMessage1 = addAllowedErrorMessage( 'has been blocked by CORS policy' ); removeMessage2 = addAllowedErrorMessage('Failed to load resource'); }); afterAll(() => { removeMessage1(); removeMessage2(); }); // The AMP validator currently emits warning about the "data-ampdevmode" // attribute, which is added by the AMP plugin. // Perhaps because the toolbar is not properly disabled on the frontend? // eslint-disable-next-line jest/no-disabled-tests -- TODO: Revisit later. it.skip('should produce valid AMP when using the AMP plugin', async () => { await createNewPost({ showWelcomeGuide: false, }); await setPostContent(EMBED_BLOCK_CONTENT); const postPermalink = await publishPost(); expect(postPermalink).not.toBeNull(); expect(postPermalink).toStrictEqual(expect.any(String)); //eslint-disable-next-line jest/no-conditional-in-test const ampPostPermaLink = postPermalink.includes('?') ? `${postPermalink}&` : `${postPermalink}?amp`; await expect(ampPostPermaLink).toBeValidAMP(); }); }); }); ================================================ FILE: packages/e2e-tests/src/specs/wordpress/widget.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { deleteWidgets, visitAdminPage, withPlugin, insertWidget, activatePlugin, } from '@web-stories-wp/e2e-test-utils'; jest.retryTimes(3, { logErrorsBeforeRetry: true }); describe('Web Stories Widget', () => { withPlugin('classic-widgets'); beforeEach(async () => { await deleteWidgets(); await activatePlugin('classic-widgets'); }); describe('Widgets Screen', () => { it('should be able to add widget', async () => { await visitAdminPage('widgets.php'); await insertWidget('Web Stories'); await expect(page).toMatchElement( '.widget-liquid-right .web-stories-field-wrapper' ); await page.$eval( '.widget-liquid-right .web-stories-field-wrapper input', (input) => (input.value = '') ); await page.type( '.widget-liquid-right .web-stories-field-wrapper input', 'Test widget' ); await expect(page).toClick( '.widget-liquid-right .widget-control-save:not(:disabled)' ); await page.waitForSelector('.spinner', { visible: false, }); await expect(page).toMatchElement( '.widget-control-close-wrapper .widget-control-close', { text: 'Done', } ); }); }); describe('Customizer', () => { // eslint-disable-next-line jest/no-disabled-tests -- Started failing on WP 6.7, needs investigation. it.skip('should be able to add widget', async () => { await visitAdminPage('customize.php'); await expect(page).toClick('li', { text: 'Widgets' }); await expect(page).toMatchElement('.control-panel-widgets.current-panel'); await expect(page).toMatchElement( '#accordion-section-sidebar-widgets-sidebar-1 .accordion-section-title' ); // The customizer has lots of transition animations. // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 500)); // expect(page).toClick(...) doesn't seem to work. await page.evaluate(() => { document .querySelector( '#accordion-section-sidebar-widgets-sidebar-1 .accordion-section-title' ) .click(); }); // The customizer has lots of transition animations. // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 500)); await expect(page).toClick('button', { text: 'Add a Widget' }); await expect(page).toMatchTextContent('Web Stories'); await page.type('#widgets-search', 'web stories'); await expect(page).toClick("div[class*='web_stories_widget-']"); // The customizer has lots of transition animations. // TODO: Remove and replace with waitForSelector or locator API. await new Promise((r) => setTimeout(r, 500)); await page.$eval( '.web-stories-field-wrapper input', (input) => (input.value = '') ); await page.type('.web-stories-field-wrapper input', 'Test widget'); await page.keyboard.press('Enter'); await page.waitForResponse( (response) => // eslint-disable-next-line jest/no-conditional-in-test response.url().includes('/wp-admin/admin-ajax.php') && response.status() === 200 ); await page.waitForSelector('.spinner', { visible: false, }); await expect(page).toClick('.widget-control-close', { text: 'Done' }); await expect(page).toClick('#save'); await expect(page).toMatchElement('#save[value="Published"]'); await page.waitForResponse( (response) => // eslint-disable-next-line jest/no-conditional-in-test response.url().includes('/wp-admin/admin-ajax.php') && response.status() === 200 ); await page.waitForSelector('.spinner', { visible: false, }); await page.waitForSelector("iframe[title='Site Preview']"); const frameHandle = await page.$("iframe[title='Site Preview']"); const frame = await frameHandle.contentFrame(); expect(frame).not.toBeNull(); await expect(frame).toMatchElement('.web-stories-widget'); }); }); }); ================================================ FILE: packages/e2e-tests/src/utils/constants.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export const monetizationDropdownSelector = 'button[aria-label="Monetization type"]'; export const telemetryCheckboxSelector = 'input[data-testid="telemetry-settings-checkbox"]'; export const videoOptimizationCheckboxSelector = 'input[data-testid="media-optimization-settings-checkbox"]'; export const videoCacheCheckboxSelector = 'input[data-testid="video-cache-settings-checkbox"]'; ================================================ FILE: packages/e2e-tests/src/utils/index.js ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export * from './constants'; ================================================ FILE: packages/element-library/.npmignore ================================================ src ================================================ FILE: packages/element-library/README.md ================================================ # Element Library --- A library of all elements used in story-editor. ================================================ FILE: packages/element-library/package.json ================================================ { "name": "@googleforcreators/element-library", "description": "A library of all elements used in story-editor", "private": false, "version": "0.1.202410011217", "author": { "name": "Google", "url": "https://creators.google/" }, "license": "Apache-2.0", "keywords": [ "web stories" ], "homepage": "https://github.com/GoogleForCreators/web-stories-wp/blob/main/packages/element-library/README.md", "repository": { "type": "git", "url": "https://github.com/GoogleForCreators/web-stories-wp.git", "directory": "packages/element-library" }, "bugs": { "url": "https://github.com/googleforcreators/web-stories-wp/issues" }, "engines": { "node": ">= 16", "npm": ">= 7.3" }, "customExports": { ".": { "default": "./src/index.ts" } }, "main": "dist/index.js", "module": "dist-module/index.js", "types": "dist-types/index.d.ts", "source": "src/index.ts", "publishConfig": { "access": "public" }, "dependencies": { "@googleforcreators/design-system": "*", "@googleforcreators/dom": "*", "@googleforcreators/elements": "*", "@googleforcreators/i18n": "*", "@googleforcreators/masks": "*", "@googleforcreators/media": "*", "@googleforcreators/moveable": "*", "@googleforcreators/patterns": "*", "@googleforcreators/react": "*", "@googleforcreators/rich-text": "*", "@googleforcreators/stickers": "*", "@googleforcreators/transform": "*", "@googleforcreators/units": "*", "classnames": "^2.5.1", "polished": "^4.3.1", "prop-types": "^15.7.2", "react-transition-group": "^4.4.5", "styled-components": "^5.3.11" }, "devDependencies": {} } ================================================ FILE: packages/element-library/src/audioSticker/constants.ts ================================================ /* * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { PanelTypes } from '@googleforcreators/design-system'; /** * Internal dependencies */ import { SHARED_DEFAULT_ATTRIBUTES } from '../shared'; export const hasEditMode = false; export const hasEditModeIfLocked = false; export const hasEditModeMoveable = false; export const editModeGrayout = false; export const hasDesignMenu = true; export const hasDuplicateMenu = false; export const isMedia = false; export const canFlip = true; export const isMaskable = false; export const isAspectAlwaysLocked = true; export const resizeRules = { vertical: false, horizontal: false, diagonal: false, minWidth: 120, minHeight: 120, }; export const defaultAttributes = { ...SHARED_DEFAULT_ATTRIBUTES, size: 'small', sticker: 'headphone-cat', style: 'none', lockDimensions: true, }; export const panels = [PanelTypes.ElementAlignment, PanelTypes.AudioSticker]; ================================================ FILE: packages/element-library/src/audioSticker/display.tsx ================================================ /* * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import type { Element, AudioStickerElement, DisplayProps, } from '@googleforcreators/elements'; /** * Internal dependencies */ import { elementFillContent } from '../shared'; import { AUDIO_STICKER_STYLES, AUDIO_STICKER_LABELS } from '../constants'; interface ElementProps { stickerStyle: keyof typeof AUDIO_STICKER_STYLES; } const Element = styled.img` ${elementFillContent} ${({ stickerStyle }) => AUDIO_STICKER_STYLES[stickerStyle]} `; function AudioStickerDisplay({ element, cdnUrl, }: DisplayProps) { const { width: elementWidth, height: elementHeight, sticker, style, } = element; return ( ); } export default AudioStickerDisplay; ================================================ FILE: packages/element-library/src/audioSticker/icon.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { Icons } from '@googleforcreators/design-system'; const IconContainer = styled.div` height: 21px; width: 21px; overflow: hidden; `; function AudioStickerIcon() { return ( ); } export default AudioStickerIcon; ================================================ FILE: packages/element-library/src/audioSticker/index.ts ================================================ /* * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { default as Display } from './display'; export { default as Output } from './output'; export { default as LayerIcon } from './icon'; export { default as getLayerText } from './layer'; export * from './constants'; ================================================ FILE: packages/element-library/src/audioSticker/layer.tsx ================================================ /* * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { __ } from '@googleforcreators/i18n'; function getAudioStickerLayerText() { return __('Audio Sticker', 'web-stories'); } export default getAudioStickerLayerText; ================================================ FILE: packages/element-library/src/audioSticker/output.tsx ================================================ /* * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { AudioStickerElement, OutputProps, } from '@googleforcreators/elements'; function AudioStickerOutput({ element }: OutputProps) { return (
); } export default AudioStickerOutput; ================================================ FILE: packages/element-library/src/audioSticker/test/output.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import AudioStickerOutput from '../output'; describe('AudioSticker output', () => { it('should produce valid AMP output', async () => { const props = { element: { type: 'audioSticker', id: '123', x: 50, y: 100, height: 1920, width: 1080, rotationAngle: 0, size: 'small', sticker: 'headphone-cat', style: 'none', }, box: { width: 1080, height: 1920, x: 50, y: 100, rotationAngle: 0 }, }; await expect().toBeValidAMPStoryElement(); }); }); ================================================ FILE: packages/element-library/src/constants.ts ================================================ /* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { __ } from '@googleforcreators/i18n'; export const AUDIO_STICKER_STYLES = { none: '', outline: 'border: 4px solid white; border-radius: 20px', dropshadow: 'filter: drop-shadow(2px 2px 10px white)', }; export const AUDIO_STICKER_LABELS = { 'headphone-cat': { label: __('Headphone Cat', 'web-stories'), }, 'tape-player': { label: __('Tape Player', 'web-stories'), }, 'loud-speaker': { label: __('Loud Speaker', 'web-stories'), }, 'audio-cloud': { label: __('Audio Cloud', 'web-stories'), }, }; export const DEFAULT_ATTRIBUTES_FOR_MEDIA = { scale: 100, focalX: 50, focalY: 50, }; ================================================ FILE: packages/element-library/src/elementTypes.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { __ } from '@googleforcreators/i18n'; import { type ElementDefinition, type AudioStickerElement, type TextElement, type ImageElement, type ShapeElement, type VideoElement, type GifElement, type StickerElement, type ProductElement, ElementType, } from '@googleforcreators/elements'; /** * Internal dependencies */ import * as textElement from './text'; import * as imageElement from './image'; import * as shapeElement from './shape'; import * as videoElement from './video'; import * as gifElement from './gif'; import * as stickerElement from './sticker'; import * as productElement from './product'; import * as audioStickerElement from './audioSticker'; const elementTypes = [ { type: ElementType.Text, name: __('Text', 'web-stories'), ...textElement, } as ElementDefinition, { type: ElementType.Image, name: __('Image', 'web-stories'), ...imageElement, } as ElementDefinition, { type: ElementType.Shape, name: __('Shape', 'web-stories'), ...shapeElement, } as ElementDefinition, { type: ElementType.Video, name: __('Video', 'web-stories'), ...videoElement, } as ElementDefinition, { type: ElementType.Gif, name: __('GIF', 'web-stories'), ...gifElement, } as ElementDefinition, { type: ElementType.Sticker, name: __('Sticker', 'web-stories') as string, ...stickerElement, } as ElementDefinition, { type: ElementType.Product, name: __('Product', 'web-stories'), ...productElement, } as ElementDefinition, { type: ElementType.AudioSticker, name: __('Audio Sticker', 'web-stories'), ...audioStickerElement, } as ElementDefinition, ]; export default elementTypes; ================================================ FILE: packages/element-library/src/gif/constants.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { PanelTypes } from '@googleforcreators/design-system'; import { ResourceType } from '@googleforcreators/media'; /** * Internal dependencies */ import { SHARED_DEFAULT_ATTRIBUTES } from '../shared'; import { MEDIA_DEFAULT_ATTRIBUTES, MEDIA_PANELS } from '../media'; export { canFlip, isMaskable, isAspectAlwaysLocked, isMedia, hasEditMode, hasEditModeIfLocked, hasEditModeMoveable, hasDuplicateMenu, hasDesignMenu, editModeGrayout, } from '../media/constants'; export { resizeRules } from '../media/constants'; export const defaultAttributes = { ...SHARED_DEFAULT_ATTRIBUTES, ...MEDIA_DEFAULT_ATTRIBUTES, resource: { type: ResourceType.Gif, id: 0, width: 0, height: 0, alt: '', src: '', mimeType: 'image/gif', output: { mimeType: 'video/mp4', src: '', }, }, }; export const panels = [ PanelTypes.ElementAlignment, ...MEDIA_PANELS, PanelTypes.Link, PanelTypes.ImageAccessibility, ]; ================================================ FILE: packages/element-library/src/gif/display.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useRef } from '@googleforcreators/react'; import { getMediaSizePositionProps } from '@googleforcreators/media'; import type { GifElement, DisplayProps } from '@googleforcreators/elements'; import type { RefObject } from 'react'; /** * Internal dependencies */ import MediaDisplay from '../media/display'; import { getBackgroundStyle, Video, VideoImage } from '../media/util'; function GifDisplay({ previewMode, box: { width, height }, element, renderResourcePlaceholder, }: DisplayProps) { const { id, poster, resource, isBackground, scale, focalX, focalY } = element; const ref = useRef(null); let style = {}; if (isBackground) { const styleProps = getBackgroundStyle(); style = { ...style, ...styleProps, }; } const videoProps = getMediaSizePositionProps( resource, width, height, scale, focalX, focalY ); return ( element={element} mediaRef={ref} showPlaceholder previewMode={previewMode} renderResourcePlaceholder={renderResourcePlaceholder} > {previewMode ? ( (poster || resource.poster) && ( } /> ) ) : ( )} ); } export default GifDisplay; ================================================ FILE: packages/element-library/src/gif/edit.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { GifElement, EditProps } from '@googleforcreators/elements'; /** * Internal dependencies */ import MediaEdit from '../media/edit'; function GifEdit({ element, box, ...rest }: EditProps) { return element={element} box={box} {...rest} />; } export default GifEdit; ================================================ FILE: packages/element-library/src/gif/frame.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import MediaFrame from '../media/frame'; function GifFrame() { return ; } export default GifFrame; ================================================ FILE: packages/element-library/src/gif/icon.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { GifElement, LayerIconProps } from '@googleforcreators/elements'; /** * Internal dependencies */ import VisibleImage from '../shared/visibleImage'; function GifLayerIcon({ element: { resource: { poster, alt }, }, }: LayerIconProps) { return ; } export default GifLayerIcon; ================================================ FILE: packages/element-library/src/gif/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ export { default as Edit } from './edit'; export { default as Frame } from './frame'; export { default as getLayerText } from './layer'; export { default as Output } from './output'; export { default as LayerIcon } from './icon'; export { default as TextContent } from '../media/textContent'; export { default as Display } from './display'; export * from './constants'; ================================================ FILE: packages/element-library/src/gif/layer.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { __ } from '@googleforcreators/i18n'; import type { GifElement } from '@googleforcreators/elements'; function getGifLayerText(element: GifElement) { const { alt } = element?.resource || {}; return alt || __('GIF', 'web-stories'); } export default getGifLayerText; ================================================ FILE: packages/element-library/src/gif/output.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { isBlobURL } from '@googleforcreators/media'; import type { GifElement, OutputProps } from '@googleforcreators/elements'; /** * Internal dependencies */ import MediaOutput from '../media/output'; function GifOutput({ element, box, flags }: OutputProps) { const { resource } = element; const src = flags?.allowBlobs || !isBlobURL(resource.output.src) ? resource.output.src : ''; const poster = flags?.allowBlobs || !isBlobURL(resource.poster) ? resource.poster : ''; return ( ); } export default GifOutput; ================================================ FILE: packages/element-library/src/gif/test/__snapshots__/output.js.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Gif Output should include poster image if available 1`] = `"
"`; exports[`Gif Output should produce an AMP video with autoplay, no controls, no audio, and loop 1`] = `"
"`; ================================================ FILE: packages/element-library/src/gif/test/output.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { renderToStaticMarkup } from '@googleforcreators/react'; /** * Internal dependencies */ import GifOutput from '../output'; describe('Gif Output', () => { const baseProps = { element: { id: '048426c8-69ae-4e04-80f1-8f3fd4434261', type: 'gif', opacity: 100, x: 65, y: 196, width: 281, height: 223, scale: 100, rotationAngle: 0, resource: { type: 'gif', mimeType: 'image/gif', creationDate: '2016-02-04T18:16:22Z', src: 'https://c.tenor.com/4F2m7BWP6KYAAAAC/flying-kiss-muah.gif', width: 281, height: 223, alt: '', attribution: { author: [], registerUsageUrl: 'https://media3p.googleapis.com/v1/media:registerUsage?token=AX7RMSdePGQBB3c/QAOBJ20QC%2BZNp2A549gSosisUYOjIC71nkvySPH5yj%2BqDBOVBmmFZ89azzUAN9x2GjkNbq3OXauUMho%3D', }, output: { mimeType: 'video/mp4', src: 'https://c.tenor.com/4F2m7BWP6KYAAAPo/flying-kiss-muah.mp4', }, }, }, box: { x: 15.7767, y: 31.71521, width: 68.20388, height: 36.08414, rotationAngle: 0, }, }; it('should produce an AMP video with autoplay, no controls, no audio, and loop', async () => { const output = ; const outputStr = renderToStaticMarkup(output); await expect(outputStr).toStrictEqual( expect.stringMatching( 'src="https://c.tenor.com/4F2m7BWP6KYAAAPo/flying-kiss-muah.mp4"' ) ); await expect(outputStr).toMatchSnapshot(); }); it('should include poster image if available', async () => { const newProps = { ...baseProps }; newProps.element.resource.poster = 'https://c.tenor.com/4F2m7BWP6KYAAAAC/flying-kiss-muah-poster.png'; const output = ; const outputStr = renderToStaticMarkup(output); await expect(outputStr).toStrictEqual( expect.stringMatching( 'src="https://c.tenor.com/4F2m7BWP6KYAAAPo/flying-kiss-muah.mp4"' ) ); await expect(outputStr).toMatchSnapshot(); }); it('should remove blob URLs', async () => { const props = { ...baseProps, element: { ...baseProps.element, resource: { ...baseProps.element.resource, output: { ...baseProps.element.resource.output, src: 'blob:https://example.com/ecee4374-8f8a-4210-8f2d-9c5f8d6a6c5a', }, }, }, }; const output = ; const outputStr = renderToStaticMarkup(output); await expect(outputStr).not.toStrictEqual(expect.stringMatching('blob:')); }); describe('AMP validation', () => { jest.retryTimes(3, { logErrorsBeforeRetry: true }); it('should produce valid AMP output', async () => { await expect().toBeValidAMPStoryElement(); }); it('should produce valid AMP output with poster', async () => { const newProps = { ...baseProps }; newProps.element.resource.poster = 'https://c.tenor.com/4F2m7BWP6KYAAAAC/flying-kiss-muah-poster.png'; await expect().toBeValidAMPStoryElement(); }); }); }); ================================================ FILE: packages/element-library/src/image/constants.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { PanelTypes } from '@googleforcreators/design-system'; import { ResourceType } from '@googleforcreators/media'; /** * Internal dependencies */ import { SHARED_DEFAULT_ATTRIBUTES } from '../shared'; import { MEDIA_DEFAULT_ATTRIBUTES, MEDIA_PANELS } from '../media'; export { canFlip, isMaskable, isAspectAlwaysLocked, isMedia, hasEditMode, hasEditModeIfLocked, hasEditModeMoveable, hasDuplicateMenu, hasDesignMenu, editModeGrayout, resizeRules, } from '../media/constants'; export const defaultAttributes = { ...SHARED_DEFAULT_ATTRIBUTES, ...MEDIA_DEFAULT_ATTRIBUTES, lockAspectRatio: false, resource: { type: ResourceType.Image, id: 0, width: 0, height: 0, alt: '', src: '', mimeType: '', }, }; export const panels = [ PanelTypes.ElementAlignment, ...MEDIA_PANELS, PanelTypes.ImageAccessibility, ]; ================================================ FILE: packages/element-library/src/image/edit.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import type { ImageElement, EditProps } from '@googleforcreators/elements'; /** * Internal dependencies */ import MediaEdit from '../media/edit'; function ImageEdit({ element, box, ...rest }: EditProps) { return element={element} box={box} {...rest} />; } export default ImageEdit; ================================================ FILE: packages/element-library/src/image/frame.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ import MediaFrame from '../media/frame'; function ImageFrame() { return ; } export default ImageFrame; ================================================ FILE: packages/element-library/src/image/icon.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { getSmallestUrlForWidth } from '@googleforcreators/media'; import type { ImageElement, LayerIconProps } from '@googleforcreators/elements'; /** * Internal dependencies */ import VisibleImage from '../shared/visibleImage'; function ImageLayerIcon({ element: { resource }, getProxiedUrl, }: LayerIconProps) { const url = getSmallestUrlForWidth(0, resource); const src = getProxiedUrl(resource, url) || undefined; return ; } export default ImageLayerIcon; ================================================ FILE: packages/element-library/src/image/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Internal dependencies */ export { default as Edit } from './edit'; export { default as Frame } from './frame'; export { default as Output } from './output'; export { default as LayerIcon } from './icon'; export { default as getLayerText } from './layer'; export { default as Display } from '../media/imageDisplay'; export { default as TextContent } from '../media/textContent'; export * from './constants'; ================================================ FILE: packages/element-library/src/image/layer.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { __ } from '@googleforcreators/i18n'; import type { ImageElement } from '@googleforcreators/elements'; function getImageLayerText(element: ImageElement) { const { alt } = element?.resource || {}; return alt || __('Image', 'web-stories'); } export default getImageLayerText; ================================================ FILE: packages/element-library/src/image/output.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { calculateSrcSet, isBlobURL } from '@googleforcreators/media'; import { PAGE_WIDTH, FULLBLEED_HEIGHT, FULLBLEED_RATIO, } from '@googleforcreators/units'; import type { ImageElement, OutputProps } from '@googleforcreators/elements'; import type { HTMLProps } from 'react'; /** * Internal dependencies */ import MediaOutput from '../media/output'; /** * Returns AMP HTML for saving into post content for displaying in the FE. * * @param props Props. * @return Rendered component. */ function ImageOutput({ element, box, flags }: OutputProps) { const { alt, isBackground, resource, width, height, scale } = element; const props: Pick< HTMLProps, 'src' | 'alt' | 'srcSet' | 'sizes' > = { src: flags?.allowBlobs || !isBlobURL(resource.src) ? resource.src : '', alt: alt !== undefined ? alt : resource.alt, }; const srcSet = calculateSrcSet(resource); if (srcSet) { props.srcSet = srcSet; // `width` of background elements reflects their original size. // We need to account for both aspect-scale-to-fit background and element scale level. let displayWidth = width; if (isBackground) { const aspectRatio = width / height; const widthAsBackground = aspectRatio <= FULLBLEED_RATIO ? PAGE_WIDTH : aspectRatio * FULLBLEED_HEIGHT; displayWidth = widthAsBackground * ((scale || 100) / 100); } // If `srcset` exists but `sizes` doesn't, amp-img will generate a sizes attribute // with best-guess values that can result in poor image selection. const imageWidthPercent = displayWidth / PAGE_WIDTH; const mobileWidth = Math.round(imageWidthPercent * 100) + 'vw'; // Width of a story page in desktop mode is 45vh. const desktopWidth = Math.round(imageWidthPercent * 45) + 'vh'; // 1024px is the minimum width for STAMP desktop mode. props.sizes = `(min-width: 1024px) ${desktopWidth}, ${mobileWidth}`; } // Prevent inline `width` style from being inserted by AMP (due to presence of `sizes` attribute), // which avoids an undesirable interaction between AMP and the Optimizer's SSR transforms. // See https://github.com/googleforcreators/web-stories-wp/pull/8099#issuecomment-870987667. return ( ); } export default ImageOutput; ================================================ FILE: packages/element-library/src/image/test/output.js ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { renderToStaticMarkup } from '@googleforcreators/react'; /** * Internal dependencies */ import ImageOutput from '../output'; describe('Image output', () => { jest.retryTimes(3, { logErrorsBeforeRetry: true }); const baseProps = { element: { id: '123', type: 'image', mimeType: 'image/png', scale: 200, origRatio: 16 / 9, x: 50, y: 100, height: 231.75, width: 206, rotationAngle: 0, resource: { id: 123, type: 'image', mimeType: 'image/png', src: 'https://example.com/image.png', alt: 'alt text', height: 1080, width: 1920, sizes: { mid: { sourceUrl: 'https://example.com/image-mid.png', width: 960, height: 540, mimeType: 'image/png', }, full: { sourceUrl: 'https://example.com/image.png', width: 1920, height: 1080, mimeType: 'image/png', }, }, }, }, box: { width: 1920, height: 1080, x: 50, y: 100, rotationAngle: 0 }, }; it('should produce valid AMP output', async () => { await expect().toBeValidAMPStoryElement(); }); it('should produce an AMP img with a srcset/sizes', async () => { const output = ; const outputStr = renderToStaticMarkup(output); await expect(output).toBeValidAMPStoryElement(); await expect(outputStr).toStrictEqual( expect.stringMatching( 'srcSet="https://example.com/image.png 1920w,' + 'https://example.com/image-mid.png 960w"' ) ); await expect(outputStr).toStrictEqual( expect.stringMatching('src="https://example.com/image.png"') ); // `sizes` should match: (min-width: ) , // The image size is 206px wide, which is half page width. // 45vh is the page width of stories in desktop mode (divided by 2, rounded up is 23). await expect(outputStr).toStrictEqual( expect.stringMatching(/sizes="\(min-width: 1024px\) 23vh, 50vw"/) ); // The "disable-inline-width" attribute should accompany the "sizes" attribute. await expect(outputStr).toStrictEqual( expect.stringMatching('disable-inline-width="true"') ); }); it('should generate correct `sizes` for a narrower background image', async () => { const props = { ...baseProps, element: { ...baseProps.element, width: 206, // 50% height: 732, // 100% isBackground: true, scale: 300, }, }; const output = ; const outputStr = renderToStaticMarkup(output); await expect(output).toBeValidAMPStoryElement(); // `sizes` should match: (min-width: ) , // The background image should scale up its width to fit the page. // 45vh is the page width of stories in desktop mode. // The image zoom is 300 (3x) so triple both measurements. await expect(outputStr).toStrictEqual( expect.stringMatching(/sizes="\(min-width: 1024px\) 135vh, 300vw"/) ); // The "disable-inline-width" attribute should accompany the "sizes" attribute. await expect(outputStr).toStrictEqual( expect.stringMatching('disable-inline-width="true"') ); }); it('should generate correct `sizes` for a wider background image', async () => { const props = { ...baseProps, element: { ...baseProps.element, width: 412, // 100% height: 183, // 25% isBackground: true, scale: 100, }, }; const output = ; const outputStr = renderToStaticMarkup(output); await expect(output).toBeValidAMPStoryElement(); // `sizes` should match: (min-width: ) , // The background image should scale up its width 4x since its height is 25%. // 45vh is the page width of stories in desktop mode. await expect(outputStr).toStrictEqual( expect.stringMatching(/sizes="\(min-width: 1024px\) 180vh, 400vw"/) ); // The "disable-inline-width" attribute should accompany the "sizes" attribute. await expect(outputStr).toStrictEqual( expect.stringMatching('disable-inline-width="true"') ); }); it('should produce an AMP img with no srcset/sizes if the resource has no `sizes`', async () => { const basePropsNoSrcset = { ...baseProps }; basePropsNoSrcset.element.resource.sizes = {}; const output = ; const outputStr = renderToStaticMarkup(output); await expect(output).toBeValidAMPStoryElement(); await expect(outputStr).toStrictEqual(expect.not.stringMatching('srcSet=')); await expect(outputStr).toStrictEqual(expect.not.stringMatching('sizes=')); await expect(outputStr).toStrictEqual( expect.stringMatching('src="https://example.com/image.png"') ); }); it('an undefined alt tag in the element should fall back to the resource', async () => { const props = { ...baseProps, element: { ...baseProps.element, alt: undefined }, }; const output = ; await expect(output).toBeValidAMPStoryElement(); const outputStr = renderToStaticMarkup(output); await expect(outputStr).toStrictEqual(expect.stringMatching('alt text')); }); it('an empty string alt tag in the element should not fall back to the resource', async () => { const props = { ...baseProps, element: { ...baseProps.element, alt: '' } }; const output = ; await expect(output).toBeValidAMPStoryElement(); const outputStr = renderToStaticMarkup(output); await expect(outputStr).not.toStrictEqual( expect.stringMatching('alt text') ); }); it('should remove blob URLs', async () => { const props = { ...baseProps, element: { ...baseProps.element, resource: { ...baseProps.element.resource, src: 'blob:https://example.com/ecee4374-8f8a-4210-8f2d-9c5f8d6a6c5a', }, }, }; const output = ; await expect(output).not.toBeValidAMPStoryElement(); const outputStr = renderToStaticMarkup(output); await expect(outputStr).not.toStrictEqual(expect.stringMatching('blob:')); }); }); ================================================ FILE: packages/element-library/src/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export * from './shared'; export * from './media'; export * from './utils/textMeasurements'; export * from './text/util'; export * from './constants'; export { default as TextOutput } from './text/output'; export { default as elementTypes } from './elementTypes'; ================================================ FILE: packages/element-library/src/media/constants.ts ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { PanelTypes } from '@googleforcreators/design-system'; /** * Internal dependencies */ import { DEFAULT_ATTRIBUTES_FOR_MEDIA } from '../constants'; export const MEDIA_DEFAULT_ATTRIBUTES = { ...DEFAULT_ATTRIBUTES_FOR_MEDIA, resource: { alt: '', }, }; export const MEDIA_MASK_OPACITY = 0.4; export const hasEditMode = true; export const hasEditModeIfLocked = true; export const hasEditModeMoveable = false; export const editModeGrayout = true; export const hasDuplicateMenu = true; export const hasDesignMenu = true; export const isMedia = true; export const canFlip = true; export const isMaskable = true; export const isAspectAlwaysLocked = false; export const resizeRules = { vertical: true, horizontal: true, diagonal: true, minWidth: 20, minHeight: 20, }; export const MEDIA_PANELS = [ PanelTypes.Filter, PanelTypes.SizePosition, PanelTypes.Border, PanelTypes.Animation, PanelTypes.Link, ]; ================================================ FILE: packages/element-library/src/media/display.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { useRef } from '@googleforcreators/react'; import { useUnits } from '@googleforcreators/units'; import { getMediaSizePositionProps } from '@googleforcreators/media'; import { useTransformHandler } from '@googleforcreators/transform'; import { getResponsiveBorder, shouldDisplayBorder, } from '@googleforcreators/masks'; import type { SequenceMediaElement, OverlayableElement, Element as ElementType, MediaElement, DisplayProps, } from '@googleforcreators/elements'; import type { ReactNode, RefObject } from 'react'; /** * Internal dependencies */ import { elementFillContent, elementWithBorder, elementWithBackgroundColor, useColorTransformHandler, } from '../shared'; import { getMediaWithScaleCss } from './util'; const Element = styled.div.attrs({ className: 'story-media-display-element' })< { showPlaceholder: boolean; } & Pick >` ${elementFillContent} ${({ showPlaceholder }) => showPlaceholder && `background-color: #C4C4C4;`} color: transparent; overflow: hidden; ${elementWithBorder} `; const Overlay = styled.div` ${elementFillContent} ${elementWithBackgroundColor} `; interface MediaDisplayProps { element: T & OverlayableElement; showPlaceholder?: boolean; previewMode?: boolean; mediaRef: RefObject< T extends SequenceMediaElement ? HTMLVideoElement | HTMLImageElement : HTMLImageElement >; renderResourcePlaceholder?: DisplayProps['renderResourcePlaceholder']; children: ReactNode; } function MediaDisplay({ element, mediaRef, children, previewMode, showPlaceholder = false, renderResourcePlaceholder, }: MediaDisplayProps) { const { id, resource, scale, focalX, focalY, border, borderRadius, width, height, overlay, mask, } = element; const { dataToEditorX } = useUnits((state) => ({ dataToEditorX: state.actions.dataToEditorX, })); const ref = useRef(null); useColorTransformHandler({ id, targetRef: ref, expectedStyle: 'border-color', }); useTransformHandler(id, (transform) => { const target = mediaRef.current; if (target) { if (transform === null) { target.style.cssText = ''; } else { const { resize } = transform; if (resize && resize[0] !== 0 && resize[1] !== 0) { // @todo this needs to resize the outside border element separately now. const newImgProps = getMediaSizePositionProps( resource, resize[0], resize[1], scale, focalX, focalY ); target.style.cssText = getMediaWithScaleCss(newImgProps); if (shouldDisplayBorder(element) && ref.current) { ref.current.style.width = resize[0] + (border?.left || 0) + (border?.right || 0) + 'px'; ref.current.style.height = resize[1] + (border?.top || 0) + (border?.bottom || 0) + 'px'; } } } } }); return ( {showPlaceholder && renderResourcePlaceholder && renderResourcePlaceholder(resource)} {children} {overlay && } ); } export default MediaDisplay; ================================================ FILE: packages/element-library/src/media/edit.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled, { css } from 'styled-components'; import { useCallback, useEffect, useRef, useState, useUnmount, } from '@googleforcreators/react'; import { __ } from '@googleforcreators/i18n'; import { calculateSrcSet, getMediaSizePositionProps, } from '@googleforcreators/media'; import { DisplayWithMask as WithMask, shouldDisplayBorder, } from '@googleforcreators/masks'; import { type MediaElement, type SequenceMediaElement, type EditProps, getTransformFlip, elementIs, } from '@googleforcreators/elements'; import type { Dispatch, SetStateAction } from 'react'; /** * Internal dependencies */ import { elementFillContent, elementWithFlip } from '../shared'; import EditCropMoveable from './editCropMoveable'; import { mediaWithScale } from './util'; import EditPanMoveable from './editPanMoveable'; import ScalePanel from './scalePanel'; import { MEDIA_MASK_OPACITY } from './constants'; import { CropBox } from '.'; const Element = styled.div` ${elementFillContent} `; // Opacity of the mask is reduced depending on the opacity assigned to the media. const fadedMediaCSS = css<{ opacity?: number; width: number; height: number; offsetX: number; offsetY: number; $transformFlip: string | null; }>` position: absolute; opacity: ${({ opacity }) => typeof opacity !== 'undefined' ? opacity * MEDIA_MASK_OPACITY : MEDIA_MASK_OPACITY}; pointer-events: none; ${mediaWithScale} ${elementWithFlip} `; const FadedImage = styled.img` ${fadedMediaCSS} `; const FadedVideo = styled.video` max-width: initial; max-height: initial; ${fadedMediaCSS} `; // Opacity is adjusted so that the double image opacity would equal // the opacity assigned to the image. const cropMediaCSS = css<{ opacity?: number; width: number; height: number; offsetX: number; offsetY: number; $transformFlip?: string | null; }>` ${mediaWithScale} ${elementWithFlip} position: absolute; cursor: grab; opacity: ${({ opacity }) => typeof opacity !== 'undefined' ? 1 - (1 - opacity) / (1 - opacity * MEDIA_MASK_OPACITY) : null}; `; const CropImage = styled.img` ${cropMediaCSS} `; // Opacity of the mask is reduced depending on the opacity assigned to the video. const CropVideo = styled.video` max-width: initial; max-height: initial; ${cropMediaCSS} `; // eslint-disable-next-line complexity -- TODO: Refactor to reduce complexity. function MediaEdit({ element, box, setLocalProperties, getProxiedUrl, updateElementById, zIndexCanvas, scaleMin, scaleMax, }: Omit, 'isTrimMode'>) { const { id, resource, opacity, scale, flip, focalX, focalY, isBackground, isLocked, borderRadius, } = element; const { x, y, width, height, rotationAngle } = box; const [fullMedia, setFullMedia] = useState< | (T extends SequenceMediaElement ? HTMLVideoElement : HTMLImageElement) | null >(null); const [croppedMedia, setCroppedMedia] = useState< | (T extends SequenceMediaElement ? HTMLVideoElement : HTMLImageElement) | null >(null); const [cropBox, setCropBox] = useState(null); const elementRef = useRef(null); const isUpdatedLocallyRef = useRef(false); const lastLocalPropertiesRef = useRef>({ scale } as Partial); const updateLocalProperties = useCallback( (properties: Partial | ((p: Partial) => Partial)) => { lastLocalPropertiesRef.current = { ...lastLocalPropertiesRef.current, ...(typeof properties === 'function' ? properties(lastLocalPropertiesRef.current) : properties), }; isUpdatedLocallyRef.current = true; setLocalProperties(lastLocalPropertiesRef.current); }, [setLocalProperties] ); const updateProperties = useCallback(() => { if (!isUpdatedLocallyRef.current) { return; } isUpdatedLocallyRef.current = false; const properties: Partial = lastLocalPropertiesRef.current; updateElementById({ elementId: id, properties }); }, [id, updateElementById]); useUnmount(updateProperties); const isImage = elementIs.media(element) && !elementIs.video(element); const isVideo = elementIs.video(element); const mediaProps = getMediaSizePositionProps( resource, width, height, scale, flip?.horizontal ? 100 - (focalX || 0) : focalX, flip?.vertical ? 100 - (focalY || 0) : focalY ); const fadedMediaProps = { draggable: false, alt: '', opacity: (opacity || 100) / 100, $transformFlip: getTransformFlip(flip), ...mediaProps, }; const cropMediaProps = { draggable: false, src: resource.src, alt: __('Drag to move media element', 'web-stories'), opacity: (opacity || 100) / 100, tabIndex: 0, $transformFlip: getTransformFlip(flip), ...mediaProps, }; const url = getProxiedUrl(resource, resource?.src); useEffect(() => { if ( croppedMedia && elementRef.current && !elementRef.current.contains(document.activeElement) ) { croppedMedia.focus(); } }, [croppedMedia]); const srcSet = calculateSrcSet(element.resource); const handleWheel = useCallback( (evt: WheelEvent) => { updateLocalProperties( ({ scale: oldScale }: Partial) => ({ scale: Math.min( scaleMax, Math.max(scaleMin, (oldScale || 0) + evt.deltaY) ), }) as Partial ); evt.preventDefault(); evt.stopPropagation(); }, [updateLocalProperties, scaleMin, scaleMax] ); // Cancelable wheel events require a non-passive listener, which React // can't do on its own, so we need to attach manually. useEffect(() => { const node = elementRef.current; const opts = { passive: false }; node?.addEventListener('wheel', handleWheel, opts); // @ts-expect-error TODO: Fix type. return () => node?.removeEventListener('wheel', handleWheel, opts); }, [handleWheel]); const borderProps = shouldDisplayBorder(element) && borderRadius ? { borderRadius: element.borderRadius, width: element.width, height: element.height, mask: element.mask, } : null; return ( {isImage && ( /* eslint-disable-next-line styled-components-a11y/alt-text -- False positive. */ > } src={url || undefined} srcSet={calculateSrcSet(resource) || undefined} /> )} {isVideo && ( //eslint-disable-next-line styled-components-a11y/media-has-caption,jsx-a11y/media-has-caption -- Faded video doesn't need captions. > } > {url && } )} {/* @ts-expect-error TODO: Fix type. */} {} {isImage && ( /*eslint-disable-next-line styled-components-a11y/alt-text -- False positive. */ > } srcSet={srcSet || undefined} /> )} {isVideo && ( /*eslint-disable-next-line styled-components-a11y/media-has-caption,jsx-a11y/media-has-caption -- Tracks might not exist. Also, unwanted in edit mode. */ > } > )} {fullMedia && croppedMedia && ( )} {!isBackground && !isLocked && cropBox && croppedMedia && ( )} aria-label={__('Scale media', 'web-stories')} data-testid="edit-panel-slider" setProperties={updateLocalProperties} x={x} y={y} width={width} height={height} scale={scale || 100} zIndexCanvas={zIndexCanvas} min={scaleMin} max={scaleMax} /> ); } export default MediaEdit; ================================================ FILE: packages/element-library/src/media/editCropMoveable.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useEffect, useRef } from '@googleforcreators/react'; import { useUnits, calcRotatedResizeOffset } from '@googleforcreators/units'; import { getFocalFromOffset } from '@googleforcreators/media'; import { Moveable } from '@googleforcreators/moveable'; import { getTransformFlip, type Flip, type MediaElement, } from '@googleforcreators/elements'; import type MoveableType from 'react-moveable'; interface EditCropMoveableProps { setProperties: (properties: Partial) => void; cropBox: HTMLElement; croppedMedia: HTMLElement; flip?: Flip; x: number; y: number; width: number; height: number; rotationAngle: number; offsetX: number; offsetY: number; mediaWidth: number; mediaHeight: number; } function EditCropMoveable({ setProperties, cropBox, croppedMedia, flip, x, y, width, height, rotationAngle, offsetX, offsetY, mediaWidth, mediaHeight, }: EditCropMoveableProps) { const { editorToDataX, editorToDataY } = useUnits((state) => ({ editorToDataX: state.actions.editorToDataX, editorToDataY: state.actions.editorToDataY, })); const moveableRef = useRef(null); const cropRef = useRef([0, 0, 0, 0, 0, 0]); const transformFlip = getTransformFlip(flip); // Refresh moveables to ensure that the selection rect is always correct. useEffect(() => { moveableRef.current?.updateRect(); }); return ( { // Focal point offset. const [fx, fy] = [drag.beforeTranslate[0], drag.beforeTranslate[1]]; // Direction of resize: left/right/top/bottom and resize deltas for // each side. const [dirX, dirY] = direction; const dw = resizeWidth - width; const dh = resizeHeight - height; const left = dirX < 0 ? dw : 0; const right = dirX > 0 ? dw : 0; const top = dirY < 0 ? dh : 0; const bottom = dirY > 0 ? dh : 0; cropRef.current = [fx, fy, left, right, top, bottom]; // eslint-disable-next-line react-hooks/immutability -- FIXME cropBox.style.transform = `translate(${fx}px, ${fy}px)`; // eslint-disable-next-line react-hooks/immutability -- FIXME croppedMedia.style.transform = `translate(${-fx}px, ${-fy}px) ${ transformFlip ?? '' }`; if (delta[0]) { cropBox.style.width = `${resizeWidth}px`; } if (delta[1]) { cropBox.style.height = `${resizeHeight}px`; } }} onResizeEnd={() => { const [fx, fy, left, right, top, bottom] = cropRef.current; cropRef.current = [0, 0, 0, 0, 0, 0]; // eslint-disable-next-line react-hooks/immutability -- FIXME cropBox.style.transform = ''; // eslint-disable-next-line react-hooks/immutability -- FIXME croppedMedia.style.transform = ''; cropBox.style.width = ''; cropBox.style.height = ''; if (left === 0 && right === 0 && top === 0 && bottom === 0) { return; } const resizeWidth = width + left + right; const resizeHeight = height + top + bottom; const [dx, dy] = calcRotatedResizeOffset( rotationAngle, left, right, top, bottom ); const resizeScale = Math.min(mediaWidth / resizeWidth, mediaHeight / resizeHeight) * 100; const resizeFocalX = getFocalFromOffset( resizeWidth, mediaWidth, offsetX + fx ); const resizeFocalY = getFocalFromOffset( resizeHeight, mediaHeight, offsetY + fy ); setProperties({ x: editorToDataX(x + dx), y: editorToDataY(y + dy), width: editorToDataX(resizeWidth), height: editorToDataY(resizeHeight), scale: resizeScale, focalX: flip?.horizontal ? 100 - resizeFocalX : resizeFocalX, focalY: flip?.vertical ? 100 - resizeFocalY : resizeFocalY, } as Partial); }} snappable // todo@: it looks like resizing bounds are not supported. verticalGuidelines={[x - offsetX, x - offsetX + mediaWidth]} horizontalGuidelines={[y - offsetY, y - offsetY + mediaHeight]} /> ); } export default EditCropMoveable; ================================================ FILE: packages/element-library/src/media/editPanMoveable.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { useEffect, useRef, useCallback } from '@googleforcreators/react'; import { getFocalFromOffset } from '@googleforcreators/media'; import { useKeyDownEffect, getKeyboardMovement, } from '@googleforcreators/design-system'; import { Moveable } from '@googleforcreators/moveable'; import { type Flip, getTransformFlip, type MediaElement, } from '@googleforcreators/elements'; import type MoveableType from 'react-moveable'; interface EditPanMoveableProps { setProperties: (properties: Partial) => void; fullMedia: HTMLElement; croppedMedia: HTMLElement; flip?: Flip; x: number; y: number; width: number; height: number; rotationAngle: number; offsetX: number; offsetY: number; mediaWidth: number; mediaHeight: number; } function EditPanMoveable({ setProperties, fullMedia, croppedMedia, flip, x, y, width, height, rotationAngle, offsetX, offsetY, mediaWidth, mediaHeight, }: EditPanMoveableProps) { const moveableRef = useRef(null); const translateRef = useRef([0, 0]); const transformFlip = getTransformFlip(flip); const update = useCallback(() => { const [tx, ty] = translateRef.current; // eslint-disable-next-line react-hooks/immutability -- FIXME fullMedia.style.transform = `translate(${tx}px, ${ty}px) ${ transformFlip ?? '' }`; // eslint-disable-next-line react-hooks/immutability -- FIXME croppedMedia.style.transform = `translate(${tx}px, ${ty}px) ${ transformFlip ?? '' }`; }, [croppedMedia, fullMedia, transformFlip]); // Refresh moveables to ensure that the selection rect is always correct. useEffect(() => { moveableRef.current?.updateRect(); }); useEffect(update, [update]); useKeyDownEffect( croppedMedia, { key: ['up', 'down', 'left', 'right'], shift: true }, ({ key, shiftKey }) => { const { dx, dy } = getKeyboardMovement(key, shiftKey); const panFocalX = getFocalFromOffset(width, mediaWidth, offsetX - dx); const panFocalY = getFocalFromOffset(height, mediaHeight, offsetY - dy); setProperties({ focalX: flip?.horizontal ? 100 - panFocalX : panFocalX, focalY: flip?.vertical ? 100 - panFocalY : panFocalY, } as Partial); update(); }, [ update, flip?.horizontal, flip?.vertical, offsetX, offsetY, setProperties, width, height, mediaHeight, mediaWidth, ] ); return ( { translateRef.current = dist; update(); }} onDragEnd={() => { croppedMedia.focus(); const [tx, ty] = translateRef.current; translateRef.current = [0, 0]; const panFocalX = getFocalFromOffset(width, mediaWidth, offsetX - tx); const panFocalY = getFocalFromOffset(height, mediaHeight, offsetY - ty); setProperties({ focalX: flip?.horizontal ? 100 - panFocalX : panFocalX, focalY: flip?.vertical ? 100 - panFocalY : panFocalY, } as Partial); update(); }} snappable // todo@: Moveable defines bounds and guidelines as the vertical and // horizontal lines and doesn't work well with `rotationAngle > 0` for // cropping/panning. It's possible to define a larger bounds using // the expansion radius, but the UX is very poor for a rotated shape. bounds={ rotationAngle === 0 ? { left: x + width - mediaWidth, top: y + height - mediaHeight, right: x + mediaWidth, bottom: y + mediaHeight, } : {} } verticalGuidelines={ rotationAngle === 0 ? [x, x + width / 2, x + width] : [x + width / 2] } horizontalGuidelines={ rotationAngle === 0 ? [y, y + height / 2, y + height] : [y + height / 2] } /> ); } export default EditPanMoveable; ================================================ FILE: packages/element-library/src/media/frame.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; /** * Internal dependencies */ import { elementFillContent } from '../shared'; const Element = styled.div` ${elementFillContent} `; function MediaFrame() { return ; } export default MediaFrame; ================================================ FILE: packages/element-library/src/media/imageDisplay.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { useEffect, useRef, useState } from '@googleforcreators/react'; import { calculateSrcSet, getMediaSizePositionProps, getSmallestUrlForWidth, preloadImage, type ResourceCacheEntry, ResourceCacheEntryType, resourceList, } from '@googleforcreators/media'; import type { ImageElement, DisplayProps } from '@googleforcreators/elements'; import type { HTMLProps } from 'react'; /** * Internal dependencies */ import { mediaWithScale } from './util'; import MediaDisplay from './display'; const Image = styled.img` position: absolute; ${mediaWithScale} `; const noop = () => false; function ImageDisplay({ element, box, previewMode, getProxiedUrl, isCurrentResourceProcessing = noop, isCurrentResourceUploading = noop, renderResourcePlaceholder, }: DisplayProps) { const { resource, scale, focalX, focalY } = element; const { id: resourceId, alt } = resource; const { width, height } = box; const ref = useRef(null); let initialSrcType = 'smallest'; let initialSrc: string | null = getSmallestUrlForWidth(0, resource); if (resourceList.get(resourceId)?.type === ResourceCacheEntryType.Cached) { initialSrcType = 'cached'; initialSrc = (resourceList.get(resourceId) as ResourceCacheEntry).url; } if ( resourceList.get(resourceId)?.type === ResourceCacheEntryType.Fullsize || isCurrentResourceProcessing(resourceId) || isCurrentResourceUploading(resourceId) ) { initialSrcType = 'fullsize'; initialSrc = resource.src; } initialSrc = getProxiedUrl(resource, initialSrc); const [srcType, setSrcType] = useState(initialSrcType); const [src, setSrc] = useState(initialSrc); const srcSet = srcType === 'fullsize' ? calculateSrcSet(resource) : ''; const imgProps: HTMLProps = getMediaSizePositionProps( resource, width, height, scale, focalX, focalY ); imgProps.crossOrigin = 'anonymous'; useEffect(() => { let timeout: number; let mounted = true; if ( resourceList.get(resourceId)?.type !== ResourceCacheEntryType.Fullsize && resource.src ) { timeout = window.setTimeout(() => { void (async () => { const url: string = getProxiedUrl(resource, resource.src) as string; try { const preloadedImg = await preloadImage({ src: url, srcset: srcSet || undefined, }); if (mounted) { resourceList.set(resource.id, { type: ResourceCacheEntryType.Fullsize, url, }); setSrc(preloadedImg.currentSrc); setSrcType(ResourceCacheEntryType.Fullsize); } } catch { // Ignore } })(); }); } else { setSrc(getProxiedUrl(resource, resource.src)); } return () => { mounted = false; clearTimeout(timeout); }; }, [getProxiedUrl, resource, srcSet, srcType, resourceId]); const showPlaceholder = srcType !== 'fullsize' || resource.isPlaceholder; return ( element={element} mediaRef={ref} showPlaceholder={showPlaceholder} previewMode={previewMode} renderResourcePlaceholder={renderResourcePlaceholder} > {alt} ); } export default ImageDisplay; ================================================ FILE: packages/element-library/src/media/index.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; /** * Internal dependencies */ import { elementWithBorderRadius } from '../shared'; export const CropBox = styled.div` width: 100%; height: 100%; position: relative; overflow: hidden; ${elementWithBorderRadius} &::after { content: ''; display: block; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; } `; export * from './constants'; ================================================ FILE: packages/element-library/src/media/output.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { PAGE_WIDTH, PAGE_HEIGHT, editorPixels, type ElementBox, } from '@googleforcreators/units'; import { getMediaSizePositionProps } from '@googleforcreators/media'; import type { ImageElement, SequenceMediaElement, } from '@googleforcreators/elements'; import type { CSSProperties, ReactNode } from 'react'; interface MediaOutputProps { element: SequenceMediaElement | ImageElement; box: ElementBox; children: ReactNode; 'data-leaf-element': 'true'; } /** * Returns AMP HTML for saving into post content for displaying in the FE. * * @return Rendered component. */ function MediaOutput({ element: { resource, scale, focalX, focalY }, box: { width: vw, height: vh }, children, ...props }: MediaOutputProps) { // Width and height are taken from the basis of 100% taking into account the // aspect ratio. const width = vw; const height = (vh * PAGE_HEIGHT) / PAGE_WIDTH; const mediaProps = getMediaSizePositionProps( resource, width, height, scale, focalX, focalY ); const wrapperStyle: CSSProperties = { position: 'absolute', width: `${editorPixels((mediaProps.width / width) * 100)}%`, height: `${editorPixels((mediaProps.height / height) * 100)}%`, left: `${-editorPixels((mediaProps.offsetX / width) * 100)}%`, top: `${-editorPixels((mediaProps.offsetY / height) * 100)}%`, }; return (
{children}
); } export default MediaOutput; ================================================ FILE: packages/element-library/src/media/scalePanel.tsx ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; import { _x } from '@googleforcreators/i18n'; import { Slider } from '@googleforcreators/design-system'; import { InOverlay } from '@googleforcreators/moveable'; import type { MediaElement } from '@googleforcreators/elements'; const MIN_WIDTH = 165; const HEIGHT = 36; const OFFSET_Y = 8; const HORIZONTAL_PADDING = 8; const Container = styled.div<{ $x: number; $y: number; $width: number; $height: number; }>` position: absolute; left: ${({ $x, $width }) => `${$x + ($width - Math.max($width, MIN_WIDTH)) / 2}px`}; top: ${({ $y, $height }) => `${$y + $height + OFFSET_Y}px`}; width: ${({ $width }) => `${Math.max($width, MIN_WIDTH)}px`}; height: ${HEIGHT}px; background: ${({ theme }) => theme.colors.bg.primary}; border-radius: 8px; padding: 3px ${HORIZONTAL_PADDING}px; margin-top: 8px; `; const ScaleSlider = styled(Slider)<{ width: number }>` width: ${({ width }) => Math.max(width, MIN_WIDTH) - 2 * HORIZONTAL_PADDING}px; `; interface ScalePanelProps { setProperties: (properties: Partial) => void; width: number; height: number; x: number; y: number; scale: number; zIndexCanvas: Record; min?: number; max?: number; } function ScalePanel({ setProperties, width, height, x, y, scale, zIndexCanvas, ...rest }: ScalePanelProps) { return ( {/* @todo: Should maxScale depend on the maximum resolution? Or should that be left up to the helper errors? Both? In either case there'd be maximum bounding scale. */} setProperties({ scale: value } as Partial) } thumbSize={24} suffix={_x('%', 'Percentage', 'web-stories')} {...rest} /> ); } export default ScalePanel; ================================================ FILE: packages/element-library/src/media/textContent.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import { type MediaElement } from '@googleforcreators/elements'; function TextContent({ resource }: MediaElement) { return `image: ${resource.src}`; } export default TextContent; ================================================ FILE: packages/element-library/src/media/util.ts ================================================ /* * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled, { css } from 'styled-components'; export const mediaWithScale = css<{ width: number; height: number; offsetX: number; offsetY: number; }>` width: ${({ width }) => `${width}px`}; height: ${({ height }) => `${height}px`}; left: ${({ offsetX }) => `${-offsetX}px`}; top: ${({ offsetY }) => `${-offsetY}px`}; `; const videoWithScale = css<{ width: number; offsetX: number; offsetY: number; isBackground?: boolean; }>` width: ${({ width }) => `${width}px`}; left: ${({ offsetX }) => `${-offsetX}px`}; top: ${({ offsetY }) => `${-offsetY}px`}; max-width: ${({ isBackground }) => (isBackground ? 'initial' : null)}; `; export function getMediaWithScaleCss({ width, height, offsetX, offsetY, }: { width: number; height: number; offsetX: number; offsetY: number; }) { // @todo: This is a complete duplication of `mediaWithScale` above. But // no other apparent way to execute interpolate `mediaWithScale` dynamically. return `width:${width}px; height:${height}px; left:${-offsetX}px; top:${-offsetY}px;`; } export const getBackgroundStyle = () => { return { minWidth: '100%', minHeight: '100%', maxWidth: 'initial', }; }; // TODO: Display poster as actual with crossorigin attr to avoid CORS issues. export const Video = styled.video.attrs({ crossOrigin: 'anonymous' })` position: absolute; max-width: initial; max-height: initial; height: ${({ height }) => `${height}px`}; background-image: ${({ poster }) => poster && `url("${poster}")`}; background-repeat: no-repeat; background-size: cover; ${videoWithScale} `; export const VideoImage = styled.img.attrs({ crossOrigin: 'anonymous' })` position: absolute; max-height: initial; object-fit: contain; ${videoWithScale} `; ================================================ FILE: packages/element-library/src/media/videoImage.tsx ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * External dependencies */ import styled from 'styled-components'; const Video = styled.video` display: block; height: 21px; width: 21px; border-radius: ${({ theme }) => theme.borders.radius.small}; object-fit: cover; `; function VideoImage({ alt, ...attrs }: { src: string; alt: string }) { return (