feat(website): new plugin to load CHANGELOG and render as blog (#6414)

* feat(website): new plugin to load CHANGELOG and render as blog

* use CJS

* move footer links

* better design

* fixes

* correctly order posts

* add authors

* Add axios

* Update styles

* oops

* oops

* add expand button

* back to index page link

* fix styles

* add feed options

* fix

* fix

* Add fallback

* fix

* fixes
This commit is contained in:
Joshua Chen 2022-01-27 23:17:31 +08:00 committed by GitHub
parent 5c447b1ca3
commit f6ff6474bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 845 additions and 48 deletions

View file

@ -0,0 +1,158 @@
/**
* 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.
*/
/* eslint-disable import/no-extraneous-dependencies */
const path = require('path');
const fs = require('fs-extra');
const pluginContentBlog = require('@docusaurus/plugin-content-blog');
const {aliasedSitePath, docuHash, normalizeUrl} = require('@docusaurus/utils');
const syncAvatars = require('./syncAvatars');
/**
* Multiple versions may be published on the same day, causing the order to be
* the reverse. Therefore, our publish time has a "fake hour" to order them.
*/
const publishTimes = new Set();
/** @type {Record<string, {name: string, url: string, alias: string, imageURL: string}>} */
const authorsMap = {};
/**
* @param {string} section
*/
function processSection(section) {
const title = section
.match(/\n## .*/)?.[0]
.trim()
.replace('## ', '');
if (!title) {
return null;
}
const content = section
.replace(/\n## .*/, '')
.trim()
.replace('running_woman', 'running');
let authors = content.match(/## Committers: \d+.*/ms);
if (authors) {
authors = authors[0]
.match(/- .*/g)
.map(
(line) =>
line.match(
/- (?:(?<name>.*?) \()?\[@(?<alias>.*)\]\((?<url>.*?)\)\)?/,
).groups,
)
.map((author) => ({
...author,
name: author.name ?? author.alias,
imageURL: `./img/${author.alias}.png`,
}))
.sort((a, b) => a.url.localeCompare(b.url));
authors.forEach((author) => {
authorsMap[author.alias] = author;
});
}
let hour = 20;
const date = title.match(/ \((.*)\)/)[1];
while (publishTimes.has(`${date}T${hour}:00`)) {
hour -= 1;
}
publishTimes.add(`${date}T${hour}:00`);
return {
title: title.replace(/ \(.*\)/, ''),
content: `---
date: ${`${date}T${hour}:00`}${
authors
? `
authors:
${authors.map((author) => ` - '${author.alias}'`).join('\n')}`
: ''
}
---
# ${title.replace(/ \(.*\)/, '')}
<!-- truncate -->
${content.replace(/####/g, '##')}`,
};
}
/**
* @param {import('@docusaurus/types').LoadContext} context
* @returns {import('@docusaurus/types').Plugin}
*/
async function ChangelogPlugin(context, options) {
const generateDir = path.join(context.siteDir, 'changelog/source');
const blogPlugin = await pluginContentBlog.default(context, {
...options,
path: generateDir,
id: 'changelog',
blogListComponent: '@theme/ChangelogList',
blogPostComponent: '@theme/ChangelogPage',
});
const changelogPath = path.join(__dirname, '../../../../CHANGELOG.md');
return {
...blogPlugin,
name: 'changelog-plugin',
async loadContent() {
const fileContent = await fs.readFile(changelogPath, 'utf-8');
const sections = fileContent
.split(/(?=\n## )/ms)
.map(processSection)
.filter(Boolean);
await Promise.all(
sections.map((section) =>
fs.outputFile(
path.join(generateDir, `${section.title}.md`),
section.content,
),
),
);
await syncAvatars(authorsMap, generateDir);
const content = await blogPlugin.loadContent();
content.blogPosts.forEach((post, index) => {
const pageIndex = Math.floor(index / options.postsPerPage);
post.metadata.listPageLink = normalizeUrl([
context.baseUrl,
options.routeBasePath,
pageIndex === 0 ? '/' : `/page/${pageIndex + 1}`,
]);
});
return content;
},
configureWebpack(...args) {
const config = blogPlugin.configureWebpack(...args);
const pluginDataDirRoot = path.join(
context.generatedFilesDir,
'changelog-plugin',
'default',
);
// Redirect the metadata path to our folder
config.module.rules[0].use[1].options.metadataPath = (mdxPath) => {
// Note that metadataPath must be the same/in-sync as
// the path from createData for each MDX.
const aliasedPath = aliasedSitePath(mdxPath, context.siteDir);
return path.join(pluginDataDirRoot, `${docuHash(aliasedPath)}.json`);
};
return config;
},
getThemePath() {
return path.join(__dirname, './theme');
},
getPathsToWatch() {
// Don't watch the generated dir
return [changelogPath];
},
};
}
ChangelogPlugin.validateOptions = pluginContentBlog.validateOptions;
module.exports = ChangelogPlugin;

View file

@ -0,0 +1,88 @@
/**
* 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.
*/
/* eslint-disable import/no-extraneous-dependencies */
// @ts-check
const path = require('path');
const fs = require('fs-extra');
// const axios = require('axios').default;
// TODO not sure how the syncing should be done at all... for now it always
// pretends the limit is reached. We should only fetch a portion of the avatars
// at a time. But seems avatars.githubusercontent.com API doesn't like HTTP requests?
/**
* @param {string} username
* @param {Record<string, number>} lastUpdateCache
* @param {Record<string, {imageURL: string; url: string}>} authorsMap
* @returns true if saved successfully (including not found); false if limited reached
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function fetchImage(username, lastUpdateCache, authorsMap) {
return false;
}
/**
* We need to keep track of all committers that are in the changelog, and fetch
* their avatars beforehand. This prevents sending too many requests to GitHub
* every time one visits a page. Avatar refreshing is done incrementally across
* each build.
*
* This method mutates the authorsMap. If the avatar fails to be fetched, the
* imageURL is re-written with the github remote URL. The authors map is then
* written to FS.
*
* @param {Record<string, {name: string, url: string, alias: string, imageURL: string}>} authorsMap
* @param {string} generateDir
*/
async function syncAvatars(authorsMap, generateDir) {
const imagePath = path.join(generateDir, 'img');
const lastUpdateCachePath = path.join(imagePath, 'lastUpdate.json');
const authorsPath = path.join(generateDir, 'authors.json');
if (!(await fs.pathExists(lastUpdateCachePath))) {
await fs.outputFile(lastUpdateCachePath, '{}');
}
/**
* Records the last time an avatar was successfully updated.
* If an entry doesn't exist, the file won't exist either.
* @type {Record<string, number>}
*/
const lastUpdateCache = await fs.readJSON(lastUpdateCachePath);
let limitReached = false;
// eslint-disable-next-line no-restricted-syntax
for (const username of Object.keys(authorsMap)) {
if (!limitReached && !lastUpdateCache[username]) {
if (!(await fetchImage(username, lastUpdateCache, authorsMap))) {
limitReached = true;
}
}
if (limitReached) {
authorsMap[username].imageURL = `https://github.com/${username}.png`;
}
}
const usersByLastUpdate = Object.entries(lastUpdateCache)
.sort((a, b) => a[1] - b[1])
.map((a) => a[0]);
// eslint-disable-next-line no-restricted-syntax
for (const username of usersByLastUpdate) {
if (
!limitReached &&
lastUpdateCache[username] < Date.now() - 24 * 3600 * 1000
) {
if (!(await fetchImage(username, lastUpdateCache, authorsMap))) {
break;
}
}
}
await fs.outputFile(
lastUpdateCachePath,
JSON.stringify(lastUpdateCache, null, 2),
);
await fs.outputFile(authorsPath, JSON.stringify(authorsMap, null, 2));
}
module.exports = syncAvatars;

View file

@ -0,0 +1,36 @@
/**
* 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 Link from '@docusaurus/Link';
import type {Props} from '@theme/BlogPostAuthor';
import styles from './styles.module.css';
function ChangelogAuthor({author}: Props): JSX.Element {
const {name, url, imageURL} = author;
return (
<div className="avatar margin-bottom--sm">
{imageURL && (
<Link className="avatar__photo-link avatar__photo" href={url}>
<img
className={styles.image}
src={imageURL}
alt={name}
onError={(e) => {
// Image returns 404 if the user's handle changes. We display a fallback instead.
e.currentTarget.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" fill="none" stroke="%2325c2a0" stroke-width="30" version="1.1"><circle cx="300" cy="230" r="115"/><path stroke-linecap="butt" d="M106.81863443903,481.4 a205,205 1 0,1 386.36273112194,0"/></svg>';
}}
/>
</Link>
)}
</div>
);
}
export default ChangelogAuthor;

View file

@ -0,0 +1,12 @@
/**
* 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.
*/
.image {
width: 100%;
height: 100%;
object-fit: cover;
}

View file

@ -0,0 +1,55 @@
/**
* 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, {useState} from 'react';
import clsx from 'clsx';
import type {Props} from '@theme/BlogPostAuthors';
import ChangelogAuthor from '@theme/ChangelogAuthor';
import styles from './styles.module.css';
import IconExpand from '@theme/IconExpand';
// Component responsible for the authors layout
export default function BlogPostAuthors({
authors,
assets,
}: Props): JSX.Element | null {
const [expanded, setExpanded] = useState(false);
const authorsCount = authors.length;
if (authorsCount === 0) {
return null;
}
const filteredAuthors = authors.slice(0, expanded ? authors.length : 10);
return (
<div
className={clsx(
'margin-top--md margin-bottom--sm',
styles.imageOnlyAuthorRow,
)}>
{filteredAuthors.map((author, idx) => (
<div className={styles.imageOnlyAuthorCol} key={idx}>
<ChangelogAuthor
author={{
...author,
// Handle author images using relative paths
imageURL: assets.authorsImageUrls[idx] ?? author.imageURL,
}}
/>
</div>
))}
{authors.length > 10 && (
<button
className={clsx('clean-btn', styles.toggleButton)}
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label="expand">
<IconExpand expanded={expanded} />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
/**
* 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.
*/
.authorCol {
max-width: inherit !important;
flex-grow: 1 !important;
}
.imageOnlyAuthorRow {
display: flex;
flex-flow: row wrap;
}
.imageOnlyAuthorCol {
margin-left: 0.3rem;
margin-right: 0.3rem;
}
.imageOnlyAuthorCol [class^='image'] {
background-color: var(--ifm-color-emphasis-100);
}
.toggleButton {
margin-left: 0.3rem;
margin-right: 0.3rem;
border-radius: 50%;
width: var(--ifm-avatar-photo-size-md);
height: var(--ifm-avatar-photo-size-md);
background-color: var(--ifm-color-emphasis-100);
}
.toggleButtonIconExpanded {
transform: rotate(180deg);
}

View file

@ -0,0 +1,79 @@
/**
* 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 clsx from 'clsx';
// eslint-disable-next-line import/no-extraneous-dependencies
import {MDXProvider} from '@mdx-js/react';
import Link from '@docusaurus/Link';
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import {blogPostContainerID} from '@docusaurus/utils-common';
import MDXComponents from '@theme/MDXComponents';
import type {Props} from '@theme/BlogPostItem';
import styles from './styles.module.css';
import ChangelogAuthors from '@theme/ChangelogAuthors';
function ChangelogItem(props: Props): JSX.Element {
const {withBaseUrl} = useBaseUrlUtils();
const {
children,
frontMatter,
assets,
metadata,
isBlogPostPage = false,
} = props;
const {date, formattedDate, permalink, title, authors} = metadata;
const image = assets.image ?? frontMatter.image;
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
return (
<article
className={!isBlogPostPage ? 'margin-bottom--md' : undefined}
itemProp="blogPost"
itemScope
itemType="http://schema.org/BlogPosting">
<header>
<TitleHeading
className={clsx(
isBlogPostPage ? styles.blogPostPageTitle : styles.blogPostTitle,
)}
itemProp="headline">
{isBlogPostPage ? (
title
) : (
<Link itemProp="url" to={permalink}>
{title}
</Link>
)}
</TitleHeading>
<div className={clsx(styles.blogPostData, 'margin-vert--md')}>
<time dateTime={date} itemProp="datePublished">
{formattedDate}
</time>
</div>
<ChangelogAuthors authors={authors} assets={assets} />
</header>
{image && (
<meta itemProp="image" content={withBaseUrl(image, {absolute: true})} />
)}
<div
// This ID is used for the feed generation to locate the main content
id={isBlogPostPage ? blogPostContainerID : undefined}
className="markdown"
itemProp="articleBody">
<MDXProvider components={MDXComponents}>{children}</MDXProvider>
</div>
</article>
);
}
export default ChangelogItem;

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.
*/
.blogPostTitle {
font-size: 2rem;
}
.blogPostPageTitle {
font-size: 3rem;
}
.blogPostData {
font-size: 0.9rem;
}
.blogPostDetailsFull {
flex-direction: column;
}

View file

@ -0,0 +1,92 @@
/**
* 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 BlogLayout from '@theme/BlogLayout';
import BlogListPaginator from '@theme/BlogListPaginator';
import type {Props} from '@theme/BlogListPage';
import {ThemeClassNames} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import ChangelogItem from '@theme/ChangelogItem';
import styles from './styles.module.css';
function ChangelogList(props: Props): JSX.Element {
const {metadata, items, sidebar} = props;
const {blogDescription, blogTitle} = metadata;
return (
<BlogLayout
title={blogTitle}
description={blogDescription}
wrapperClassName={ThemeClassNames.wrapper.blogPages}
pageClassName={ThemeClassNames.page.blogListPage}
searchMetadata={{
// assign unique search tag to exclude this page from search results!
tag: 'blog_posts_list',
}}
sidebar={sidebar}>
<header className="margin-bottom--lg">
<h1 style={{fontSize: '3rem'}}>{blogTitle}</h1>
<p>
Subscribe through{' '}
<Link href="pathname:///changelog/rss.xml" className={styles.rss}>
<b>RSS feeds</b>
<svg
style={{
fill: '#f26522',
position: 'relative',
left: 4,
top: 1,
marginRight: 8,
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24">
<path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248-1.796 0-3.252-1.454-3.252-3.248 0-1.794 1.456-3.248 3.252-3.248 1.795.001 3.251 1.454 3.251 3.248zm-6.503-12.572v4.811c6.05.062 10.96 4.966 11.022 11.009h4.817c-.062-8.71-7.118-15.758-15.839-15.82zm0-3.368c10.58.046 19.152 8.594 19.183 19.188h4.817c-.03-13.231-10.755-23.954-24-24v4.812z" />
</svg>
</Link>{' '}
or follow us on{' '}
<Link
href="https://twitter.com/docusaurus"
className={styles.twitter}>
<b>Twitter</b>
<svg
style={{
fill: '#1da1f2',
position: 'relative',
left: 4,
top: 1,
marginRight: 8,
}}
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512">
<path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z" />
</svg>
</Link>{' '}
to stay up-to-date with new releases!
</p>
</header>
{items.map(({content: BlogPostContent}) => (
<ChangelogItem
key={BlogPostContent.metadata.permalink}
frontMatter={BlogPostContent.frontMatter}
assets={BlogPostContent.assets}
metadata={BlogPostContent.metadata}
truncated={BlogPostContent.metadata.truncated}>
<BlogPostContent />
</ChangelogItem>
))}
<BlogListPaginator metadata={metadata} />
</BlogLayout>
);
}
export default ChangelogList;

View file

@ -0,0 +1,28 @@
/**
* 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.
*/
.blogPostTitle {
font-size: 2rem;
}
.blogPostData {
font-size: 0.9rem;
}
.blogPostDetailsFull {
flex-direction: column;
}
.rss,
.rss:hover {
color: #f26522;
}
.twitter,
.twitter:hover {
color: #1da1f2;
}

View file

@ -0,0 +1,102 @@
/**
* 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 Seo from '@theme/Seo';
import BlogLayout from '@theme/BlogLayout';
import ChangelogItem from '@theme/ChangelogItem';
import BlogPostPaginator from '@theme/BlogPostPaginator';
import type {Props} from '@theme/BlogPostPage';
import {ThemeClassNames} from '@docusaurus/theme-common';
import TOC from '@theme/TOC';
import Link from '@docusaurus/Link';
// This page doesn't change anything. It's just swapping BlogPostItem with our
// own ChangelogItem. We don't want to apply the swizzled item to the actual blog.
function BlogPostPage(props: Props): JSX.Element {
const {content: BlogPostContents, sidebar} = props;
const {assets, metadata} = BlogPostContents;
const {
title,
description,
nextItem,
prevItem,
date,
tags,
authors,
frontMatter,
// @ts-expect-error: we injected this
listPageLink,
} = metadata;
const {
hide_table_of_contents: hideTableOfContents,
keywords,
toc_min_heading_level: tocMinHeadingLevel,
toc_max_heading_level: tocMaxHeadingLevel,
} = frontMatter;
const image = assets.image ?? frontMatter.image;
return (
<BlogLayout
wrapperClassName={ThemeClassNames.wrapper.blogPages}
pageClassName={ThemeClassNames.page.blogPostPage}
sidebar={sidebar}
toc={
!hideTableOfContents &&
BlogPostContents.toc &&
BlogPostContents.toc.length > 0 ? (
<TOC
toc={BlogPostContents.toc}
minHeadingLevel={tocMinHeadingLevel}
maxHeadingLevel={tocMaxHeadingLevel}
/>
) : undefined
}>
<Seo
title={title}
description={description}
keywords={keywords}
image={image}>
<meta property="og:type" content="article" />
<meta property="article:published_time" content={date} />
{authors.some((author) => author.url) && (
<meta
property="article:author"
content={authors
.map((author) => author.url)
.filter(Boolean)
.join(',')}
/>
)}
{tags.length > 0 && (
<meta
property="article:tag"
content={tags.map((tag) => tag.label).join(',')}
/>
)}
</Seo>
<Link to={listPageLink}> Back to index page</Link>
<ChangelogItem
frontMatter={frontMatter}
assets={assets}
metadata={metadata}
isBlogPostPage>
<BlogPostContents />
</ChangelogItem>
{(nextItem || prevItem) && (
<BlogPostPaginator nextItem={nextItem} prevItem={prevItem} />
)}
</BlogLayout>
);
}
export default BlogPostPage;

View file

@ -0,0 +1,37 @@
/**
* 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 type {Props} from '@theme/IconExpand';
function IconExpand({expanded, ...props}: Props): JSX.Element {
if (expanded) {
return (
<svg
viewBox="0 0 1024 1024"
width="20"
height="20"
fill="currentColor"
{...props}>
<path d="M783.915092 1009.031953l-271.898251-277.615587-271.930737 277.550617a49.214558 49.214558 0 0 1-70.752018 0 51.780862 51.780862 0 0 1 0-72.246322l307.274261-313.706262a49.279528 49.279528 0 0 1 70.784503 0l307.33923 313.706262a51.975771 51.975771 0 0 1 0 72.311292 49.409467 49.409467 0 0 1-70.816988 0z m-307.306745-608.05155L169.269117 87.274141A51.975771 51.975771 0 0 1 169.269117 14.96285a49.409467 49.409467 0 0 1 70.816987 0l271.930737 277.615586L783.850122 14.96285a49.409467 49.409467 0 0 1 70.816988 0 51.975771 51.975771 0 0 1 0 72.311291l-307.33923 313.706262a49.376982 49.376982 0 0 1-70.719533 0z" />
</svg>
);
}
return (
<svg
viewBox="0 0 1024 1024"
width="20"
height="20"
fill="currentColor"
{...props}>
<path d="M476.612887 1009.12034L169.240699 695.380437a51.981345 51.981345 0 0 1 0-72.319045 49.382277 49.382277 0 0 1 70.824582 0l271.959897 277.645356 271.862433-277.645356a49.382277 49.382277 0 0 1 70.824582 0 51.981345 51.981345 0 0 1 0 72.319045l-307.307212 313.739903a49.447254 49.447254 0 0 1-70.792094 0z m307.274724-608.116755L511.99269 123.455693l-271.959897 277.645357a49.382277 49.382277 0 0 1-70.824582 0 51.981345 51.981345 0 0 1 0-72.319045L476.580399 15.042102a49.382277 49.382277 0 0 1 70.727117 0l307.372188 313.739903a51.981345 51.981345 0 0 1 0 72.319045 49.414766 49.414766 0 0 1-70.824582 0z" />
</svg>
);
}
export default IconExpand;

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.
*/
declare module '@theme/ChangelogItem';
declare module '@theme/ChangelogAuthors';
declare module '@theme/ChangelogAuthor';
declare module '@theme/IconExpand' {
import type {ComponentProps} from 'react';
export interface Props extends ComponentProps<'svg'> {
expanded?: boolean;
}
const IconExpand: (props: Props) => JSX.Element;
export default IconExpand;
}

View file

@ -0,0 +1,38 @@
/**
* 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, {useEffect} from 'react';
import Layout from '@theme/Layout';
import cannyScript from './cannyScript';
import clsx from 'clsx';
import styles from './styles.module.css';
const BOARD_TOKEN = '054e0e53-d951-b14c-7e74-9eb8f9ed2f91';
function FeatureRequests({basePath}: {basePath: string}): JSX.Element {
useEffect(() => {
cannyScript();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const {Canny} = window as any;
Canny('render', {
boardToken: BOARD_TOKEN,
basePath,
});
}, [basePath]);
return (
<Layout title="Feedback" description="Docusaurus 2 Feature Requests page">
<main
className={clsx('container', 'margin-vert--lg', styles.main)}
data-canny
/>
</Layout>
);
}
export default FeatureRequests;

View file

@ -0,0 +1,36 @@
/**
* 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 utils from '@docusaurus/utils';
/**
* @param {import('@docusaurus/types').LoadContext} context
* @returns {import('@docusaurus/types').Plugin}
*/
export default function FeatureRequestsPlugin(context) {
return {
name: 'feature-requests-plugin',
async contentLoaded({actions}) {
const basePath = utils.normalizeUrl([
context.baseUrl,
'/feature-requests',
]);
const paths = await actions.createData(
'paths.json',
JSON.stringify(basePath),
);
actions.addRoute({
path: basePath,
exact: false,
component: '@site/src/plugins/featureRequests/FeatureRequestsPage',
modules: {
basePath: paths,
},
});
},
};
}

View file

@ -0,0 +1,31 @@
// Provided by Canny.
/* eslint-disable */
function cannyScript() {
!(function (w, d, i, s) {
function l() {
if (!d.getElementById(i)) {
let f = d.getElementsByTagName(s)[0],
e = d.createElement(s);
(e.type = 'text/javascript'),
(e.async = !0),
(e.src = 'https://canny.io/sdk.js'),
f.parentNode.insertBefore(e, f);
}
}
if (typeof w.Canny !== 'function') {
var c = function () {
c.q.push(arguments);
};
(c.q = []),
(w.Canny = c),
d.readyState === 'complete'
? l()
: w.attachEvent
? w.attachEvent('onload', l)
: w.addEventListener('load', l, !1);
}
})(window, document, 'canny-jssdk', 'script');
}
export default cannyScript;

View file

@ -0,0 +1,13 @@
/**
* 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.
*/
.main {
padding: var(--ifm-spacing-horizontal);
border-radius: 4px;
background: var(--site-color-feedback-background);
min-height: 500px;
}