refactor(theme): use JSON-LD instead of microdata for blog structured data (#9669)

Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
John Reilly 2024-02-15 15:25:21 +00:00 committed by GitHub
parent 8abd1899a6
commit 60d9346965
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 348 additions and 68 deletions

View file

@ -4,9 +4,21 @@
"description": "Blog plugin for Docusaurus.", "description": "Blog plugin for Docusaurus.",
"main": "lib/index.js", "main": "lib/index.js",
"types": "src/plugin-content-blog.d.ts", "types": "src/plugin-content-blog.d.ts",
"exports": {
"./lib/*": "./lib/*",
"./src/*": "./src/*",
"./client": {
"type": "./lib/client/index.d.ts",
"default": "./lib/client/index.js"
},
".": {
"types": "./src/plugin-content-blog.d.ts",
"default": "./lib/index.js"
}
},
"scripts": { "scripts": {
"build": "tsc", "build": "tsc --build",
"watch": "tsc --watch", "watch": "tsc --build --watch",
"test:generate-build-snap": "yarn docusaurus build src/__tests__/__fixtures__/website --out-dir build-snap && yarn rimraf src/__tests__/__fixtures__/website/.docusaurus && yarn rimraf src/__tests__/__fixtures__/website/build-snap/assets && git add src/__tests__/__fixtures__/website/build-snap" "test:generate-build-snap": "yarn docusaurus build src/__tests__/__fixtures__/website --out-dir build-snap && yarn rimraf src/__tests__/__fixtures__/website/.docusaurus && yarn rimraf src/__tests__/__fixtures__/website/build-snap/assets && git add src/__tests__/__fixtures__/website/build-snap"
}, },
"repository": { "repository": {

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import useRouteContext from '@docusaurus/useRouteContext';
import type {BlogMetadata} from '@docusaurus/plugin-content-blog';
export function useBlogMetadata(): BlogMetadata {
const routeContext = useRouteContext();
const blogMetadata = routeContext?.data?.blogMetadata;
if (!blogMetadata) {
throw new Error(
"useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
);
}
return blogMetadata as BlogMetadata;
}

View file

@ -42,6 +42,7 @@ import type {
BlogTags, BlogTags,
BlogContent, BlogContent,
BlogPaginated, BlogPaginated,
BlogMetadata,
} from '@docusaurus/plugin-content-blog'; } from '@docusaurus/plugin-content-blog';
export default async function pluginContentBlog( export default async function pluginContentBlog(
@ -182,6 +183,7 @@ export default async function pluginContentBlog(
blogArchiveComponent, blogArchiveComponent,
routeBasePath, routeBasePath,
archiveBasePath, archiveBasePath,
blogTitle,
} = options; } = options;
const {addRoute, createData} = actions; const {addRoute, createData} = actions;
@ -257,6 +259,15 @@ export default async function pluginContentBlog(
), ),
); );
const blogMetadata: BlogMetadata = {
blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
blogTitle,
};
const blogMetadataPath = await createData(
`blogMetadata-${pluginId}.json`,
JSON.stringify(blogMetadata, null, 2),
);
// Create routes for blog entries. // Create routes for blog entries.
await Promise.all( await Promise.all(
blogPosts.map(async (blogPost) => { blogPosts.map(async (blogPost) => {
@ -276,6 +287,9 @@ export default async function pluginContentBlog(
sidebar: aliasedSource(sidebarProp), sidebar: aliasedSource(sidebarProp),
content: metadata.source, content: metadata.source,
}, },
context: {
blogMetadata: aliasedSource(blogMetadataPath),
},
}); });
blogItemsToMetadata[id] = metadata; blogItemsToMetadata[id] = metadata;

View file

@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
/// <reference types="@docusaurus/module-type-aliases" />
declare module '@docusaurus/plugin-content-blog' { declare module '@docusaurus/plugin-content-blog' {
import type {LoadedMDXContent} from '@docusaurus/mdx-loader'; import type {LoadedMDXContent} from '@docusaurus/mdx-loader';
import type {MDXOptions} from '@docusaurus/mdx-loader'; import type {MDXOptions} from '@docusaurus/mdx-loader';
@ -466,6 +468,13 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
blogTagsListPath: string; blogTagsListPath: string;
}; };
export type BlogMetadata = {
/** the path to the base of the blog */
blogBasePath: string;
/** title of the overall blog */
blogTitle: string;
};
export type BlogTags = { export type BlogTags = {
[permalink: string]: BlogTag; [permalink: string]: BlogTag;
}; };
@ -537,6 +546,7 @@ declare module '@theme/BlogPostPage' {
BlogPostFrontMatter, BlogPostFrontMatter,
BlogSidebar, BlogSidebar,
PropBlogPostContent, PropBlogPostContent,
BlogMetadata,
} from '@docusaurus/plugin-content-blog'; } from '@docusaurus/plugin-content-blog';
export type FrontMatter = BlogPostFrontMatter; export type FrontMatter = BlogPostFrontMatter;
@ -548,6 +558,8 @@ declare module '@theme/BlogPostPage' {
readonly sidebar: BlogSidebar; readonly sidebar: BlogSidebar;
/** Content of this post as an MDX component, with useful metadata. */ /** Content of this post as an MDX component, with useful metadata. */
readonly content: Content; readonly content: Content;
/** Metadata about the blog. */
readonly blogMetadata: BlogMetadata;
} }
export default function BlogPostPage(props: Props): JSX.Element; export default function BlogPostPage(props: Props): JSX.Element;
@ -557,6 +569,10 @@ declare module '@theme/BlogPostPage/Metadata' {
export default function BlogPostPageMetadata(): JSX.Element; export default function BlogPostPageMetadata(): JSX.Element;
} }
declare module '@theme/BlogPostPage/StructuredData' {
export default function BlogPostStructuredData(): JSX.Element;
}
declare module '@theme/BlogListPage' { declare module '@theme/BlogListPage' {
import type {Content} from '@theme/BlogPostPage'; import type {Content} from '@theme/BlogPostPage';
import type { import type {
@ -579,6 +595,28 @@ declare module '@theme/BlogListPage' {
export default function BlogListPage(props: Props): JSX.Element; export default function BlogListPage(props: Props): JSX.Element;
} }
declare module '@theme/BlogListPage/StructuredData' {
import type {Content} from '@theme/BlogPostPage';
import type {
BlogSidebar,
BlogPaginatedMetadata,
} from '@docusaurus/plugin-content-blog';
export interface Props {
/** Blog sidebar. */
readonly sidebar: BlogSidebar;
/** Metadata of the current listing page. */
readonly metadata: BlogPaginatedMetadata;
/**
* Array of blog posts included on this page. Every post's metadata is also
* available.
*/
readonly items: readonly {readonly content: Content}[];
}
export default function BlogListPageStructuredData(props: Props): JSX.Element;
}
declare module '@theme/BlogTagsListPage' { declare module '@theme/BlogTagsListPage' {
import type {BlogSidebar} from '@docusaurus/plugin-content-blog'; import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
import type {TagsListItem} from '@docusaurus/utils'; import type {TagsListItem} from '@docusaurus/utils';

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo-client",
"moduleResolution": "bundler",
"module": "esnext",
"target": "esnext",
"rootDir": "src",
"outDir": "lib"
},
"include": ["src/client", "src/*.d.ts"],
"exclude": ["**/__tests__/**"]
}

View file

@ -1,5 +1,6 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"references": [{"path": "./tsconfig.client.json"}],
"compilerOptions": { "compilerOptions": {
"noEmit": false, "noEmit": false,
"incremental": true, "incremental": true,
@ -8,5 +9,5 @@
"outDir": "lib" "outDir": "lib"
}, },
"include": ["src"], "include": ["src"],
"exclude": ["**/__tests__/**"] "exclude": ["src/client", "**/__tests__/**"]
} }

View file

@ -25,9 +25,7 @@ export default function BlogLayout(props: Props): JSX.Element {
className={clsx('col', { className={clsx('col', {
'col--7': hasSidebar, 'col--7': hasSidebar,
'col--9 col--offset-1': !hasSidebar, 'col--9 col--offset-1': !hasSidebar,
})} })}>
itemScope
itemType="https://schema.org/Blog">
{children} {children}
</main> </main>
{toc && <div className="col col--2">{toc}</div>} {toc && <div className="col col--2">{toc}</div>}

View file

@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Head from '@docusaurus/Head';
import {useBlogListPageStructuredData} from '@docusaurus/theme-common';
import type {Props} from '@theme/BlogListPage/StructuredData';
export default function BlogListPageStructuredData(props: Props): JSX.Element {
const structuredData = useBlogListPageStructuredData(props);
return (
<Head>
<script type="application/ld+json">
{JSON.stringify(structuredData)}
</script>
</Head>
);
}

View file

@ -19,6 +19,7 @@ import BlogListPaginator from '@theme/BlogListPaginator';
import SearchMetadata from '@theme/SearchMetadata'; import SearchMetadata from '@theme/SearchMetadata';
import type {Props} from '@theme/BlogListPage'; import type {Props} from '@theme/BlogListPage';
import BlogPostItems from '@theme/BlogPostItems'; import BlogPostItems from '@theme/BlogPostItems';
import BlogListPageStructuredData from '@theme/BlogListPage/StructuredData';
function BlogListPageMetadata(props: Props): JSX.Element { function BlogListPageMetadata(props: Props): JSX.Element {
const {metadata} = props; const {metadata} = props;
@ -54,6 +55,7 @@ export default function BlogListPage(props: Props): JSX.Element {
ThemeClassNames.page.blogListPage, ThemeClassNames.page.blogListPage,
)}> )}>
<BlogListPageMetadata {...props} /> <BlogListPageMetadata {...props} />
<BlogListPageStructuredData {...props} />
<BlogListPageContent {...props} /> <BlogListPageContent {...props} />
</HtmlClassNameProvider> </HtmlClassNameProvider>
); );

