diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..fa1b908 --- /dev/null +++ b/.distignore @@ -0,0 +1,14 @@ +*.lock +.distignore +.editorconfig +.eslintrc +.git +.gitattributes +.github +.gitignore +.stylelintrc.json +.wordpress-org +node_modules +package-lock.json +package.json +phpcs.xml diff --git a/.github/workflows/wordpress-plugin-check.yml b/.github/workflows/wordpress-plugin-check.yml index 877b81d..515d98a 100644 --- a/.github/workflows/wordpress-plugin-check.yml +++ b/.github/workflows/wordpress-plugin-check.yml @@ -19,10 +19,13 @@ jobs: with: php-version: latest coverage: none - tools: wp-cli + tools: wp-cli:v2.12 + + - name: Install no-dev dependencies only + run: composer install --no-dev -o - name: Install latest version of dist-archive-command - run: wp package install wp-cli/dist-archive-command:@stable + run: wp package install wp-cli/dist-archive-command:v3.1.0 - name: Build plugin run: | @@ -33,4 +36,6 @@ jobs: - name: Run plugin check uses: wordpress/plugin-check-action@v1 with: + ignore-codes: | + missing_direct_file_access_protection build-dir: ./tmp-build/${{ github.event.repository.name }} diff --git a/lib/PostType/Website.php b/lib/PostType/Website.php index afd9473..82579a1 100644 --- a/lib/PostType/Website.php +++ b/lib/PostType/Website.php @@ -63,9 +63,10 @@ public function register_post_type() { 'show_ui' => true, 'show_in_nav_menus' => true, 'supports' => [ + 'custom-fields', 'editor', + 'thumbnail', 'title', - 'custom-fields', ], 'has_archive' => true, 'rewrite' => true, diff --git a/package.json b/package.json index 285fb29..24f981a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:unit": "wp-scripts test-unit-js" }, "devDependencies": { + "@wordpress/icons": "^11.7.0", "@wordpress/scripts": "^31.4.0", "postcss-prefix-selector": "^2.1.1" }, diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..ea69301 --- /dev/null +++ b/readme.txt @@ -0,0 +1,28 @@ +=== Webring === + +Contributors: Kau-Boy +Stable tag: 1.0.0 +Tested up to: 6.9 +Requires at least: 6.8 +Requires PHP: 7.4 +License: GPLv3 +License URI: https://www.gnu.org/licenses/gpl-3.0.txt +Tags: webring, fediverse, open web, blogroll + +Create and manage webrings on your WordPress site. + +== Description == + +Create and manage webrings on your WordPress site with a dedicated +custom post type for member websites. You can optionally organize +entries with categories to keep your webrings structured and easy +to maintain. + +The plugin also provides a block for displaying the HTML snippet +visitors can use to join or link to your webrings, plus a block +that lists all websites currently included in a webring. + +== Changelog == + += 1.0.0 = +* First stable version diff --git a/src/block/html-snippet/render.php b/src/block/html-snippet/render.php index ac80134..eabb9bb 100644 --- a/src/block/html-snippet/render.php +++ b/src/block/html-snippet/render.php @@ -13,6 +13,8 @@ * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render */ +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound + $webring_name = $attributes['webringName'] ?? 'webring'; $show_copy_instructions = $attributes['showCopyInstructions'] ?? true; $show_copy_button = $attributes['showCopyButton'] ?? true; diff --git a/src/block/website-list/block.json b/src/block/website-list/block.json new file mode 100644 index 0000000..f659f87 --- /dev/null +++ b/src/block/website-list/block.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "webring/website-list", + "version": "0.1.0", + "title": "Webring Website List", + "category": "widgets", + "icon": "smiley", + "description": "Example block scaffolded with Create Block tool.", + "example": {}, + "supports": { + "anchor": true, + "align": true, + "html": false, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true, + "link": true + } + }, + "spacing": { + "margin": true, + "padding": true + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": true, + "color": true, + "width": true, + "style": true + } + } + }, + "attributes": { + "categories": { + "type": "array", + "items": { + "type": "object" + } + }, + "postLayout": { + "type": "string", + "default": "list" + }, + "columns": { + "type": "number", + "default": 3 + }, + "displayFeaturedImage": { + "type": "boolean", + "default": false + }, + "featuredImageAlign": { + "type": "string", + "enum": [ "left", "center", "right" ] + }, + "featuredImageSizeSlug": { + "type": "string", + "default": "thumbnail" + }, + "featuredImageSizeWidth": { + "type": "number" + }, + "featuredImageSizeHeight": { + "type": "number" + }, + "addLinkToFeaturedImage": { + "type": "boolean", + "default": false + } + }, + "textdomain": "webring", + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css", + "style": "file:./style-index.css", + "render": "file:./render.php" +} diff --git a/src/block/website-list/edit.js b/src/block/website-list/edit.js new file mode 100644 index 0000000..cb398a8 --- /dev/null +++ b/src/block/website-list/edit.js @@ -0,0 +1,444 @@ +/** + * WordPress dependencies + */ +import { + QueryControls, + RangeControl, + ToggleControl, + ToolbarGroup, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { + InspectorControls, + BlockControls, + __experimentalImageSizeControl as ImageSizeControl, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { + list, + grid, + alignNone, + positionLeft, + positionCenter, + positionRight, +} from '@wordpress/icons'; +import { store as coreStore } from '@wordpress/core-data'; +import ServerSideRender from '@wordpress/server-side-render'; + +/** + * Module Constants + */ +const CATEGORIES_LIST_QUERY = { + per_page: -1, + _fields: 'id,name', + context: 'view', +}; + +const imageAlignmentOptions = [ + { + value: 'none', + icon: alignNone, + label: __( 'None' ), + }, + { + value: 'left', + icon: positionLeft, + label: __( 'Left' ), + }, + { + value: 'center', + icon: positionCenter, + label: __( 'Center' ), + }, + { + value: 'right', + icon: positionRight, + label: __( 'Right' ), + }, +]; + +/** + * Retrieves the translation of text. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/ + */ +import { __, _x } from '@wordpress/i18n'; + +/** + * React hook that is used to mark the block wrapper element. + * It provides all the necessary props like the class name. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops + */ +import { useBlockProps } from '@wordpress/block-editor'; + +/** + * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. + * Those files can contain any CSS code that gets applied to the editor. + * + * @see https://www.npmjs.com/package/@wordpress/scripts#using-css + */ +import './editor.scss'; + +function Controls( { attributes, setAttributes } ) { + const { + categories, + selectedAuthor, + displayFeaturedImage, + displayWebsiteUrl, + postLayout, + columns, + featuredImageAlign, + featuredImageSizeSlug, + featuredImageSizeWidth, + featuredImageSizeHeight, + addLinkToFeaturedImage, + } = attributes; + const { + imageSizes, + defaultImageWidth, + defaultImageHeight, + categoriesList, + } = useSelect( + ( select ) => { + const { getEntityRecords } = select( coreStore ); + const settings = select( blockEditorStore ).getSettings(); + + return { + defaultImageWidth: + settings.imageDimensions?.[ featuredImageSizeSlug ] + ?.width ?? 0, + defaultImageHeight: + settings.imageDimensions?.[ featuredImageSizeSlug ] + ?.height ?? 0, + imageSizes: settings.imageSizes, + categoriesList: getEntityRecords( + 'taxonomy', + 'webring_category', + CATEGORIES_LIST_QUERY + ), + }; + }, + [ featuredImageSizeSlug ] + ); + + const imageSizeOptions = imageSizes + .filter( ( { slug } ) => slug !== 'full' ) + .map( ( { name, slug } ) => ( { + value: slug, + label: name, + } ) ); + const categorySuggestions = + categoriesList?.reduce( + ( accumulator, category ) => ( { + ...accumulator, + [ category.name ]: category, + } ), + {} + ) ?? {}; + const selectCategories = ( tokens ) => { + const hasNoSuggestion = tokens.some( + ( token ) => + typeof token === 'string' && !categorySuggestions[ token ] + ); + if (hasNoSuggestion) { + return; + } + // Categories that are already will be objects, while new additions will be strings (the name). + // allCategories normalize the array so that they are all objects. + const allCategories = tokens.map( ( token ) => { + return typeof token === 'string' + ? categorySuggestions[ token ] + : token; + } ); + // We do nothing if the category is not selected from suggestions. + if (allCategories.includes( null )) { + return false; + } + setAttributes( { categories: allCategories } ); + }; + + return ( + <> + + setAttributes( { + displayWebsiteUrl: false, + } ) + } + > + !!displayWebsiteUrl } + label={ __( 'Display website URL' ) } + onDeselect={ () => + setAttributes( { displayWebsiteUrl: false } ) + } + isShownByDefault + > + + setAttributes( { displayWebsiteUrl: value } ) + } + /> + + + + setAttributes( { + displayFeaturedImage: false, + featuredImageAlign: undefined, + featuredImageSizeSlug: 'thumbnail', + featuredImageSizeWidth: null, + featuredImageSizeHeight: null, + addLinkToFeaturedImage: false, + } ) + } + > + !!displayFeaturedImage } + label={ __( 'Display featured image' ) } + onDeselect={ () => + setAttributes( { displayFeaturedImage: false } ) + } + isShownByDefault + > + + setAttributes( { displayFeaturedImage: value } ) + } + /> + + { displayFeaturedImage && ( + <> + + featuredImageSizeSlug !== 'thumbnail' || + featuredImageSizeWidth !== null || + featuredImageSizeHeight !== null + } + label={ __( 'Image size' ) } + onDeselect={ () => + setAttributes( { + featuredImageSizeSlug: 'thumbnail', + featuredImageSizeWidth: null, + featuredImageSizeHeight: null, + } ) + } + isShownByDefault + > + { + const newAttrs = {}; + if (value.hasOwnProperty( 'width' )) { + newAttrs.featuredImageSizeWidth = + value.width; + } + if (value.hasOwnProperty( 'height' )) { + newAttrs.featuredImageSizeHeight = + value.height; + } + setAttributes( newAttrs ); + } } + slug={ featuredImageSizeSlug } + width={ featuredImageSizeWidth } + height={ featuredImageSizeHeight } + imageWidth={ defaultImageWidth } + imageHeight={ defaultImageHeight } + imageSizeOptions={ imageSizeOptions } + imageSizeHelp={ __( + 'Select the size of the source image.' + ) } + onChangeImage={ ( value ) => + setAttributes( { + featuredImageSizeSlug: value, + featuredImageSizeWidth: undefined, + featuredImageSizeHeight: undefined, + } ) + } + /> + + !!featuredImageAlign } + label={ __( 'Image alignment' ) } + onDeselect={ () => + setAttributes( { + featuredImageAlign: undefined, + } ) + } + isShownByDefault + > + + setAttributes( { + featuredImageAlign: + value !== 'none' + ? value + : undefined, + } ) + } + > + { imageAlignmentOptions.map( + ( { value, icon, label } ) => { + return ( + + ); + } + ) } + + + !!addLinkToFeaturedImage } + label={ __( 'Add link to featured image' ) } + onDeselect={ () => + setAttributes( { + addLinkToFeaturedImage: false, + } ) + } + isShownByDefault + > + + setAttributes( { + addLinkToFeaturedImage: value, + } ) + } + /> + + + ) } + + + setAttributes( { + categories: undefined, + selectedAuthor: undefined, + columns: 3, + } ) + } + > + + categories?.length > 0 || + !!selectedAuthor + } + label={ __( 'Sort and filter' ) } + onDeselect={ () => + setAttributes( { + categories: undefined, + selectedAuthor: undefined, + } ) + } + isShownByDefault + > + + + + { postLayout === 'grid' && ( + columns !== 3 } + label={ __( 'Columns' ) } + onDeselect={ () => + setAttributes( { + columns: 3, + } ) + } + isShownByDefault + > + + setAttributes( { columns: value } ) + } + min={ 2 } + max={ 6 } + required + /> + + ) } + + + ) +} + +/** + * The edit function describes the structure of your block in the context of the + * editor. This represents what the editor will render when the block is used. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit + * + * @return {Element} Element to render. + */ +export default function Edit( { attributes, setAttributes } ) { + const { + postLayout, + } = attributes; + console.log( `postLayout: ${ postLayout }` ); + + const inspectorControls = ( + + + + ); + + const layoutControls = [ + { + icon: list, + title: _x( 'List view', 'Latest posts block display setting' ), + onClick: () => setAttributes( { postLayout: 'list' } ), + isActive: postLayout === 'list', + }, + { + icon: grid, + title: _x( 'Grid view', 'Latest posts block display setting' ), + onClick: () => setAttributes( { postLayout: 'grid' } ), + isActive: postLayout === 'grid', + }, + ]; + + return ( + <> + { inspectorControls } + + + + +
+ +
+ + ); +} diff --git a/src/block/website-list/editor.scss b/src/block/website-list/editor.scss new file mode 100644 index 0000000..10dde32 --- /dev/null +++ b/src/block/website-list/editor.scss @@ -0,0 +1,29 @@ +/** + * The following styles get applied inside the editor only. + * + * Replace them with your own styles or remove the file completely. + */ +div.wp-block-webring-website-list { + padding: 0; +} + +.wp-block-webring-website-list { + // Apply overflow for post items, so any floated featured images won't crop the focus style. + > li { + overflow: hidden; + } +} + +.wp-block-webring-website-list li a > div { + display: inline; +} + +:root :where(.wp-block-webring-website-list) { + padding-left: 2.5em; +} +:root { + :where(.wp-block-webring-website-list.is-grid), + :where(.wp-block-webring-website-list__list) { + padding-left: 0; + } +} diff --git a/src/block/website-list/index.js b/src/block/website-list/index.js new file mode 100644 index 0000000..d82621b --- /dev/null +++ b/src/block/website-list/index.js @@ -0,0 +1,33 @@ +/** + * Registers a new block provided a unique name and an object defining its behavior. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. + * All files containing `style` keyword are bundled together. The code used + * gets applied both to the front of your site and to the editor. + * + * @see https://www.npmjs.com/package/@wordpress/scripts#using-css + */ +import './style.scss'; + +/** + * Internal dependencies + */ +import Edit from './edit'; +import metadata from './block.json'; + +/** + * Every block starts by registering a new block type definition. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ + */ +registerBlockType( metadata.name, { + /** + * @see ./edit.js + */ + edit: Edit, +} ); diff --git a/src/block/website-list/render.php b/src/block/website-list/render.php new file mode 100644 index 0000000..83e4b86 --- /dev/null +++ b/src/block/website-list/render.php @@ -0,0 +1,128 @@ + 'webring_website', + 'post_status' => 'publish', +]; + +if ( ! empty( $attributes['categories'] ) ) { + $args['tax_query'] = [ + [ + 'taxonomy' => 'webring_category', + 'field' => 'term_id', + 'terms' => array_column( $attributes['categories'], 'id' ), + ], + ]; +} + +$query = new WP_Query(); +$webring_websites = $query->query( $args ); + +if ( empty( $webring_websites ) ) { + if ( ! wp_is_serving_rest_request() ) { + return; + } + + printf( + '
%s
', + esc_html__( 'No websites found. Try to change your filters', 'webring' ), + ); + + return; +} else { + if ( isset( $attributes['displayFeaturedImage'] ) && $attributes['displayFeaturedImage'] ) { + update_post_thumbnail_cache( $query ); + } + + $list_items_markup = ''; + + foreach ( $webring_websites as $website ) { + $website_link = get_post_meta( $website, 'webring_website_url' ); + $website_title = get_the_title( $website ); + + if ( ! $website_title ) { + $website_title = __( '(no title)', 'webring' ); + } + + $list_items_markup .= '
  • '; + + if ( $attributes['displayFeaturedImage'] && has_post_thumbnail( $website ) ) { + $image_style = ''; + if ( isset( $attributes['featuredImageSizeWidth'] ) ) { + $image_style .= sprintf( 'max-width:%spx;', $attributes['featuredImageSizeWidth'] ); + } + if ( isset( $attributes['featuredImageSizeHeight'] ) ) { + $image_style .= sprintf( 'max-height:%spx;', $attributes['featuredImageSizeHeight'] ); + } + + $image_classes = 'wp-block-webring-website-list__featured-image'; + if ( isset( $attributes['featuredImageAlign'] ) ) { + $image_classes .= ' align' . $attributes['featuredImageAlign']; + } + + $featured_image = get_the_post_thumbnail( + $website, + $attributes['featuredImageSizeSlug'], + [ + 'style' => esc_attr( $image_style ), + ] + ); + if ( $attributes['addLinkToFeaturedImage'] ) { + $featured_image = sprintf( + '%3$s', + esc_url( $website_link ), + esc_attr( $website_title ), + $featured_image + ); + } + $list_items_markup .= sprintf( + '
    %2$s
    ', + esc_attr( $image_classes ), + $featured_image + ); + } + + $list_items_markup .= sprintf( + '%2$s', + esc_url( $website_link ), + $website_title + ); + + $list_items_markup .= "
  • \n"; + } + + $classes = [ 'wp-block-webring-website-list__list' ]; + if ( isset( $attributes['postLayout'] ) && 'grid' === $attributes['postLayout'] ) { + $classes[] = 'is-grid'; + } + if ( isset( $attributes['columns'] ) && 'grid' === $attributes['postLayout'] ) { + $classes[] = 'columns-' . $attributes['columns']; + } + if ( isset( $attributes['style']['elements']['link']['color']['text'] ) ) { + $classes[] = 'has-link-color'; + } + + $wrapper_attributes = get_block_wrapper_attributes( [ 'class' => implode( ' ', $classes ) ] ); + + // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped + printf( + '', + $wrapper_attributes, + $list_items_markup + ); +} diff --git a/src/block/website-list/style.scss b/src/block/website-list/style.scss new file mode 100644 index 0000000..d407dee --- /dev/null +++ b/src/block/website-list/style.scss @@ -0,0 +1,100 @@ +/** + * The following styles get applied both on the front of your site + * and in the editor. + * + * Replace them with your own styles or remove the file completely. + */ + +@use "@wordpress/base-styles/mixins" as *; + +.wp-block-webring-website-list { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; + + &.alignleft { + /*rtl:ignore*/ + margin-right: 2em; + } + &.alignright { + /*rtl:ignore*/ + margin-left: 2em; + } + &.wp-block-webring-website-list__list { + list-style: none; + + li { + clear: both; + overflow-wrap: break-word; + } + } + + &.is-grid { + display: flex; + flex-wrap: wrap; + + li { + margin: 0 1.25em 1.25em 0; + width: 100%; + } + } + + @include break-small { + @for $i from 2 through 6 { + &.columns-#{ $i } li { + width: calc((100% / #{$i}) - 1.25em + (1.25em / #{$i})); + + &:nth-child(#{ $i }n) { + margin-right: 0; + } + } + } + } +} + +:root { + :where(.wp-block-webring-website-list.is-grid) { + padding: 0; + } + :where(.wp-block-webring-website-list.wp-block-webring-website-list__list) { + padding-left: 0; + } +} + +.wp-block-webring-website-list__post-date, +.wp-block-webring-website-list__post-author { + display: block; + font-size: 0.8125em; +} + +.wp-block-webring-website-list__post-excerpt, +.wp-block-webring-website-list__post-full-content { + margin-top: 0.5em; + margin-bottom: 1em; +} + +.wp-block-webring-website-list__featured-image { + a { + display: inline-block; + } + img { + height: auto; + width: auto; + max-width: 100%; + } + &.alignleft { + /*rtl:ignore*/ + margin-right: 1em; + /*rtl:ignore*/ + float: left; + } + &.alignright { + /*rtl:ignore*/ + margin-left: 1em; + /*rtl:ignore*/ + float: right; + } + &.aligncenter { + margin-bottom: 1em; + text-align: center; + } +}