facebook/docusaurus

Author pages include unlisted posts (missing filtering in createAuthorsRoutes)

Summary

  • Context: The createAuthorsRoutes function in routes.ts generates paginated routes for author pages, which list all blog posts written by each author.

  • Bug: Author pages include unlisted blog posts in their pagination, exposing posts that are marked as unlisted (unlisted: true in frontmatter).

  • Actual vs. expected: Unlisted blog posts appear on author pages, whereas they should only appear on the main blog list pagination, tag pages, and archive pages when explicitly filtered.

  • Impact: Unlisted blog posts are inappropriately exposed on author pages, violating the intended behavior of the unlisted feature which is meant to keep posts accessible only via direct links.

Code with bug

In packages/docusaurus-plugin-content-blog/src/routes.ts, the createAuthorsRoutes function:

function createAuthorsRoutes(): RouteConfig[] {
  if (
    authorsMap === undefined ||
    Object.keys(authorsMap).length === 0
  ) {
    return [];
  }

  // Filter blog posts once at the source
  const listedBlogPosts = blogPosts.filter(shouldBeListed); // <-- FIX 🟢

  const blogPostsByAuthorKey = groupBlogPostsByAuthorKey({
    authorsMap,
    blogPosts: listedBlogPosts, // <-- FIX 🟢 Use only listed posts
  });

  const authors = Object.values(authorsMap);

  return [
    createAuthorListRoute(),
    ...authors.flatMap(createAuthorPaginatedRoute),
  ];

  function createAuthorPaginatedRoute(
    author: AuthorWithKey,
  ): RouteConfig[] {
    const authorBlogPosts =
      blogPostsByAuthorKey[author.key] ?? [];

    if (!author.page) {
      return [];
    }

    const pages = paginateBlogPosts({
      blogPosts: authorBlogPosts,
      basePageUrl: author.page.permalink,
      blogDescription,
      blogTitle,
      pageBasePath: authorsBasePath,
      postsPerPageOption: postsPerPage,
    });

    return pages.map(({ metadata, items }) => ({
      path: metadata.permalink,
      component: blogAuthorsPostsComponent,
      exact: true,
      modules: {
        items: blogPostItemsModule(items),
        sidebar: sidebarModulePath,
      },
      props: {
        author: toAuthorItemProp({
          author,
          count: authorBlogPosts.length,
        }),
        listMetadata: metadata,
      },
      context: {
        blogMetadata: blogMetadataModulePath,
      },
    }));
  }
}

const pages = paginateBlogPosts({
  blogPosts: authorBlogPosts, // <-- BUG 🔴 May contain unlisted posts
  basePageUrl: author.page.permalink,
  blogDescription,
  blogTitle,
  pageBasePath: authorsBasePath,
  postsPerPageOption: postsPerPage,
});

return pages.map(({ metadata, items }) => {
  return {
    path: metadata.permalink,
    component: blogAuthorsPostsComponent,
    exact: true,
    modules: {
      items: blogPostItemsModule(items), // <-- BUG 🔴 Items include unlisted posts
      sidebar: sidebarModulePath,
    },
    props: {
      author: toAuthorItemProp({
        author,
        count: authorBlogPosts.length,
      }),
      listMetadata: metadata,
    },
    context: {
      blogMetadata: blogMetadataModulePath,
    },
  };
});

Logical proof

  1. Input includes both listed and unlisted posts in blogPosts.

  2. The codebase defines listedBlogPosts as blogPosts.filter(shouldBeListed), but author pages do not use it.

  3. groupBlogPostsByAuthorKey({authorsMap, blogPosts}) receives the unfiltered blogPosts, so each author’s group includes unlisted posts.

  4. paginateBlogPosts({ blogPosts: authorBlogPosts, ... }) paginates whatever it receives; it does not filter by unlisted.

  5. The resulting route modules use blogPostItemsModule(items), which therefore includes unlisted posts.

  6. Conclusion: Unlisted posts are rendered on author pages because filtering is never applied for authors, unlike tag, archive, and main list pages which use filtered inputs.

Recommended fix

Filter out unlisted posts before pagination in author routes:

function createAuthorPaginatedRoute(author: AuthorWithKey): RouteConfig[] {
  const authorBlogPosts = blogPostsByAuthorKey[author.key] ?? [];

  // Filter out unlisted posts
  const listedAuthorBlogPosts = authorBlogPosts.filter(shouldBeListed); // <-- FIX 🟢

  if (!author.page) {
    return [];
  }

  const pages = paginateBlogPosts({
    blogPosts: listedAuthorBlogPosts, // <-- FIX 🟢 Use filtered list
    basePageUrl: author.page.permalink,
    blogDescription,
    blogTitle,
    pageBasePath: authorsBasePath,
    postsPerPageOption: postsPerPage,
  });

  return pages.map(({ metadata, items }) => {
    return {
      path: metadata.permalink,
      component: blogAuthorsPostsComponent,
      exact: true,
      modules: {
        items: blogPostItemsModule(items),
        sidebar: sidebarModulePath,
      },
      props: {
        author: toAuthorItemProp({
          author,
          count: listedAuthorBlogPosts.length, // <-- Updated count for listed posts
        }),
        listMetadata: metadata,
      },
      context: {
        blogMetadata: blogMetadataModulePath,
      },
    };
  });
}

Alternatively, pass listedBlogPosts to groupBlogPostsByAuthorKey instead of blogPosts, though the above approach is more localized and matches the pattern used for tag pages.