View file

@ -6,36 +6,11 @@
*/ */
import React from 'react'; import React from 'react';
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import {useBlogPost} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/BlogPostItem/Container'; import type {Props} from '@theme/BlogPostItem/Container';
export default function BlogPostItemContainer({ export default function BlogPostItemContainer({
children, children,
className, className,
}: Props): JSX.Element { }: Props): JSX.Element {
const { return <article className={className}>{children}</article>;
frontMatter,
assets,
metadata: {description},
} = useBlogPost();
const {withBaseUrl} = useBaseUrlUtils();
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
return (
<article
className={className}
itemProp="blogPost"
itemScope
itemType="https://schema.org/BlogPosting">
{description && <meta itemProp="description" content={description} />}
{image && (
<link itemProp="image" href={withBaseUrl(image, {absolute: true})} />
)}
{keywords.length > 0 && (
<meta itemProp="keywords" content={keywords.join(',')} />
)}
{children}
</article>
);
} }

View file

@ -21,8 +21,7 @@ export default function BlogPostItemContent({
<div <div
// This ID is used for the feed generation to locate the main content // This ID is used for the feed generation to locate the main content
id={isBlogPostPage ? blogPostContainerID : undefined} id={isBlogPostPage ? blogPostContainerID : undefined}
className={clsx('markdown', className)} className={clsx('markdown', className)}>
itemProp="articleBody">
<MDXContent>{children}</MDXContent> <MDXContent>{children}</MDXContent>
</div> </div>
); );

