diff --git a/blocks/definition/README.md b/blocks/definition/README.md
new file mode 100644
index 00000000..b1a956cf
--- /dev/null
+++ b/blocks/definition/README.md
@@ -0,0 +1,20 @@
+# Definition block
+
+A block to display a term and definition or abbreviation with semantic markup ( `
`, ` `, ` ` etc).
+
+Features:
+
+- Online definition lookup
+- Semantic markup of definitions and abbreviations
+
+### TODO
+
+- [ ] Multiple dictionary sources, and the ability to add one's own API key.
+- [ ] Support a list of definitions, or an accordion style for glossary pages that have lots of definitions in them
+- [ ] Add definition image/thumbnail option
+- [ ] Add audio tag for pronunciation example
+- [ ] A permalink / easy to copy anchor link to the definition from the front-end
+
+![alt text][screenshot]
+
+[screenshot]: screenshot.png "Definition block screenshot"
diff --git a/blocks/definition/editor.scss b/blocks/definition/editor.scss
new file mode 100644
index 00000000..43758d0e
--- /dev/null
+++ b/blocks/definition/editor.scss
@@ -0,0 +1,96 @@
+/**
+ * Editor styles
+ */
+
+.wp-block-a8c-definition {
+ padding: 10px 10px;
+ .wp-block-a8c-definition__term {
+ font-size: 2.5rem;
+ border-bottom: 2px solid #eeeeee;
+ display: block;
+ margin-bottom: 10px;
+ }
+ .wp-block-a8c-definition__definition {
+ padding-left: 0;
+ margin-left: 0;
+ }
+ .wp-block-a8c-definition__term-metadata {
+ padding-left: 10px;
+ font-size: 14px;
+ opacity: 0.8;
+ font-style: italic;
+ font-weight: normal;
+ }
+ .wp-block-a8c-definition__term-text {
+ font-style: normal;
+ }
+ .wp-block-a8c-definition__term-definition {
+ padding-left: 10px;
+ }
+ .wp-block-a8c-definition__term-metadata-item {
+ display: inline-block;
+ margin-right: 8px;
+ }
+ .wp-block-a8c-definition__term-metadata-item:after {
+ content: ']';
+ }
+ .wp-block-a8c-definition__term-metadata-item:before {
+ content: '[';
+ }
+ // Minimal style
+ &.is-style-minimal,
+ &.is-minimal {
+ padding: 0;
+ .wp-block-a8c-definition__term {
+ font-size: 2rem;
+ border-bottom: 0;
+ display: block;
+ margin-bottom: 10px;
+ }
+ .wp-block-a8c-definition__term-metadata {
+ padding-left: 0;
+ font-size: .9rem;
+ font-weight: normal;
+ display: block;
+ }
+ .wp-block-a8c-definition__term-definition {
+ padding-left: 0;
+ }
+ .wp-block-a8c-definition__term-metadata-item:after,
+ .wp-block-a8c-definition__term-metadata-item:before {
+ content: '';
+ }
+ }
+}
+
+.wp-block-a8c-definition__panel-row--definition-settings {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ margin-bottom: 24px;
+
+ .components-base-control {
+ margin-bottom: 4px;
+ }
+
+ .wp-block-a8c-definition__definition-settings-help-text {
+ font-size: 12px;
+ font-style: normal;
+ color: rgb( 117, 117, 117 );
+ }
+}
+
+.wp-block-a8c-definition__search-control-container {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+
+ .wp-block-a8c-definition__search-button {
+ text-decoration: none;
+ }
+
+ .components-button {
+ white-space: normal;
+ }
+}
+
diff --git a/blocks/definition/index.php b/blocks/definition/index.php
new file mode 100644
index 00000000..faf5bca0
--- /dev/null
+++ b/blocks/definition/index.php
@@ -0,0 +1,20 @@
+ 'block-experiments',
+ 'style' => 'block-experiments',
+ 'editor_style' => 'block-experiments-editor',
+ ]
+ );
+
+ wp_set_script_translations( 'block-experiments', 'definition' );
+} );
diff --git a/blocks/definition/screenshot.png b/blocks/definition/screenshot.png
new file mode 100644
index 00000000..b5a12e18
Binary files /dev/null and b/blocks/definition/screenshot.png differ
diff --git a/blocks/definition/src/components/search-results.js b/blocks/definition/src/components/search-results.js
new file mode 100644
index 00000000..d86d3907
--- /dev/null
+++ b/blocks/definition/src/components/search-results.js
@@ -0,0 +1,53 @@
+/**
+ * External dependencies.
+ */
+/**
+ * WordPress dependencies
+ */
+import { Button } from '@wordpress/components';
+
+/**
+ * Search results for dictionary search.
+ *
+ * @param {Object} Props.
+ * @param {string} Props.selectedId
+ * @param {Array} Props.searchResults
+ * @param {Function} Props.onSelectDefinition
+ * @param {string} Props.title
+ * @return {WPElement} Element to render.
+ */
+export default function SearchResults( {
+ selectedId = '',
+ searchResults = [],
+ onSelectDefinition,
+ title,
+} ) {
+ return (
+ <>
+
+ { title }
+
+
+ { searchResults.length &&
+ searchResults.map( ( option ) => (
+
+
+ onSelectDefinition( option.value )
+ }
+ >
+ { option.label }
+
+
+ ) ) }
+
+ >
+ );
+}
diff --git a/blocks/definition/src/components/term-metadata.js b/blocks/definition/src/components/term-metadata.js
new file mode 100644
index 00000000..38214e94
--- /dev/null
+++ b/blocks/definition/src/components/term-metadata.js
@@ -0,0 +1,56 @@
+/**
+ * External dependencies.
+ */
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { findInCollection } from '../utils';
+import { PARTS_OF_SPEECH } from '../constants';
+
+/**
+ * Term metadata to render in both the editor and the save methods.
+ *
+ * @param {Object} Props.
+ * @param {string} Props.partOfSpeech
+ * @param {string} Props.phoneticTranscription
+ * @return {WPElement} Element to render.
+ */
+export function TermMetaData( { partOfSpeech, phoneticTranscription } ) {
+ const shouldShowTermMetaData = !! partOfSpeech || !! phoneticTranscription;
+
+ if ( ! shouldShowTermMetaData ) {
+ return null;
+ }
+ // Find a translated part of speech value from our list or use the raw value.
+ const partOfSpeechTitle =
+ findInCollection( PARTS_OF_SPEECH, 'value', partOfSpeech )?.title ||
+ partOfSpeech;
+
+ return (
+
+ { partOfSpeech && (
+
+ { partOfSpeechTitle }
+
+ ) }
+ { phoneticTranscription && (
+
+ { phoneticTranscription }
+
+ ) }
+
+ );
+}
+
+export default TermMetaData;
diff --git a/blocks/definition/src/constants.js b/blocks/definition/src/constants.js
new file mode 100644
index 00000000..72d3a222
--- /dev/null
+++ b/blocks/definition/src/constants.js
@@ -0,0 +1,55 @@
+/**
+ * External dependencies.
+ */
+import { __ } from '@wordpress/i18n';
+
+export const ABBREVIATION = 'abbreviation';
+
+export const DEFAULT_LOCALE = 'en';
+
+export const PARTS_OF_SPEECH = [
+ {
+ label: __( 'Choose a word class (optional)', 'definition' ),
+ value: '',
+ },
+ {
+ label: __( 'Noun, e.g., Apple', 'definition' ),
+ title: __( 'Noun', 'definition' ),
+ value: 'noun',
+ },
+ {
+ label: __( 'Verb, e.g., Eat', 'definition' ),
+ title: __( 'Verb', 'definition' ),
+ value: 'verb',
+ },
+ {
+ label: __( 'Article, e.g., The', 'definition' ),
+ title: __( 'Article', 'definition' ),
+ value: 'article',
+ },
+ {
+ label: __( 'Pronoun, e.g., Their', 'definition' ),
+ title: __( 'Pronoun', 'definition' ),
+ value: 'pronoun',
+ },
+ {
+ label: __( 'Preposition, e.g., With', 'definition' ),
+ title: __( 'Preposition', 'definition' ),
+ value: 'preposition',
+ },
+ {
+ label: __( 'Adverb, e.g., Quickly', 'definition' ),
+ title: __( 'Adverb', 'definition' ),
+ value: 'adverb',
+ },
+ {
+ label: __( 'Conjunction, e.g., And', 'definition' ),
+ title: __( 'Conjunction', 'definition' ),
+ value: 'conjunction',
+ },
+ {
+ label: __( 'Abbreviation, e.g., PLC', 'definition' ),
+ title: __( 'Abbreviation', 'definition' ),
+ value: ABBREVIATION,
+ },
+];
diff --git a/blocks/definition/src/controls.js b/blocks/definition/src/controls.js
new file mode 100644
index 00000000..bb0f8719
--- /dev/null
+++ b/blocks/definition/src/controls.js
@@ -0,0 +1,308 @@
+/**
+ * External dependencies
+ */
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ Button,
+ Panel,
+ PanelBody,
+ SelectControl,
+ ToggleControl,
+ PanelRow,
+ ExternalLink,
+} from '@wordpress/components';
+import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
+import { useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import SearchResults from './components/search-results';
+import useFetchDefinition from './use-fetch-definition';
+import { isEmpty } from 'lodash';
+import { DictionaryApi } from './lib/dictionary-apis';
+import { getLocaleFromLocaleData } from './utils';
+
+/**
+ * Returns help text for the abbreviation toggle control
+ *
+ * @param {boolean} checked Whether the control is checked or not.
+ * @return {string} The help message.
+ */
+function getToggleAbbreviationHelp( checked ) {
+ return checked
+ ? __(
+ 'Your definition is an abbreviation, e.g., LOL, and is wrapped in an tag',
+ 'definition'
+ )
+ : __(
+ 'Your definition is a whole word or term and not an abbreviation.',
+ 'definition'
+ );
+}
+
+/**
+ * Returns help text for the should show meta toggle control.
+ *
+ * @param {boolean} checked Whether the control is checked or not.
+ * @return {string} The help message.
+ */
+function getToggleShouldShowTermMetaHelp( checked ) {
+ return checked
+ ? __( 'Show parts of speech and other term information.', 'definition' )
+ : __( 'Hide parts of speech and other term information', 'definition' );
+}
+
+/**
+ * Stores and returns a dictionary API object.
+ *
+ * @param {string} key A key identifier for a Dictionary API.
+ * @return {Function} A dictionary class with static member methods.
+ */
+function getDictionaryApiByKey( key = 'dictionaryApi' ) {
+ return {
+ dictionaryApi: DictionaryApi,
+ }[ key ];
+}
+
+/**
+ * Returns editor controls.
+ *
+ * @param root0
+ * @param root0.term
+ * @param root0.isAbbreviation
+ * @param root0.onToggleAbbreviation
+ * @param root0.partOfSpeech
+ * @param root0.onChangePartOfSpeech
+ * @param root0.partsOfSpeechOptions
+ * @param root0.onSelectDefinition
+ * @param root0.shouldShowTermMeta
+ * @param root0.onToggleShouldShowTermMeta
+ * @return {WPElement} Element to render.
+ */
+export default function DefinitionControls( {
+ term,
+ isAbbreviation,
+ onToggleAbbreviation,
+ partOfSpeech,
+ onChangePartOfSpeech,
+ partsOfSpeechOptions,
+ onSelectDefinition,
+ shouldShowTermMeta,
+ onToggleShouldShowTermMeta,
+} ) {
+ const [ shouldShowLanguagePicker, setShouldShowLanguagePicker ] = useState(
+ false
+ );
+ const [ searchLocale, setSearchLocale ] = useState(
+ getLocaleFromLocaleData()
+ );
+ const [ shouldShowSearchResults, setShouldShowSearchResults ] = useState(
+ false
+ );
+ const [ definitionOptions, setDefinitionOptions ] = useState( [] );
+ // We cache the last search term so we compare with incoming `term` prop.
+ const [ searchTerm, setSearchTerm ] = useState( '' );
+ const [ selectedSearchTermId, setSelectedSearchTermId ] = useState( null );
+
+ const dictionaryApi = getDictionaryApiByKey();
+ const {
+ isFetching,
+ definitionData,
+ fetchDefinition,
+ errorMessage,
+ } = useFetchDefinition( {
+ locale: searchLocale,
+ api: dictionaryApi,
+ } );
+ const searchForDefinition = () => {
+ if ( ! term || isFetching ) {
+ return;
+ }
+
+ const strippedTerm = stripHTML( term );
+
+ // Don't perform fetch if the current term already matches the fetched term.
+ if ( strippedTerm === definitionData?.definition ) {
+ setShouldShowSearchResults( true );
+ return;
+ }
+
+ setSearchTerm( strippedTerm );
+ fetchDefinition( strippedTerm );
+ };
+
+ const setDefinitionData = ( indexKey ) => {
+ setSelectedSearchTermId( indexKey );
+ const {
+ definition,
+ partOfSpeech,
+ phoneticTranscription,
+ isAbbreviation,
+ } = dictionaryApi.getSelectedDefinition(
+ definitionData,
+ indexKey.split( '-' )
+ );
+ onSelectDefinition( {
+ definition,
+ partOfSpeech,
+ phoneticTranscription,
+ isAbbreviation,
+ } );
+ };
+
+ const showLocalePicker = () => setShouldShowLanguagePicker( true );
+ const hideLocalePicker = () => setShouldShowLanguagePicker( false );
+
+ // Close the search results if the definition term changes.
+ useEffect( () => {
+ if ( term !== searchTerm || errorMessage ) {
+ setShouldShowSearchResults( false );
+ }
+ }, [ term, searchTerm, errorMessage ] );
+
+ // Set new UI definition data when definitionData from fetch updates.
+ useEffect( () => {
+ if ( ! isEmpty( definitionData ) ) {
+ const newDefinitionOptions = DictionaryApi.getOptionsList(
+ definitionData
+ );
+
+ setDefinitionOptions( newDefinitionOptions );
+
+ if ( newDefinitionOptions.length > 0 ) {
+ setShouldShowSearchResults( true );
+ }
+ }
+ }, [ definitionData ] );
+
+ return (
+
+
+
+
+
+ Not sure? See{ ' ' }
+
+ Parts of Speech
+
+
+
+
+
+
+
+
+
+
+
+
+ { ! term && (
+
+ { ' ' }
+ { __(
+ 'Enter a definition term in the Editor block to search.',
+ 'definition'
+ ) }
+
+ ) }
+ { term && (
+
+ { term && ! shouldShowSearchResults && (
+
+ { sprintf(
+ /* translators: placeholder is a work or term the user wishes to search. */
+ __(
+ 'Search definition for "%s"',
+ 'definition'
+ ),
+ stripHTML( term )
+ ) }
+
+ ) }
+ { errorMessage && (
+
+ { errorMessage }
+
+ ) }
+ { shouldShowSearchResults && (
+
+ ) }
+
+ ) }
+{/*
+ { __( 'Current search locale:' ) }
+
+ { searchLocale }
+
+ { shouldShowLanguagePicker && (
+
+ { dictionaryApi
+ .getSupportedLocales()
+ .map( ( locale ) => (
+ hideLocalePicker() }
+ key={ locale }
+ >
+ { locale }
+
+ ) ) }
+
+ ) }
+ */}
+
+
+ );
+}
diff --git a/blocks/definition/src/edit.js b/blocks/definition/src/edit.js
new file mode 100644
index 00000000..56ec8e93
--- /dev/null
+++ b/blocks/definition/src/edit.js
@@ -0,0 +1,142 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ InspectorControls,
+ RichText,
+ useBlockProps,
+} from '@wordpress/block-editor';
+import { useEffect } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import DefinitionControls from './controls';
+import TermMetaData from './components/term-metadata';
+import { PARTS_OF_SPEECH, ABBREVIATION } from './constants';
+
+/**
+ * Definition Edit method.
+ *
+ * @param props
+ * @param props.attributes
+ * @param props.setAttributes
+ * @return {WPElement} Element to render.
+ */
+export default function DefinitionEdit( { attributes, setAttributes } ) {
+ const {
+ definition,
+ term,
+ isAbbreviation,
+ partOfSpeech,
+ phoneticTranscription,
+ shouldShowTermMeta,
+ } = attributes;
+ const blockProps = useBlockProps( {
+ className: 'wp-block-a8c-definition',
+ } );
+ const onToggleAbbreviation = ( newValue ) => {
+ const newPartOfSpeech =
+ newValue && partOfSpeech !== ABBREVIATION
+ ? ABBREVIATION
+ : partOfSpeech;
+ setAttributes( {
+ isAbbreviation: newValue,
+ partOfSpeech: newPartOfSpeech,
+ } );
+ };
+ const onToggleShouldShowTermMeta = () =>
+ setAttributes( { shouldShowTermMeta: ! shouldShowTermMeta } );
+ const onChangePartOfSpeech = ( newValue ) => {
+ const newIsAbbreviation =
+ newValue === ABBREVIATION && ! isAbbreviation ? true : false;
+ setAttributes( {
+ partOfSpeech: newValue,
+ isAbbreviation: newIsAbbreviation,
+ } );
+ };
+ const setDefinitionData = ( {
+ definition,
+ partOfSpeech,
+ phoneticTranscription,
+ isAbbreviation,
+ } ) => {
+ setAttributes( {
+ definition,
+ partOfSpeech,
+ phoneticTranscription,
+ isAbbreviation,
+ } );
+ };
+ const definitionTagName = isAbbreviation ? 'abbr' : 'dfn';
+
+ // Reset term data if term is deleted.
+ useEffect( () => {
+ if ( ! term ) {
+ setAttributes( {
+ partOfSpeech: '',
+ definition: '',
+ isAbbreviation: false,
+ phoneticTranscription: '',
+ } );
+ }
+ }, [ term ] );
+
+ return (
+ <>
+
+
+
+
+
+
+ setAttributes( { term: newTerm } )
+ }
+ value={ term }
+ multiline={ false }
+ />
+ { shouldShowTermMeta && (
+
+ ) }
+
+
+ setAttributes( { definition: newDefinition } )
+ }
+ value={ definition }
+ />
+
+ >
+ );
+}
diff --git a/blocks/definition/src/index.js b/blocks/definition/src/index.js
new file mode 100644
index 00000000..b73c6c92
--- /dev/null
+++ b/blocks/definition/src/index.js
@@ -0,0 +1,80 @@
+/**
+ * External dependencies.
+ */
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import edit from './edit';
+import save from './save';
+
+/**
+ * Register block type definition.
+ */
+export const registerBlock = () => {
+ registerBlockType( 'a8c/definition', {
+ apiVersion: 2,
+ title: __( 'Definition', 'definition' ),
+ description: __( 'A term and definition block.', 'definition' ),
+ category: 'text',
+ keywords: [ __( 'define', 'definition' ), __( 'term', 'definition' ) ],
+ icon: 'book',
+ attributes: {
+ term: {
+ type: 'string',
+ source: 'html',
+ selector: 'dfn',
+ default: '',
+ },
+ definition: {
+ type: 'string',
+ source: 'html',
+ selector: 'dd',
+ default: '',
+ },
+ shouldShowTermMeta: {
+ type: 'boolean',
+ default: false,
+ },
+ isAbbreviation: {
+ type: 'boolean',
+ default: true,
+ },
+ partOfSpeech: {
+ type: 'string',
+ default: '',
+ },
+ phoneticTranscription: {
+ type: 'string',
+ default: '',
+ },
+ },
+ /* styles: [
+ {
+ name: 'default',
+ label: __( 'Default', 'definition' ),
+ isDefault: true,
+ },
+ {
+ name: 'minimal',
+ label: __( 'Minimal', 'definition' ),
+ },
+ ],*/
+ example: {
+ attributes: {
+ term: __( 'Hot dog', 'definition' ),
+ definition: __(
+ 'A hot dog (also spelled hotdog) is a food consisting of a grilled or steamed sausage served in the slit of a partially sliced bun.',
+ 'definition'
+ ),
+ },
+ },
+ edit,
+ save,
+ } );
+};
diff --git a/blocks/definition/src/lib/dictionary-apis/class-dictionary-api.js b/blocks/definition/src/lib/dictionary-apis/class-dictionary-api.js
new file mode 100644
index 00000000..33f15941
--- /dev/null
+++ b/blocks/definition/src/lib/dictionary-apis/class-dictionary-api.js
@@ -0,0 +1,141 @@
+/**
+ * A collection of static methods to fetch/interpret the results of the dictionaryapi API endpoint.
+ * See: https://dictionaryapi.dev/
+ * Each dictionary source class should have the same interface.
+ */
+
+export default class DictionaryApi {
+ /**
+ * Returns a list of supported locale slugs. These are the slugs that the API accepts.
+ * Any transformations between WordPress and 3rd-party slugs should be done in `getFetchUrl`.
+ *
+ * @return {Array} A list of supported locale slugs.
+ */
+ static getSupportedLocales() {
+ return [
+ 'ar',
+ 'de',
+ 'en',
+ 'en_GB',
+ 'es',
+ 'fr',
+ 'hi',
+ 'it',
+ 'ja',
+ 'ko',
+ 'ru',
+ 'pt-BR',
+ 'tr',
+ ];
+ }
+
+ /**
+ * Returns an iterable collection of objects so we can display a select list etc.
+ *
+ * @param {Object} definitionData The raw JSON results from a successful call to the API.
+ * @return {Array} A collection of options.
+ */
+ static getOptionsList( definitionData ) {
+ const newDefinitionOptions = [];
+ for ( const definitionsIndex in definitionData ) {
+ if (
+ definitionData.hasOwnProperty( definitionsIndex ) &&
+ Array.isArray( definitionData[ definitionsIndex ].meanings )
+ ) {
+ definitionData[ definitionsIndex ].meanings.forEach(
+ ( meaning, meaningsIndex ) => {
+ meaning.definitions.forEach(
+ ( subDefinition, subDefinitionIndex ) => {
+ newDefinitionOptions.push( {
+ value: `${ definitionsIndex }-${ meaningsIndex }-${ subDefinitionIndex }`,
+ label: `${ subDefinition.definition } [${ meaning.partOfSpeech }]`,
+ } );
+ }
+ );
+ }
+ );
+ }
+ }
+ return newDefinitionOptions;
+ }
+
+ /**
+ * For the given indices, fetches the result from the API search results data.
+ *
+ * @param {Object} definitionData The raw JSON results from a successful call to the API.
+ * @param {Array} indexArray An array of indices pointing to a child property of the data. The order must correspond to the depth of the target value.
+ * @return {Object} The properties of the definition. The properties correspond to the expected block attributes.
+ */
+ static getSelectedDefinition( definitionData, indexArray = [] ) {
+ const definition =
+ definitionData[ indexArray[ 0 ] ].meanings[ indexArray[ 1 ] ]
+ .definitions[ indexArray[ 2 ] ].definition;
+ const partOfSpeech =
+ definitionData[ indexArray[ 0 ] ].meanings[ indexArray[ 1 ] ]
+ .partOfSpeech;
+ let isAbbreviation = false;
+ if ( partOfSpeech === 'abbreviation' ) {
+ isAbbreviation = true;
+ }
+ const phoneticTranscription =
+ definitionData[ indexArray[ 0 ] ].phonetics[ indexArray[ 1 ] ]
+ ?.text ||
+ definitionData[ indexArray[ 0 ] ].phonetics[ 0 ]?.text;
+ // TODO: add pronunciation audio. Low prio.
+ //const newPhoneticAudio = definitionData[ indexArray[0] ].phonetics[ indexArray[1] ]?.audio || definitionData[ indexArray[0] ].phonetics[0]?.audio;
+ return {
+ definition,
+ partOfSpeech,
+ phoneticTranscription,
+ isAbbreviation,
+ };
+ }
+
+ /**
+ * Takes a WordPress language code and returns the API's corresponding code.
+ * Not a required on the public interface.
+ *
+ * @param slug
+ * @return {string} The locale code.
+ */
+ static _getApiSlug( slug ) {
+ const supportedLocales = DictionaryApi.getSupportedLocales();
+
+ // Check if there's a direct match.
+ if ( supportedLocales.indexOf( slug ) > -1 ) {
+ return slug;
+ }
+
+ // Return any custom transforms.
+ const wpToApiSlugDictionary = {
+ pt_BR: 'pt-BR',
+ };
+
+ if ( wpToApiSlugDictionary[ slug ] ) {
+ return wpToApiSlugDictionary[ slug ];
+ }
+
+ // Finally check if there's match on the root of any language variants.
+ if ( slug[ 2 ] === '_' ) {
+ slug = slug.split( '_' )[ 0 ];
+ }
+
+ if ( supportedLocales.indexOf( slug ) > -1 ) {
+ return slug;
+ }
+
+ return slug;
+ }
+
+ /**
+ * Returns a concatenated URL to fetch a definition for a given term and language.
+ *
+ * @param {string} term The search term.
+ * @param {string} lang The locale.
+ * @return {string} The URL.
+ */
+ static getFetchUrl( term, lang = 'en' ) {
+ const apiLocale = DictionaryApi._getApiSlug( lang );
+ return `https://api.dictionaryapi.dev/api/v2/entries/${ apiLocale }/${ term }`;
+ }
+}
diff --git a/blocks/definition/src/lib/dictionary-apis/index.js b/blocks/definition/src/lib/dictionary-apis/index.js
new file mode 100644
index 00000000..2a077fe1
--- /dev/null
+++ b/blocks/definition/src/lib/dictionary-apis/index.js
@@ -0,0 +1,3 @@
+import DictionaryApi from './class-dictionary-api';
+
+export { DictionaryApi };
diff --git a/blocks/definition/src/save.js b/blocks/definition/src/save.js
new file mode 100644
index 00000000..3e1f26e6
--- /dev/null
+++ b/blocks/definition/src/save.js
@@ -0,0 +1,53 @@
+/**
+ * External dependencies.
+ */
+import { RichText, useBlockProps } from '@wordpress/block-editor';
+import TermMetaData from './components/term-metadata';
+
+/**
+ * Save method. Renders the frontend markup.
+ *
+ * @param props
+ * @param props.attributes
+ * @return {WPElement} Element to render.
+ */
+export default function save( { attributes } ) {
+ const {
+ definition,
+ term,
+ isAbbreviation,
+ partOfSpeech,
+ phoneticTranscription,
+ shouldShowTermMeta,
+ } = attributes;
+ const blockProps = useBlockProps.save( {
+ className: 'wp-block-a8c-definition',
+ } );
+ const definitionTagName = isAbbreviation ? 'abbr' : 'dfn';
+
+ return (
+
+
+
+ { shouldShowTermMeta && (
+
+ ) }
+
+
+
+ );
+}
diff --git a/blocks/definition/src/use-fetch-definition.js b/blocks/definition/src/use-fetch-definition.js
new file mode 100644
index 00000000..19c5492a
--- /dev/null
+++ b/blocks/definition/src/use-fetch-definition.js
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { DEFAULT_LOCALE } from './constants';
+
+/**
+ * A hook to fetch a dictionary definition from an API.
+ *
+ * @param {string} initialSearchTermValue The initial value of the search term.
+ * @param {string} locale The target language of the definition.
+ * @return {object} A object of methods and properties to access API request data.
+ */
+const useFetchDefinition = ( {
+ initialSearchTermValue = '',
+ locale = DEFAULT_LOCALE,
+ api,
+} ) => {
+ const [ isFetching, setIsFetching ] = useState( false );
+ const [ term, setTerm ] = useState( initialSearchTermValue );
+ const [ definitionData, setDefinitionData ] = useState( {} );
+ const [ errorMessage, setErrorMessage ] = useState( '' );
+ const defaultErrorMessage = __(
+ "Sorry, we couldn't find a definition.",
+ 'definition'
+ );
+
+ useEffect( () => {
+ if ( ! term ) {
+ return;
+ }
+
+ setErrorMessage( '' );
+
+ // TODO: support other locales. Medium priority.
+ // TODO: support other dictionary sources. Low priority.
+ const fetchUrl = api.getFetchUrl( term, locale );
+
+ const fetchResults = async () => {
+ setIsFetching( true );
+
+ const definitionFetch = await window.fetch( fetchUrl )
+ .then( ( response ) => {
+ if ( response.ok ) {
+ return response;
+ }
+ setErrorMessage( defaultErrorMessage );
+ return false;
+ } )
+ .catch( () => {
+ setErrorMessage( defaultErrorMessage );
+ return false;
+ } );
+
+ if ( definitionFetch ) {
+ const definitionResponse = await definitionFetch.json();
+ setDefinitionData( {
+ term,
+ ...definitionResponse
+ } );
+ }
+ setIsFetching( false );
+ };
+
+ fetchResults();
+ }, [ term ] );
+
+ return { isFetching, definitionData, errorMessage, fetchDefinition: setTerm };
+};
+
+export default useFetchDefinition;
diff --git a/blocks/definition/src/utils.js b/blocks/definition/src/utils.js
new file mode 100644
index 00000000..743e7bbd
--- /dev/null
+++ b/blocks/definition/src/utils.js
@@ -0,0 +1,34 @@
+/**
+ * External dependencies.
+ */
+import { getLocaleData } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { DEFAULT_LOCALE } from './constants';
+
+/**
+ * Returns current locale slug.
+ *
+ * @return {string} The locale slug.
+ */
+export function getLocaleFromLocaleData() {
+ const localeData = getLocaleData();
+ return ( localeData && localeData[ '' ]?.lang ) || DEFAULT_LOCALE;
+}
+
+/**
+ * Returns a member from a collection of objects where key === value.
+ *
+ * @param {Array} collection The collection to search.
+ * @param {String} key The property name.
+ * @param {String|Number|Boolean} value The value of the property.
+ * @return {Object|null} The locale slug.
+ */
+export function findInCollection( collection, key, value ) {
+ if ( collection instanceof Object ) {
+ return collection.find( ( item ) => item[ key ] === value );
+ }
+ return null;
+}
diff --git a/blocks/definition/style.scss b/blocks/definition/style.scss
new file mode 100644
index 00000000..1470e139
--- /dev/null
+++ b/blocks/definition/style.scss
@@ -0,0 +1,46 @@
+/**
+ * Editor styles
+ */
+
+.wp-block-a8c-definition__panel-row {
+ display: block;
+ margin-bottom: 20px;
+}
+.wp-block-a8c-definition__search-results-container {
+ background: white;
+ padding: 10px;
+ position: relative;
+}
+.wp-block-a8c-definition__search-control-container {
+ .wp-block-a8c-definition__current-locale,
+ .wp-block-a8c-definition__search-button {
+ padding: 0px 10px;
+ text-decoration: none;
+ height: auto;
+ }
+}
+
+.wp-block-a8c-definition__current-locale {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-end;
+ .wp-block-a8c-definition__current-locale-button {
+ margin-left:8px;
+ }
+}
+
+.wp-block-a8c-definition__search-results-title {
+ margin: 0 0 4px 0;
+ color: grey;
+ text-transform: uppercase;
+ font-size: 11px;
+}
+.wp-block-a8c-definition__search-results-item .wp-block-a8c-definition__search-results-item-button {
+ border: 0;
+ white-space: normal;
+ text-align: left;
+ height: auto;
+}
+.wp-block-a8c-definition__error-message {
+ color: crimson;
+}
diff --git a/editor.scss b/editor.scss
index 1a0f9502..3bb46413 100644
--- a/editor.scss
+++ b/editor.scss
@@ -7,3 +7,4 @@
@import './blocks/starscape/editor.scss';
@import './blocks/waves/editor.scss';
@import './blocks/book/editor.scss';
+@import './blocks/definition/editor.scss';
diff --git a/src/index.js b/src/index.js
index 283c3cd3..760772b6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -30,6 +30,7 @@ import * as sketchBlock from '../blocks/sketch/src';
import * as starscapeBlock from '../blocks/starscape/src';
import * as wavesBlock from '../blocks/waves/src';
import * as bookBlock from '../blocks/book/src';
+import * as definitionBlock from '../blocks/definition/src';
// Instantiate the blocks, adding them to our block category
bauhausCentenaryBlock.registerBlock();
@@ -40,3 +41,4 @@ sketchBlock.registerBlock();
starscapeBlock.registerBlock();
wavesBlock.registerBlock();
bookBlock.registerBlock();
+definitionBlock.registerBlock();
diff --git a/style.scss b/style.scss
index 1cb0e437..a7b98336 100644
--- a/style.scss
+++ b/style.scss
@@ -7,3 +7,4 @@
@import './blocks/starscape/style.scss';
@import './blocks/waves/style.scss';
@import './blocks/book/style.scss';
+@import './blocks/definition/style.scss';