diff --git a/packages/cli/configuration-loader/src/docs-yml/getAllPages.ts b/packages/cli/configuration-loader/src/docs-yml/getAllPages.ts index 76947042786f..42285d878676 100644 --- a/packages/cli/configuration-loader/src/docs-yml/getAllPages.ts +++ b/packages/cli/configuration-loader/src/docs-yml/getAllPages.ts @@ -75,6 +75,8 @@ function getAllPagesFromNavigationConfig(navigation: docsYml.DocsNavigationConfi ); } else if (tab.child.type === "changelog") { return tab.child.changelog; + } else if (tab.child.type === "blog") { + return tab.child.blog; } return []; }); @@ -129,6 +131,8 @@ export function getAllPagesFromNavigationItem({ item }: { item: docsYml.DocsNavi ]); case "changelog": return item.changelog; + case "blog": + return item.blog; case "librarySection": // Library docs pages are generated locally, but referenced via _navigation.yml return []; diff --git a/packages/cli/configuration-loader/src/docs-yml/getReferencedApiSections.ts b/packages/cli/configuration-loader/src/docs-yml/getReferencedApiSections.ts index 05ee75e00217..04de2cc5c15e 100644 --- a/packages/cli/configuration-loader/src/docs-yml/getReferencedApiSections.ts +++ b/packages/cli/configuration-loader/src/docs-yml/getReferencedApiSections.ts @@ -100,6 +100,7 @@ export function visitDocsNavigationItem({ case "page": case "link": case "changelog": + case "blog": case "librarySection": return; default: diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index af145c4c4d01..e99b15024628 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -1280,6 +1280,23 @@ async function convertNavigationTabConfiguration({ }; } + if (tab.blog != null) { + return { + title: tab.displayName, + icon: resolveIconPath(tab.icon, absolutePathToConfig), + slug: tab.slug, + skipUrlSlug: tab.skipSlug, + hidden: tab.hidden, + child: { + type: "blog", + blog: await listFiles(resolveFilepath(tab.blog, absolutePathToConfig), "{md,mdx}") + }, + viewers: parseRoles(tab.viewers), + orphaned: tab.orphaned, + featureFlags: convertFeatureFlag(tab.featureFlag) + }; + } + assertNever(tab as never); } @@ -1334,6 +1351,7 @@ async function convertNavigationConfiguration({ } const DEFAULT_CHANGELOG_TITLE = "Changelog"; +const DEFAULT_BLOG_TITLE = "Blog"; async function expandFolderConfiguration({ rawConfig, @@ -1518,6 +1536,19 @@ async function convertNavigationItem({ featureFlags: convertFeatureFlag(rawConfig.featureFlag) }; } + if (isRawBlogConfig(rawConfig)) { + return { + type: "blog", + blog: await listFiles(resolveFilepath(rawConfig.blog, absolutePathToConfig), "{md,mdx}"), + hidden: rawConfig.hidden ?? false, + icon: resolveIconPath(rawConfig.icon, absolutePathToConfig), + title: rawConfig.title ?? DEFAULT_BLOG_TITLE, + slug: rawConfig.slug, + viewers: parseRoles(rawConfig.viewers), + orphaned: rawConfig.orphaned, + featureFlags: convertFeatureFlag(rawConfig.featureFlag) + }; + } if (isRawFolderConfig(rawConfig)) { return await expandFolderConfiguration({ rawConfig, @@ -1741,6 +1772,10 @@ function isRawChangelogConfig(item: unknown): item is docsYml.RawSchemas.Changel return isPlainObject(item) && typeof item.changelog === "string"; } +function isRawBlogConfig(item: unknown): item is docsYml.RawSchemas.BlogConfiguration { + return isPlainObject(item) && typeof item.blog === "string"; +} + function isRawFolderConfig(item: unknown): item is docsYml.RawSchemas.FolderConfiguration { return isPlainObject(item) && typeof item.folder === "string"; } diff --git a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts index 8ba13aa8e0ab..b0fb8914fe6c 100644 --- a/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts +++ b/packages/cli/configuration/src/docs-yml/DocsYmlSchemas.ts @@ -6,6 +6,8 @@ export const TabId = z.string(); export const ChangelogFolderRelativePath = z.string(); +export const BlogFolderRelativePath = z.string(); + export const AudienceId = z.string(); export const RoleId = z.string(); @@ -681,6 +683,19 @@ export const ChangelogConfiguration = WithPermissions.merge(WithFeatureFlags).me }) ); +// ===== Blog Configuration ===== +// Mirrors ChangelogConfiguration — a directory of dated markdown posts rendered +// as a card grid (vs. the changelog timeline). See ADR 0023 (fern-platform). +export const BlogConfiguration = WithPermissions.merge(WithFeatureFlags).merge( + z.object({ + blog: BlogFolderRelativePath, + title: z.string().optional(), + slug: z.string().optional(), + icon: z.string().optional(), + hidden: z.boolean().optional() + }) +); + // ===== Library Reference Configuration ===== export const LibraryReferenceConfiguration = WithPermissions.merge(WithFeatureFlags).merge( @@ -820,6 +835,7 @@ export const NavigationItem: z.ZodType = z.lazy(() => LibraryReferenceConfiguration, LinkConfiguration, ChangelogConfiguration, + BlogConfiguration, FolderConfiguration ]) ); @@ -884,7 +900,8 @@ export const TabConfig = WithPermissions.merge(WithFeatureFlags).merge( hidden: z.boolean().optional(), href: z.string().optional(), target: Target.optional(), - changelog: ChangelogFolderRelativePath.optional() + changelog: ChangelogFolderRelativePath.optional(), + blog: BlogFolderRelativePath.optional() }) ); diff --git a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts index e663c82ecc9f..a857d71c3baa 100644 --- a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts @@ -333,6 +333,7 @@ type TabbedNavigationChild = | TabbedNavigationChild.Layout | TabbedNavigationChild.Link | TabbedNavigationChild.Changelog + | TabbedNavigationChild.Blog | TabbedNavigationChild.Variants; export declare namespace TabbedNavigationChild { @@ -352,6 +353,11 @@ export declare namespace TabbedNavigationChild { changelog: AbsoluteFilePath[]; } + export interface Blog { + type: "blog"; + blog: AbsoluteFilePath[]; + } + export interface Variants { type: "variants"; variants: TabVariant[]; @@ -377,7 +383,8 @@ export type DocsNavigationItem = | DocsNavigationItem.ApiSection | DocsNavigationItem.LibrarySection | DocsNavigationItem.Link - | DocsNavigationItem.Changelog; + | DocsNavigationItem.Changelog + | DocsNavigationItem.Blog; export declare namespace DocsNavigationItem { export interface Page @@ -455,6 +462,17 @@ export declare namespace DocsNavigationItem { slug: string | undefined; } + export interface Blog + extends CjsFdrSdk.navigation.v1.WithPermissions, + CjsFdrSdk.navigation.latest.WithFeatureFlags { + type: "blog"; + blog: AbsoluteFilePath[]; + title: string; + icon: string | AbsoluteFilePath | undefined; + hidden: boolean | undefined; + slug: string | undefined; + } + export interface LibrarySection extends CjsFdrSdk.navigation.v1.WithPermissions, CjsFdrSdk.navigation.latest.WithFeatureFlags { diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts new file mode 100644 index 000000000000..884c37b7ab5f --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogConfiguration.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as FernDocsConfig from "../../../index.js"; + +export interface BlogConfiguration extends FernDocsConfig.WithPermissions, FernDocsConfig.WithFeatureFlags { + blog: FernDocsConfig.BlogFolderRelativePath; + title?: string; + slug?: string; + icon?: string; + hidden?: boolean; +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogFolderRelativePath.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogFolderRelativePath.ts new file mode 100644 index 000000000000..eb2e47876bc1 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/BlogFolderRelativePath.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * The relative path to a folder containing markdown blog posts broken down by date. + * + * Example: + * ``` + * blog: "blog" + * ``` + * + * This will look for markdown files in the `/fern/blog` directory, which should contain files named like + * - `/fern/blog/2024-04-29-my-post.mdx`. + * - `/fern/blog/2023-01-02-another-post.mdx`. + */ +export type BlogFolderRelativePath = string; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts index 52bfb6c63ef1..a37b77d20528 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/NavigationItem.ts @@ -9,4 +9,5 @@ export type NavigationItem = | FernDocsConfig.LibraryReferenceConfiguration | FernDocsConfig.LinkConfiguration | FernDocsConfig.ChangelogConfiguration + | FernDocsConfig.BlogConfiguration | FernDocsConfig.FolderConfiguration; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts index 2a05b6e67062..932937ed1ffd 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/TabConfig.ts @@ -16,4 +16,5 @@ export interface TabConfig extends FernDocsConfig.WithPermissions, FernDocsConfi href?: string; target?: FernDocsConfig.Target; changelog?: FernDocsConfig.ChangelogFolderRelativePath; + blog?: FernDocsConfig.BlogFolderRelativePath; } diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts index 84d60bfdde9a..f4af4c4e95d5 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/index.ts @@ -23,6 +23,8 @@ export * from "./BackgroundImageThemedConfig.js"; export * from "./BodyThemeConfig.js"; export * from "./ChangelogConfiguration.js"; export * from "./ChangelogFolderRelativePath.js"; +export * from "./BlogConfiguration.js"; +export * from "./BlogFolderRelativePath.js"; export * from "./ChangelogLayout.js"; export * from "./CheckConfig.js"; export * from "./CheckRuleSeverity.js"; diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts new file mode 100644 index 000000000000..4d8088c72f28 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogConfiguration.ts @@ -0,0 +1,32 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as FernDocsConfig from "../../../../api/index.js"; +import * as core from "../../../../core/index.js"; +import type * as serializers from "../../../index.js"; +import { BlogFolderRelativePath } from "./BlogFolderRelativePath.js"; +import { WithFeatureFlags } from "./WithFeatureFlags.js"; +import { WithPermissions } from "./WithPermissions.js"; + +export const BlogConfiguration: core.serialization.ObjectSchema< + serializers.BlogConfiguration.Raw, + FernDocsConfig.BlogConfiguration +> = core.serialization + .object({ + blog: BlogFolderRelativePath, + title: core.serialization.string().optional(), + slug: core.serialization.string().optional(), + icon: core.serialization.string().optional(), + hidden: core.serialization.boolean().optional(), + }) + .extend(WithPermissions) + .extend(WithFeatureFlags); + +export declare namespace BlogConfiguration { + export interface Raw extends WithPermissions.Raw, WithFeatureFlags.Raw { + blog: BlogFolderRelativePath.Raw; + title?: string | null; + slug?: string | null; + icon?: string | null; + hidden?: boolean | null; + } +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogFolderRelativePath.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogFolderRelativePath.ts new file mode 100644 index 000000000000..cd16316b40a9 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/BlogFolderRelativePath.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as FernDocsConfig from "../../../../api/index.js"; +import * as core from "../../../../core/index.js"; +import type * as serializers from "../../../index.js"; + +export const BlogFolderRelativePath: core.serialization.Schema< + serializers.BlogFolderRelativePath.Raw, + FernDocsConfig.BlogFolderRelativePath +> = core.serialization.string(); + +export declare namespace BlogFolderRelativePath { + export type Raw = string; +} diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts index 58e21463e6ca..14cc5ee4cf8d 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/NavigationItem.ts @@ -4,6 +4,7 @@ import type * as FernDocsConfig from "../../../../api/index.js"; import * as core from "../../../../core/index.js"; import * as serializers from "../../../index.js"; import { ApiReferenceConfiguration } from "./ApiReferenceConfiguration.js"; +import { BlogConfiguration } from "./BlogConfiguration.js"; import { ChangelogConfiguration } from "./ChangelogConfiguration.js"; import { FolderConfiguration } from "./FolderConfiguration.js"; import { LibraryReferenceConfiguration } from "./LibraryReferenceConfiguration.js"; @@ -18,6 +19,7 @@ export const NavigationItem: core.serialization.Schema, + private markdownToNoIndex: Map, + private markdownToTags: Map, + private blogFiles: AbsoluteFilePath[] | undefined, + private docsWorkspace: DocsWorkspace, + private idgen: NodeIdGenerator + ) {} + + public toBlogNode(opts: ConvertOptions): FernNavigation.V1.BlogNode { + const title = opts.title ?? DEFAULT_BLOG_TITLE; + + const unsortedBlogItems: { + date: Date; + pageId: FernNavigation.PageId; + absoluteFilepath: AbsoluteFilePath; + }[] = []; + + let overviewPagePath: AbsoluteFilePath | undefined = undefined; + for (const absoluteFilepath of this.blogFiles ?? []) { + const filename = last(absoluteFilepath.split("/")); + if (filename == null) { + continue; + } + const blogDate = extractDatetimeFromChangelogTitle(filename); + if (blogDate == null) { + const nameWithoutExtension = filename.split(".")[0]?.toLowerCase(); + if (nameWithoutExtension != null && RESERVED_OVERVIEW_PAGE_NAMES.includes(nameWithoutExtension)) { + overviewPagePath = absoluteFilepath; + } + + continue; + } + const relativePath = this.toRelativeFilepath(absoluteFilepath); + unsortedBlogItems.push({ + date: blogDate, + pageId: FernNavigation.PageId(relativePath), + absoluteFilepath + }); + } + + const slug = opts.parentSlug.apply({ + fullSlug: overviewPagePath != null ? this.markdownToFullSlug.get(overviewPagePath)?.split("/") : undefined, + skipUrlSlug: false, // blog pages should always have a url slug + urlSlug: opts.slug ?? kebabCase(title) + }); + + const noindex = overviewPagePath != null ? this.markdownToNoIndex.get(overviewPagePath) : undefined; + + const blogItems = unsortedBlogItems.map((item): FernNavigation.V1.BlogEntryNode => { + const date = dayjs.utc(item.date); + return { + id: this.idgen.get(item.pageId), + type: "blogEntry", + collapsed: undefined, + title: date.format("MMMM D, YYYY"), + slug: slug + .apply({ + fullSlug: this.markdownToFullSlug.get(item.absoluteFilepath)?.split("/"), + // Title-based fallback: the post's filename minus a leading + // `YYYY-MM-DD-` prefix (frontmatter `slug` overrides via fullSlug). + urlSlug: this.fallbackUrlSlug(item.absoluteFilepath) + }) + .get(), + icon: undefined, + hidden: undefined, + date: item.date.toISOString(), + pageId: item.pageId, + noindex: this.markdownToNoIndex.get(item.absoluteFilepath), + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined, + tags: this.markdownToTags.get(item.absoluteFilepath) + }; + }); + + const entries = orderBy(blogItems, (entry) => entry.date, "desc"); + const overviewPageId = + overviewPagePath != null ? FernNavigation.PageId(this.toRelativeFilepath(overviewPagePath)) : undefined; + const id = this.idgen.get(overviewPageId ?? "blog"); + const blogYears = this.groupByYear(id, entries, slug); + + return { + id, + type: "blog", + collapsed: undefined, + title, + slug: slug.get(), + icon: opts.icon, + hidden: opts.hidden, + children: blogYears, + overviewPageId, + noindex, + authed: undefined, + viewers: opts.viewers, + orphaned: opts.orphaned, + featureFlags: undefined + }; + } + + public orUndefined(): BlogNodeConverter | undefined { + return this.blogFiles != null && this.blogFiles.length > 0 ? this : undefined; + } + + /** Derives a slug from a post filename, stripping a leading `YYYY-MM-DD-` date prefix. */ + private fallbackUrlSlug(absoluteFilepath: AbsoluteFilePath): string { + const filename = last(absoluteFilepath.split("/")) ?? ""; + const nameWithoutExtension = filename.split(".")[0] ?? filename; + const withoutDatePrefix = nameWithoutExtension.replace(/^\d{4}-\d{2}-\d{2}-?/, ""); + return kebabCase(withoutDatePrefix.length > 0 ? withoutDatePrefix : nameWithoutExtension); + } + + private groupByYear( + prefix: string, + entries: FernNavigation.V1.BlogEntryNode[], + parentSlug: FernNavigation.V1.SlugGenerator + ): FernNavigation.V1.BlogYearNode[] { + const years = new Map(); + for (const entry of entries) { + const year = dayjs.utc(entry.date).year(); + const yearEntries = years.get(year) ?? []; + yearEntries.push(entry); + years.set(year, yearEntries); + } + return orderBy( + Array.from(years.entries()).map(([year, entries]) => { + const slug = parentSlug.append(year.toString()).get(); + const id = this.idgen.get(`${prefix}/year/${year}`); + return { + id, + type: "blogYear" as const, + collapsed: undefined, + title: year.toString(), + year, + slug, + icon: undefined, + hidden: undefined, + children: this.groupByMonth(id, entries, parentSlug), + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + }; + }), + "year", + "desc" + ); + } + + private groupByMonth( + prefix: string, + entries: FernNavigation.V1.BlogEntryNode[], + parentSlug: FernNavigation.V1.SlugGenerator + ): FernNavigation.V1.BlogMonthNode[] { + const months = new Map(); + for (const entry of entries) { + const month = dayjs.utc(entry.date).month() + 1; + const monthEntries = months.get(month) ?? []; + monthEntries.push(entry); + months.set(month, monthEntries); + } + return orderBy( + Array.from(months.entries()).map(([month, entries]) => { + const date = dayjs(new Date(0, month - 1)); + return { + id: this.idgen.get(`${prefix}/month/${month}`), + type: "blogMonth" as const, + collapsed: undefined, + title: date.format("MMMM YYYY"), + month, + slug: parentSlug.append(month.toString()).get(), + icon: undefined, + hidden: undefined, + children: entries, + authed: undefined, + viewers: undefined, + orphaned: undefined, + featureFlags: undefined + }; + }), + "month", + "desc" + ); + } + + private toRelativeFilepath(filepath: AbsoluteFilePath): RelativeFilePath; + private toRelativeFilepath(filepath: AbsoluteFilePath | undefined): RelativeFilePath | undefined; + private toRelativeFilepath(filepath: AbsoluteFilePath | undefined): RelativeFilePath | undefined { + if (filepath == null) { + return undefined; + } + return relative(this.docsWorkspace.absoluteFilePath, filepath); + } +} + +function orderBy>( + items: T[], + key: K, + order?: "asc" | "desc" +): T[]; +function orderBy(items: T[], key: (item: T) => string | number, order?: "asc" | "desc"): T[]; +function orderBy>( + items: T[], + key: K | ((item: T) => string | number), + order: "asc" | "desc" = "asc" +): T[] { + return items.concat().sort((a, b) => { + const aValue = typeof key === "function" ? key(a) : a[key]; + const bValue = typeof key === "function" ? key(b) : b[key]; + if (aValue < bValue) { + return order === "asc" ? -1 : 1; + } else if (aValue > bValue) { + return order === "asc" ? 1 : -1; + } + return 0; + }); +} diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index 22fff2dee141..b2775f486757 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -75,6 +75,7 @@ type AIChatConfigWithMaskPii = NonNullable this.toSectionNode({ prefix, item: value, parentSlug, contentSource }), link: async (value) => this.toLinkNode(value), changelog: async (value) => this.toChangelogNode(value, parentSlug), + blog: async (value) => this.toBlogNode(value, parentSlug), librarySection: async (value) => this.handleLibrarySection(value, parentSlug, contentSource) }); } @@ -1820,6 +1822,7 @@ export class DocsDefinitionResolver { }), link: async (value) => this.toLinkNode(value), changelog: async (value) => this.toChangelogNode(value, parentSlug, hideChildren), + blog: async (value) => this.toBlogNode(value, parentSlug, hideChildren), librarySection: async (value) => this.handleLibrarySection(value, parentSlug, contentSource) }); } @@ -2090,6 +2093,30 @@ export class DocsDefinitionResolver { }); } + private async toBlogNode( + item: docsYml.DocsNavigationItem.Blog, + parentSlug: FernNavigation.V1.SlugGenerator, + hideChildren?: boolean + ): Promise { + const blogResolver = new BlogNodeConverter( + this.markdownFilesToFullSlugs, + this.markdownFilesToNoIndex, + this.markdownFilesToTags, + item.blog, + this.docsWorkspace, + this.#idgen + ); + + return blogResolver.toBlogNode({ + parentSlug, + title: item.title, + icon: this.resolveIconFileId(item.icon), + viewers: item.viewers, + hidden: hideChildren || item.hidden, + slug: item.slug + }); + } + private async toLinkNode(item: docsYml.DocsNavigationItem.Link): Promise { return { type: "link", @@ -2616,6 +2643,7 @@ export class DocsDefinitionResolver { link: ({ href, target }) => this.toTabLinkNode(item, href, target), layout: ({ layout }) => this.toTabNode(prefix, item, layout, parentSlug, contentSource), changelog: ({ changelog }) => this.toTabChangelogNode(item, changelog, parentSlug), + blog: ({ blog }) => this.toTabBlogNode(item, blog, parentSlug), variants: ({ variants }) => this.toTabNodeWithVariants(prefix, item, variants, parentSlug, contentSource) }); } @@ -2643,6 +2671,29 @@ export class DocsDefinitionResolver { }); } + private async toTabBlogNode( + item: docsYml.TabbedNavigation, + blog: AbsoluteFilePath[], + parentSlug: FernNavigation.V1.SlugGenerator + ): Promise { + const blogResolver = new BlogNodeConverter( + this.markdownFilesToFullSlugs, + this.markdownFilesToNoIndex, + this.markdownFilesToTags, + blog, + this.docsWorkspace, + this.#idgen + ); + return blogResolver.toBlogNode({ + parentSlug, + title: item.title, + icon: this.resolveIconFileId(item.icon), + viewers: item.viewers, + hidden: item.hidden, + slug: item.slug + }); + } + private async toTabLinkNode( item: docsYml.TabbedNavigation, href: string, diff --git a/packages/cli/workspace/loader/src/docs-yml.schema.json b/packages/cli/workspace/loader/src/docs-yml.schema.json index 9d86fee3f48f..d765c0381b96 100644 --- a/packages/cli/workspace/loader/src/docs-yml.schema.json +++ b/packages/cli/workspace/loader/src/docs-yml.schema.json @@ -1112,6 +1112,16 @@ "type": "null" } ] + }, + "blog": { + "oneOf": [ + { + "$ref": "#/definitions/docs.BlogFolderRelativePath" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3130,6 +3140,9 @@ { "$ref": "#/definitions/docs.ChangelogConfiguration" }, + { + "$ref": "#/definitions/docs.BlogConfiguration" + }, { "$ref": "#/definitions/docs.FolderConfiguration" } @@ -6710,6 +6723,93 @@ } ], "description": "The `js` object allows you to customize the behavior of your docs site by injecting custom JavaScript, i.e.\n\n```yaml\njs: \"path/to/js/file.js\"\n```\n\nor, multiple files:\n\n```yaml\njs:\n - \"path/to/js/file.js\"\n - \"path/to/another/js/file.js\"\n```\n\nor remote js:\n\n```yaml\njs:\n url: \"https://example.com/path/to/js/file.js\"\n strategy: \"afterInteractive\"\n```\n\nor, mixed:\n\n```yaml\njs:\n - \"path/to/js/file.js\"\n - path: \"path/to/another/js/file.js\"\n strategy: \"beforeInteractive\"\n - url: \"https://example.com/path/to/js/file.js\"\n```" + }, + "docs.BlogFolderRelativePath": { + "type": "string", + "description": "The relative path to a folder containing markdown blog posts broken down by date.\n\nExample:\n```\nblog: \"blog\"\n```\n\nThis will look for markdown files in the `/fern/blog` directory, which should contain files named like\n- `/fern/blog/2024-04-29-my-post.mdx`." + }, + "docs.BlogConfiguration": { + "type": "object", + "properties": { + "viewers": { + "oneOf": [ + { + "$ref": "#/definitions/docs.Role" + }, + { + "type": "null" + } + ] + }, + "orphaned": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "When `orphaned` is set to `true`, the roles will not inherit from parents." + }, + "feature-flag": { + "oneOf": [ + { + "$ref": "#/definitions/docs.FeatureFlagConfiguration" + }, + { + "type": "null" + } + ] + }, + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "icon": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "blog": { + "$ref": "#/definitions/docs.BlogFolderRelativePath" + } + }, + "required": [ + "blog" + ], + "additionalProperties": false } } } \ No newline at end of file diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts index 27001c4a4413..405672ffe588 100644 --- a/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts @@ -310,6 +310,34 @@ async function visitNavigationItem({ context.logger.trace(`Changelog directory does not exist: ${changelogDir}`); } } + + if (navigationItemIsBlog(navigationItem)) { + const blogDir = resolve(dirname(absoluteFilepathToConfiguration), navigationItem.blog); + context.logger.trace(`Starting blog processing for directory: ${blogDir}`); + + if (await doesPathExist(blogDir)) { + const files = await readdir(blogDir); + const markdownFiles = files.filter((file) => file.endsWith(".md") || file.endsWith(".mdx")); + context.logger.debug(`Processing ${markdownFiles.length} blog files in ${blogDir}`); + + await asyncPool(VALIDATION_CONCURRENCY, markdownFiles, async (file) => { + const absoluteFilepath = resolve(blogDir, file); + const content = (await readFile(absoluteFilepath, "utf8")).toString(); + context.logger.trace(`Validating blog file: ${file}`); + + await visitor.markdownPage?.( + { + title: file, + content, + absoluteFilepath + }, + [...nodePath, "blog", file] + ); + }); + } else { + context.logger.trace(`Blog directory does not exist: ${blogDir}`); + } + } } function navigationItemIsFolder( @@ -406,6 +434,11 @@ function navigationItemIsChangelog( return (item as docsYml.RawSchemas.ChangelogConfiguration)?.changelog != null; } +function navigationItemIsBlog(item: docsYml.RawSchemas.NavigationItem): item is docsYml.RawSchemas.BlogConfiguration { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (item as docsYml.RawSchemas.BlogConfiguration)?.blog != null; +} + function navigationItemIsPage(item: docsYml.RawSchemas.NavigationItem): item is docsYml.RawSchemas.PageConfiguration { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return (item as docsYml.RawSchemas.PageConfiguration)?.page != null;