Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: load favicon from WordPress server #119

Merged
merged 19 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/late-breads-listen.md
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder, changesets is "dumb" and major|minor|patch is mapped blindly to x.y.z, not real semver

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@snapwp/query": patch
"@snapwp/next": patch
"snapwp": patch
---

feat: load default favicon metadata from WordPress
19 changes: 17 additions & 2 deletions examples/nextjs/starter/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { RootLayout } from '@snapwp/next';
import { RootLayout, generateRootMetaData } from '@snapwp/next';
import type { Metadata } from 'next';
import type { PropsWithChildren } from 'react';

export default function Layout( { children }: { children: React.ReactNode } ) {
export default function Layout( { children }: PropsWithChildren ) {
return (
<RootLayout>
<>{ children }</>
</RootLayout>
);
}

/**
* Generate custom metadata with rootMetadata generated using generateRootMeraData.
*
* @return dynamic metadata generated from generateRootMetaData() and custom logic.
*/
export async function generateMetadata(): Promise< Metadata > {
const rootMetaData = await generateRootMetaData();

return {
...rootMetaData,
};
}
151 changes: 151 additions & 0 deletions packages/next/src/root-layout/icons-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { QueryEngine } from '@snapwp/query';

interface GeneralSettingsProps {
generalSettings: {
siteIcon: {
mediaItemUrl: string | undefined;
mediaDetails: {
sizes: IconData[];
};
};
};
}

interface IconMetaData {
faviconIcons: FormattedIconData[];
appleIcons: FormattedIconData[] | undefined;
msApplicationTileIcon: IconData | undefined;
}

interface IconData {
sourceUrl: string;
height: string;
width: string;
}

interface FormattedIconData {
url: string;
sizes: string;
}

/**
* Fetch site icon data and filter them into categorized formats.
* @see - https://developer.wordpress.org/reference/functions/wp_site_icon/#:~:text=Displays%20site%20icon%20meta%20tags. Reason why we are different resolution icons into individual category.
*
* @todo Refactor for composability alongside SEO metadata patterns
*
* @return Categorized icons.
*/
export const getIcons = async (): Promise< IconMetaData > => {
const settings: GeneralSettingsProps | undefined =
await QueryEngine.getGeneralSettings();

if ( ! settings ) {
return {
faviconIcons: [],
appleIcons: undefined,
msApplicationTileIcon: undefined,
};
}

let fallbackIcons: IconMetaData = {
faviconIcons: [],
appleIcons: undefined,
msApplicationTileIcon: undefined,
};

// Creating fallback icons if siteIcon is present but mediaDetails is not.
if ( settings.generalSettings.siteIcon.mediaItemUrl ) {
fallbackIcons = {
...fallbackIcons,
faviconIcons: [
{
sizes: '512x512',
url: settings.generalSettings.siteIcon.mediaItemUrl,
},
],
};
}

// Return fallback icons if sizes are not present.
if ( ! settings.generalSettings.siteIcon.mediaDetails.sizes ) {
return fallbackIcons;
}

const sizes = settings.generalSettings.siteIcon.mediaDetails.sizes;

// Filter out valid icons
const validIcons: IconData[] = sizes.filter(
( icon ) => !! ( icon.sourceUrl && icon.height && icon.width )
) as IconData[];

// Return fallback icons if no valid icons are found.
if ( ! validIcons.length ) {
return fallbackIcons;
}

// Filter icons by sizes.
const filteredFaviconIcons = filterIconsBySize( validIcons, [
'32x32',
'192x192',
] );

// Format icons into required metadata structure. If filteredFaviconIcons is empty, return # as url of icon.
const formattedFaviconIcons: FormattedIconData[] = filteredFaviconIcons
? formatIcons( filteredFaviconIcons )
: [ { url: '#', sizes: '' } ];

const filteredAppleIcons = filterIconsBySize( validIcons, [ '180x180' ] );

// Format icons into required metadata structure.
const formattedAppleIcons: FormattedIconData[] = filteredAppleIcons
? formatIcons( filteredAppleIcons )
: [];

return {
faviconIcons: formattedFaviconIcons,
appleIcons: formattedAppleIcons,
msApplicationTileIcon: findIconBySize( validIcons, '270x270' ),
};
};

/**
* Filter icons by multiple size formats.
*
* @param icons - List of all available icons.
* @param sizes - Array of sizes in "WxH" format (e.g., "32x32").
* @return Filtered list of icons.
*/
export const filterIconsBySize = (
icons: IconData[],
sizes: string[]
): IconData[] =>
icons.filter( ( icon ) =>
sizes.includes( `${ icon.width }x${ icon.height }` )
);

/**
* Find a single icon matching the specified size.
*
* @param icons - List of icons.
* @param size - Size in "WxH" format (e.g., "270x270").
* @return The first matching icon or undefined.
*/
export const findIconBySize = (
icons: IconData[],
size: string
): IconData | undefined =>
icons.find( ( icon ) => `${ icon.width }x${ icon.height }` === size );

/**
* Format icons into the required metadata structure.
*
* @param icons - List of icons.
*
* @return Formatted list of icons.
*/
const formatIcons = ( icons: IconData[] ): FormattedIconData[] =>
icons.map( ( { sourceUrl, width, height } ) => ( {
url: sourceUrl,
sizes: `${ width }x${ height }`,
} ) );
31 changes: 30 additions & 1 deletion packages/next/src/root-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { PropsWithChildren } from 'react';
import { QueryEngine } from '@snapwp/query';
import { GlobalHead } from './global-head';
import { getIcons } from './icons-metadata';
import type { Metadata } from 'next';
import type { PropsWithChildren } from 'react';

export type RootLayoutProps = {
getGlobalStyles?: ( typeof QueryEngine )[ 'getGlobalStyles' ];
Expand Down Expand Up @@ -30,3 +32,30 @@ export async function RootLayout( {
</html>
);
}

/**
* Generate and return root metadata, including icons and other metadata.
*
* @return Merged metadata.
*/
export async function generateRootMetaData(): Promise< Metadata > {
/**
* Fetch icons in required format, apply faviconIcons and apple touch icons in icons metadata property while apply msapplication-TileImage in other metadata property.
*
* @todo Review composability when implementing SEO metadata
*/
const { faviconIcons, appleIcons, msApplicationTileIcon } =
await getIcons();

return {
icons: {
icon: faviconIcons,
apple: appleIcons,
},
other: {
...( msApplicationTileIcon && {
'msapplication-TileImage': msApplicationTileIcon.sourceUrl,
} ),
},
};
}
15 changes: 15 additions & 0 deletions packages/query/src/queries/get-general-settings.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
query GetGeneralSettings {
generalSettings {
siteIcon {
id
mediaDetails {
sizes {
width
height
sourceUrl
}
}
mediaItemUrl
}
}
}
38 changes: 38 additions & 0 deletions packages/query/src/query-engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getGraphqlUrl, getConfig } from '@snapwp/core/config';
import {
GetCurrentTemplateDocument,
GetGeneralSettingsDocument,
GetGlobalStylesDocument,
} from '@graphqlTypes/graphql';
import {
Expand All @@ -14,6 +15,7 @@ import {
import parseTemplate from '@/utils/parse-template';
import parseGlobalStyles from '@/utils/parse-global-styles';
import { Logger, type GlobalHeadProps } from '@snapwp/core';
import parseGeneralSettings from '@/utils/parse-general-settings';

/**
* Singleton class to handle GraphQL queries using Apollo.
Expand Down Expand Up @@ -88,6 +90,42 @@ export class QueryEngine {
}
};

/**
* Fetches the general settings, like favicon icon.
*
* @return General settings data.
*/
static getGeneralSettings = async () => {
if ( ! QueryEngine.isClientInitialized ) {
QueryEngine.initialize();
}

try {
const data = await QueryEngine.apolloClient.query( {
query: GetGeneralSettingsDocument,
fetchPolicy: 'network-only', // @todo figure out a caching strategy, instead of always fetching from network
errorPolicy: 'all',
} );

return parseGeneralSettings( data );
} catch ( error ) {
if ( error instanceof ApolloError ) {
logApolloErrors( error );

// If there are networkError throw the error with proper message.
if ( error.networkError ) {
// Throw the error with proper message.
throw new Error(
getNetworkErrorMessage( error.networkError )
);
}
}

// If error is not an instance of ApolloError, throw the error again.
throw error;
}
};

/**
* Fetches blocks, scripts and styles for the given uri.
* @param uri - The URL of the seed node.
Expand Down
67 changes: 67 additions & 0 deletions packages/query/src/utils/parse-general-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Logger } from '@snapwp/core';
import type { GetGeneralSettingsQuery } from '@graphqlTypes/graphql';
import type { ApolloQueryResult } from '@apollo/client';

/**
* @param queryData - The data fetched from the general settings query.
*
* @throws Throws an error if the query data is missing or invalid.
*
* @return An object containing parsed general settings data.
*/
export default function parseGeneralSettings(
queryData: ApolloQueryResult< GetGeneralSettingsQuery >
) {
if ( queryData.errors?.length ) {
queryData.errors?.forEach( ( error ) => {
Logger.error( `Error fetching global styles: ${ error }` );
} );
}

// If queryData does not have generalSettings or siteIcon, return undefined because without siteIcon, generalSettings is not valid.
if ( ! queryData.data.generalSettings?.siteIcon ) {
return undefined;
}

const sizes:
| undefined
| { sourceUrl: string; height: string; width: string }[] = [];

// If mediaDetails and sizes are present, parse the sizes.
if ( queryData.data.generalSettings.siteIcon.mediaDetails?.sizes ) {
queryData.data.generalSettings.siteIcon.mediaDetails.sizes.forEach(
( size ) => {
// If size is null or undefined skip the iteration.
if ( ! size ) {
return;
}

// If sourceUrl, height and width are not present, skip the iteration.
if ( ! ( size.sourceUrl && size.height && size.width ) ) {
return;
}

sizes.push( {
sourceUrl: size.sourceUrl,
height: size.height,
width: size.width,
} );
}
);
}

// MediaItemUrl and mediaDetails are optional fields. Meaning if either of them is there, it should be returned.
return {
generalSettings: {
siteIcon: {
// If mediaItemUrl is not present, return undefined because it is an optional field.
mediaItemUrl:
queryData.data.generalSettings.siteIcon.mediaItemUrl ||
undefined,
mediaDetails: {
sizes,
},
},
},
};
}