View file

@ -28,31 +28,18 @@ export default function BlogPostItemHeaderAuthor({
<div className={clsx('avatar margin-bottom--sm', className)}> <div className={clsx('avatar margin-bottom--sm', className)}>
{imageURL && ( {imageURL && (
<MaybeLink href={link} className="avatar__photo-link"> <MaybeLink href={link} className="avatar__photo-link">
<img <img className="avatar__photo" src={imageURL} alt={name} />
className="avatar__photo"
src={imageURL}
alt={name}
itemProp="image"
/>
</MaybeLink> </MaybeLink>
)} )}
{name && ( {name && (
<div <div className="avatar__intro">
className="avatar__intro"
itemProp="author"
itemScope
itemType="https://schema.org/Person">
<div className="avatar__name"> <div className="avatar__name">
<MaybeLink href={link} itemProp="url"> <MaybeLink href={link}>
<span itemProp="name">{name}</span> <span>{name}</span>
</MaybeLink> </MaybeLink>
</div> </div>
{title && ( {title && <small className="avatar__subtitle">{title}</small>}
<small className="avatar__subtitle" itemProp="description">
{title}
</small>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -40,11 +40,7 @@ function ReadingTime({readingTime}: {readingTime: number}) {
} }
function Date({date, formattedDate}: {date: string; formattedDate: string}) { function Date({date, formattedDate}: {date: string; formattedDate: string}) {
return ( return <time dateTime={date}>{formattedDate}</time>;
<time dateTime={date} itemProp="datePublished">
{formattedDate}
</time>
);
} }
function Spacer() { function Spacer() {

View file

@ -20,14 +20,8 @@ export default function BlogPostItemHeaderTitle({
const {permalink, title} = metadata; const {permalink, title} = metadata;
const TitleHeading = isBlogPostPage ? 'h1' : 'h2'; const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
return ( return (
<TitleHeading className={clsx(styles.title, className)} itemProp="headline"> <TitleHeading className={clsx(styles.title, className)}>
{isBlogPostPage ? ( {isBlogPostPage ? title : <Link to={permalink}>{title}</Link>}
title
) : (
<Link itemProp="url" to={permalink}>
{title}
</Link>
)}
</TitleHeading> </TitleHeading>
); );
} }

View file

@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Head from '@docusaurus/Head';
import {useBlogPostStructuredData} from '@docusaurus/theme-common';
export default function BlogPostStructuredData(): JSX.Element {
const structuredData = useBlogPostStructuredData();
return (
<Head>
<script type="application/ld+json">
{JSON.stringify(structuredData)}
</script>
</Head>
);
}

View file

@ -13,6 +13,7 @@ import BlogLayout from '@theme/BlogLayout';
import BlogPostItem from '@theme/BlogPostItem'; import BlogPostItem from '@theme/BlogPostItem';
import BlogPostPaginator from '@theme/BlogPostPaginator'; import BlogPostPaginator from '@theme/BlogPostPaginator';
import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata'; import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
import TOC from '@theme/TOC'; import TOC from '@theme/TOC';
import type {Props} from '@theme/BlogPostPage'; import type {Props} from '@theme/BlogPostPage';
import Unlisted from '@theme/Unlisted'; import Unlisted from '@theme/Unlisted';
@ -45,6 +46,7 @@ function BlogPostPageContent({
) : undefined ) : undefined
}> }>
{unlisted && <Unlisted />} {unlisted && <Unlisted />}
<BlogPostItem>{children}</BlogPostItem> <BlogPostItem>{children}</BlogPostItem>
{(nextItem || prevItem) && ( {(nextItem || prevItem) && (
@ -64,6 +66,7 @@ export default function BlogPostPage(props: Props): JSX.Element {
ThemeClassNames.page.blogPostPage, ThemeClassNames.page.blogPostPage,
)}> )}>
<BlogPostPageMetadata /> <BlogPostPageMetadata />
<BlogPostPageStructuredData />
<BlogPostPageContent sidebar={props.sidebar}> <BlogPostPageContent sidebar={props.sidebar}>
<BlogPostContent /> <BlogPostContent />
</BlogPostPageContent> </BlogPostPageContent>

View file

@ -50,7 +50,8 @@
"@docusaurus/core": "3.0.0", "@docusaurus/core": "3.0.0",
"@docusaurus/types": "3.0.0", "@docusaurus/types": "3.0.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"lodash": "^4.17.21" "lodash": "^4.17.21",
"schema-dts": "^1.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",

View file

@ -39,6 +39,11 @@ export {
filterDocCardListItems, filterDocCardListItems,
} from './utils/docsUtils'; } from './utils/docsUtils';
export {
useBlogListPageStructuredData,
useBlogPostStructuredData,
} from './utils/structuredDataUtils';
export {usePluralForm} from './utils/usePluralForm'; export {usePluralForm} from './utils/usePluralForm';
export {useCollapsible, Collapsible} from './components/Collapsible'; export {useCollapsible, Collapsible} from './components/Collapsible';

View file

@ -0,0 +1,169 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {useBaseUrlUtils, type BaseUrlUtils} from '@docusaurus/useBaseUrl';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData';
import {useBlogPost} from '../contexts/blogPost';
import type {
Blog,
BlogPosting,
WithContext,
Person,
ImageObject,
} from 'schema-dts';
import type {
Author,
PropBlogPostContent,
} from '@docusaurus/plugin-content-blog';
import type {DocusaurusConfig} from '@docusaurus/types';
function getBlogPost(
blogPostContent: PropBlogPostContent,
siteConfig: DocusaurusConfig,
withBaseUrl: BaseUrlUtils['withBaseUrl'],
) {
const {assets, frontMatter, metadata} = blogPostContent;
const {date, title, description} = metadata;
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
const blogUrl = `${siteConfig.url}${metadata.permalink}`;
return {
'@type': 'BlogPosting',
'@id': blogUrl,
mainEntityOfPage: blogUrl,
url: blogUrl,
headline: title,
name: title,
description,
datePublished: date,
...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}),
};
}
function getAuthor(authors: Author[]) {
const authorsStructuredData = authors.map(createPersonStructuredData);
return {
author:
authorsStructuredData.length === 1
? authorsStructuredData[0]
: authorsStructuredData,
};
}
function getImage(
image: string | undefined,
withBaseUrl: BaseUrlUtils['withBaseUrl'],
title: string,
) {
return image
? {
image: createImageStructuredData({
imageUrl: withBaseUrl(image, {absolute: true}),
caption: `title image for the blog post: ${title}`,
}),
}
: {};
}
export function useBlogListPageStructuredData(
props: BlogListPageStructuredDataProps,
): WithContext<Blog> {
const {siteConfig} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils();
const {
metadata: {blogDescription, blogTitle, permalink},
} = props;
const url = `${siteConfig.url}${permalink}`;
// details on structured data support: https://schema.org/Blog
return {
'@context': 'https://schema.org',
'@type': 'Blog',
'@id': url,
mainEntityOfPage: url,
headline: blogTitle,
description: blogDescription,
blogPost: props.items.map((blogItem) =>
getBlogPost(blogItem.content, siteConfig, withBaseUrl),
),
};
}
export function useBlogPostStructuredData(): WithContext<BlogPosting> {
const blogMetadata = useBlogMetadata();
const {assets, metadata} = useBlogPost();
const {siteConfig} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils();
const {date, title, description, frontMatter} = metadata;
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];
const url = `${siteConfig.url}${metadata.permalink}`;
// details on structured data support: https://schema.org/BlogPosting
// BlogPosting is one of the structured data types that Google explicitly
// supports: https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': url,
mainEntityOfPage: url,
url,
headline: title,
name: title,
description,
datePublished: date,
...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}),
isPartOf: {
'@type': 'Blog',
'@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
name: blogMetadata.blogTitle,
},
};
}
/** @returns A {@link https://schema.org/Person} constructed from the {@link Author} */
function createPersonStructuredData(author: Author): Person {
return {
'@type': 'Person',
...(author.name ? {name: author.name} : {}),
...(author.title ? {description: author.title} : {}),
...(author.url ? {url: author.url} : {}),
...(author.email ? {email: author.email} : {}),
...(author.imageURL ? {image: author.imageURL} : {}),
};
}
/** @returns A {@link https://schema.org/ImageObject} */
function createImageStructuredData({
imageUrl,
caption,
}: {
imageUrl: string;
caption: string;
}): ImageObject {
return {
'@type': 'ImageObject',
'@id': imageUrl,
url: imageUrl,
contentUrl: imageUrl,
caption,
};
}

View file

@ -75,7 +75,7 @@ export type RouteContext = {
/** /**
* Plugin-specific context data. * Plugin-specific context data.
*/ */
data?: object | undefined; data?: {[key: string]: unknown};
}; };
/** /**

View file

@ -60,7 +60,7 @@ To read more about types of meta tags, visit [the MDN docs](https://developer.mo
Similar to [global metadata](#global-metadata), Docusaurus also allows for the addition of meta-information to individual pages. Follow [this guide](./guides/markdown-features/markdown-features-head-metadata.mdx) for configuring the `<head>` tag. In short: Similar to [global metadata](#global-metadata), Docusaurus also allows for the addition of meta-information to individual pages. Follow [this guide](./guides/markdown-features/markdown-features-head-metadata.mdx) for configuring the `<head>` tag. In short:
```md title="my-markdown-page.md" ```md title="my-markdown-page.mdx"
# A cooking guide # A cooking guide
<head> <head>

View file

@ -453,6 +453,8 @@ export default async function createConfigAsync() {
type: 'all', type: 'all',
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`, copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`,
}, },
blogTitle: 'Docusaurus blog',
blogDescription: 'Read blog posts about Docusaurus from the team',
blogSidebarCount: 'ALL', blogSidebarCount: 'ALL',
blogSidebarTitle: 'All our posts', blogSidebarTitle: 'All our posts',
} satisfies BlogOptions, } satisfies BlogOptions,

View file

@ -14539,6 +14539,11 @@ scheduler@^0.23.0:
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
schema-dts@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/schema-dts/-/schema-dts-1.1.2.tgz#82ccf71b5dcb80065a1cc5941888507a4ce1e44b"
integrity sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==
schema-utils@2.7.0: schema-utils@2.7.0:
version "2.7.0" version "2.7.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7"