diff --git a/fern/pages/changelogs/cli/2024-09-28.mdx b/fern/pages/changelogs/cli/2024-09-28.mdx new file mode 100644 index 00000000000..30ab1df983f --- /dev/null +++ b/fern/pages/changelogs/cli/2024-09-28.mdx @@ -0,0 +1,6 @@ +## 0.43.7 +**`(fix):`** The `valid-markdown` rule has been updated to try and parse the markdown file into a +valid AST. If the file fails to parse, `fern check` will log an error as well +as the path to the markdown. + + diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index aa0158b9ba8..9a6d65a86c1 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,3 +1,12 @@ +- changelogEntry: + - summary: | + The `valid-markdown` rule has been updated to try and parse the markdown file into a + valid AST. If the file fails to parse, `fern check` will log an error as well + as the path to the markdown. + type: fix + irVersion: 53 + version: 0.43.7 + - changelogEntry: - summary: | The OpenAPI importer now appropriately brings in responses that are under the `text/event-stream` diff --git a/packages/cli/docs-markdown-utils/src/index.ts b/packages/cli/docs-markdown-utils/src/index.ts index 685b8d33f12..4e7b1e6d7cb 100644 --- a/packages/cli/docs-markdown-utils/src/index.ts +++ b/packages/cli/docs-markdown-utils/src/index.ts @@ -1,3 +1,4 @@ export { parseImagePaths, replaceImagePathsAndUrls } from "./parseImagePaths"; export { replaceReferencedMarkdown } from "./replaceReferencedMarkdown"; export { replaceReferencedCode } from "./replaceReferencedCode"; +export { parseMarkdownToTree } from "./parseMarkdownToTree"; diff --git a/packages/cli/docs-markdown-utils/src/parseImagePaths.ts b/packages/cli/docs-markdown-utils/src/parseImagePaths.ts index cd7d0a30820..3df102387bc 100644 --- a/packages/cli/docs-markdown-utils/src/parseImagePaths.ts +++ b/packages/cli/docs-markdown-utils/src/parseImagePaths.ts @@ -6,6 +6,7 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { mdxFromMarkdown } from "mdast-util-mdx"; import { mdx } from "micromark-extension-mdx"; import { visit } from "unist-util-visit"; +import { parseMarkdownToTree } from "./parseMarkdownToTree"; interface AbsolutePathMetadata { absolutePathToMdx: AbsoluteFilePath; @@ -48,10 +49,7 @@ export function parseImagePaths( visitFrontmatterImages(data, ["image", "og:image", "og:logo", "twitter:image"], mapImage); - const tree = fromMarkdown(content, { - extensions: [mdx()], - mdastExtensions: [mdxFromMarkdown()] - }); + const tree = parseMarkdownToTree(content); let offset = 0; diff --git a/packages/cli/docs-markdown-utils/src/parseMarkdownToTree.ts b/packages/cli/docs-markdown-utils/src/parseMarkdownToTree.ts new file mode 100644 index 00000000000..dba21bcdd50 --- /dev/null +++ b/packages/cli/docs-markdown-utils/src/parseMarkdownToTree.ts @@ -0,0 +1,11 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { Root } from "mdast-util-from-markdown/lib"; +import { mdxFromMarkdown } from "mdast-util-mdx"; +import { mdx } from "micromark-extension-mdx"; + +export function parseMarkdownToTree(content: string): Root { + return fromMarkdown(content, { + extensions: [mdx()], + mdastExtensions: [mdxFromMarkdown()] + }); +} diff --git a/packages/cli/ete-tests/src/tests/generate/__snapshots__/generate.test.ts.snap b/packages/cli/ete-tests/src/tests/generate/__snapshots__/generate.test.ts.snap index 28eccaf7a75..2282c7ba388 100644 --- a/packages/cli/ete-tests/src/tests/generate/__snapshots__/generate.test.ts.snap +++ b/packages/cli/ete-tests/src/tests/generate/__snapshots__/generate.test.ts.snap @@ -2,6 +2,6 @@ exports[`fern generate > missing docs page 1`] = ` "[docs]: Found 1 errors and 0 warnings. Run fern check --warnings to print out the warnings. -[docs]: docs.yml -> navigation -> 0 -> page +[docs]: docs.yml -> navigation -> 0 -> path Path missing.md does not exist" `; diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileAst.ts deleted file mode 100644 index c8f93fafe08..00000000000 --- a/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileAst.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { docsYml } from "@fern-api/configuration"; -import { parseImagePaths, replaceReferencedCode, replaceReferencedMarkdown } from "@fern-api/docs-markdown-utils"; -import { AbsoluteFilePath, dirname, doesPathExist, RelativeFilePath, resolve } from "@fern-api/fs-utils"; -import { TaskContext } from "@fern-api/task-context"; -import { NodePath } from "@fern-api/fern-definition-schema"; -import { readFile } from "fs/promises"; -import yaml from "js-yaml"; -import path from "path"; -import { APIWorkspaceLoader } from "./APIWorkspaceLoader"; -import { DocsConfigFileAstVisitor } from "./DocsConfigFileAstVisitor"; -import { validateVersionConfigFileSchema } from "./validateVersionConfig"; - -export async function visitDocsConfigFileYamlAst( - contents: docsYml.RawSchemas.DocsConfiguration, - visitor: Partial, - absoluteFilepathToConfiguration: AbsoluteFilePath, - absolutePathToFernFolder: AbsoluteFilePath, - context: TaskContext, - loadAPIWorkspace: APIWorkspaceLoader -): Promise { - await visitor.file?.( - { - config: contents - }, - [] - ); - - // the following code parses markdown files for media and adds them to the filepath visitor - let pageEntries: Record = {}; - - try { - // wrap the parse call in a try/catch because it will throw if a markdown file doesn't exist - const { pages } = await docsYml.parseDocsConfiguration({ - rawDocsConfiguration: contents, - context, - absoluteFilepathToDocsConfig: absoluteFilepathToConfiguration, - absolutePathToFernFolder - }); - pageEntries = pages; - } catch { - // if the parse fails, we'll just skip this step - } - - // replaces all instances of with the content of the referenced markdown file - // this should happen before we parse image paths, as the referenced markdown files may contain images. - for (const [relativePath, markdown] of Object.entries(pageEntries)) { - pageEntries[RelativeFilePath.of(relativePath)] = await replaceReferencedMarkdown({ - markdown, - absolutePathToFernFolder, - absolutePathToMdx: resolve(absolutePathToFernFolder, relativePath), - context - }); - pageEntries[RelativeFilePath.of(relativePath)] = await replaceReferencedCode({ - markdown, - absolutePathToFernFolder, - absolutePathToMdx: resolve(absolutePathToFernFolder, relativePath), - context - }); - } - - for (const [relativePath, markdown] of Object.entries(pageEntries)) { - const { filepaths } = parseImagePaths(markdown, { - absolutePathToFernFolder, - absolutePathToMdx: resolve(absolutePathToFernFolder, relativePath) - }); - - // visit each media filepath in each markdown file - for (const filepath of filepaths) { - await visitor.filepath?.( - { - absoluteFilepath: filepath, - value: path.relative(absolutePathToFernFolder, filepath), - willBeUploaded: true - }, - [relativePath] - ); - } - } - - if (contents.js != null) { - if (Array.isArray(contents.js)) { - // multiple JS configs - await Promise.all( - contents.js.map((script, idx) => - visitScript({ - absoluteFilepathToConfiguration, - visitor, - script, - nodePath: ["js", `${idx}`] - }) - ) - ); - } else { - // single JS config - await visitScript({ - absoluteFilepathToConfiguration, - visitor, - script: contents.js, - nodePath: ["js"] - }); - } - } - - if (contents.css != null) { - if (Array.isArray(contents.css)) { - // multiple CSS files - await Promise.all( - contents.css.map((stylesheet, idx) => - visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: stylesheet, - visitor, - nodePath: ["css", `${idx}`], - willBeUploaded: false - }) - ) - ); - } else { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.css, - visitor, - nodePath: ["css"], - willBeUploaded: false - }); - } - } - - if (contents.backgroundImage != null) { - if (typeof contents.backgroundImage === "string") { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.backgroundImage, - visitor, - nodePath: ["background-image"] - }); - } else { - if (contents.backgroundImage.dark != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.backgroundImage.dark, - visitor, - nodePath: ["background-image", "dark"] - }); - } - if (contents.backgroundImage.light != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.backgroundImage.light, - visitor, - nodePath: ["background-image", "light"] - }); - } - } - } - if (contents.favicon != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.favicon, - visitor, - nodePath: ["favicon"] - }); - } - if (contents.logo?.dark != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.logo.dark, - visitor, - nodePath: ["logo", "dark"] - }); - } - if (contents.logo?.light != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.logo.light, - visitor, - nodePath: ["logo", "light"] - }); - } - - if (contents.navigation != null) { - await visitNavigation({ - navigation: contents.navigation, - visitor, - nodePath: ["navigation"], - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context - }); - } - - if (contents.typography?.codeFont?.path != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.typography.codeFont.path, - visitor, - nodePath: ["typography", "codeFont"] - }); - } - - for (const path of contents.typography?.codeFont?.paths ?? []) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: typeof path === "string" ? path : path.path, - visitor, - nodePath: ["typography", "codeFont"] - }); - } - - if (contents.typography?.bodyFont?.path != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.typography.bodyFont.path, - visitor, - nodePath: ["typography", "bodyFont"] - }); - } - - for (const path of contents.typography?.bodyFont?.paths ?? []) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: typeof path === "string" ? path : path.path, - visitor, - nodePath: ["typography", "bodyFont"] - }); - } - - if (contents.typography?.headingsFont?.path != null) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: contents.typography.headingsFont.path, - visitor, - nodePath: ["typography", "headingsFont"] - }); - } - - for (const path of contents.typography?.headingsFont?.paths ?? []) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: typeof path === "string" ? path : path.path, - visitor, - nodePath: ["typography", "headingsFont"] - }); - } - - if (contents.versions != null) { - await Promise.all( - contents.versions.map(async (version, idx) => { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: version.path, - visitor, - nodePath: ["versions", `${idx}`], - willBeUploaded: false - }); - const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), version.path); - const content = yaml.load((await readFile(absoluteFilepath)).toString()); - if (await doesPathExist(absoluteFilepath)) { - await visitor.versionFile?.( - { - path: version.path, - content - }, - [version.path] - ); - } - const parsedVersionFile = await validateVersionConfigFileSchema({ value: content }); - if (parsedVersionFile.type === "success") { - await visitNavigation({ - navigation: parsedVersionFile.contents.navigation, - visitor, - nodePath: ["navigation"], - absoluteFilepathToConfiguration: absoluteFilepath, - loadAPIWorkspace, - context - }); - } - }) - ); - } -} - -async function visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath, - visitor, - nodePath, - willBeUploaded = true -}: { - absoluteFilepathToConfiguration: AbsoluteFilePath; - rawUnresolvedFilepath: string; - visitor: Partial; - nodePath: NodePath; - willBeUploaded?: boolean; -}) { - const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), rawUnresolvedFilepath); - await visitor.filepath?.( - { - absoluteFilepath, - value: rawUnresolvedFilepath, - willBeUploaded - }, - nodePath - ); -} - -async function visitNavigation({ - navigation, - visitor, - nodePath, - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context -}: { - navigation: docsYml.RawSchemas.NavigationConfig; - visitor: Partial; - nodePath: NodePath; - absoluteFilepathToConfiguration: AbsoluteFilePath; - loadAPIWorkspace: APIWorkspaceLoader; - context: TaskContext; -}): Promise { - if (navigationConfigIsTabbed(navigation)) { - await Promise.all( - navigation.map(async (tab, tabIdx) => { - if (tab.layout != null) { - await Promise.all( - tab.layout.map(async (item, itemIdx) => { - await visitNavigationItem({ - navigationItem: item, - visitor, - nodePath: [...nodePath, `${tabIdx}`, "layout", `${itemIdx}`], - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context - }); - }) - ); - } - }) - ); - } else { - await Promise.all( - navigation.map(async (item, itemIdx) => { - await visitNavigationItem({ - navigationItem: item, - visitor, - nodePath: [...nodePath, `${itemIdx}`], - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context - }); - }) - ); - } -} - -async function visitNavigationItem({ - navigationItem, - visitor, - nodePath, - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context -}: { - navigationItem: docsYml.RawSchemas.NavigationItem; - visitor: Partial; - nodePath: NodePath; - absoluteFilepathToConfiguration: AbsoluteFilePath; - loadAPIWorkspace: APIWorkspaceLoader; - context: TaskContext; -}): Promise { - if (navigationItemIsPage(navigationItem)) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath: navigationItem.path, - visitor, - nodePath: [...nodePath, "page"], - willBeUploaded: false - }); - const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), navigationItem.path); - if (await doesPathExist(absoluteFilepath)) { - const content = (await readFile(absoluteFilepath)).toString(); - await visitor.markdownPage?.( - { - title: navigationItem.page, - content, - absoluteFilepath - }, - [...nodePath, "page", navigationItem.path] - ); - } - } - - if (navigationItemIsSection(navigationItem)) { - await Promise.all( - navigationItem.contents.map(async (item, itemIdx) => { - await visitNavigationItem({ - navigationItem: item, - visitor, - nodePath: [...nodePath, "section", "contents", `${itemIdx}`], - absoluteFilepathToConfiguration, - loadAPIWorkspace, - context - }); - }) - ); - } - - if (navigationItemIsApi(navigationItem)) { - const workspace = loadAPIWorkspace(navigationItem.apiName != null ? navigationItem.apiName : undefined); - if (workspace != null) { - await visitor.apiSection?.( - { - config: navigationItem, - workspace, - context - }, - [...nodePath, "api"] - ); - } - } -} - -async function visitScript({ - absoluteFilepathToConfiguration, - visitor, - script, - nodePath -}: { - absoluteFilepathToConfiguration: AbsoluteFilePath; - visitor: Partial; - script: docsYml.RawSchemas.JsConfigOptions; - nodePath: NodePath; -}) { - const rawUnresolvedFilepath = typeof script === "string" ? script : "path" in script ? script.path : null; - - if (rawUnresolvedFilepath) { - await visitFilepath({ - absoluteFilepathToConfiguration, - rawUnresolvedFilepath, - visitor, - nodePath, - willBeUploaded: true - }); - } -} - -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; -} - -function navigationItemIsSection( - item: docsYml.RawSchemas.NavigationItem -): item is docsYml.RawSchemas.SectionConfiguration { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (item as docsYml.RawSchemas.SectionConfiguration).section != null; -} - -function navigationItemIsApi( - item: docsYml.RawSchemas.NavigationItem -): item is docsYml.RawSchemas.ApiReferenceConfiguration { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (item as docsYml.RawSchemas.ApiReferenceConfiguration).api != null; -} - -function navigationConfigIsTabbed( - config: docsYml.RawSchemas.NavigationConfig -): config is docsYml.RawSchemas.TabbedNavigationConfig { - return (config as docsYml.RawSchemas.TabbedNavigationConfig)[0]?.tab != null; -} diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts new file mode 100644 index 00000000000..25f803002e4 --- /dev/null +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitDocsConfigFileYamlAst.ts @@ -0,0 +1,327 @@ +import { docsYml } from "@fern-api/configuration"; +import { AbsoluteFilePath, dirname, doesPathExist, resolve } from "@fern-api/fs-utils"; +import { TaskContext } from "@fern-api/task-context"; +import yaml from "js-yaml"; +import { DocsConfigFileAstVisitor } from "./DocsConfigFileAstVisitor"; +import { APIWorkspaceLoader } from "./APIWorkspaceLoader"; +import { noop, visitObject } from "@fern-api/core-utils"; +import { NodePath } from "@fern-api/fern-definition-schema"; +import { visitFilepath } from "./visitFilepath"; +import { visitNavigationAst } from "./visitNavigationAst"; +import { validateVersionConfigFileSchema } from "./validateVersionConfig"; +import { readFile } from "fs/promises"; + +export declare namespace visitDocsConfigFileYamlAst { + interface Args { + contents: docsYml.RawSchemas.DocsConfiguration; + visitor: Partial; + absoluteFilepathToConfiguration: AbsoluteFilePath; + absolutePathToFernFolder: AbsoluteFilePath; + context: TaskContext; + loadAPIWorkspace: APIWorkspaceLoader; + } +} + +export async function visitDocsConfigFileYamlAst({ + contents, + visitor, + absoluteFilepathToConfiguration, + context, + loadAPIWorkspace, + absolutePathToFernFolder +}: visitDocsConfigFileYamlAst.Args): Promise { + await visitor.file?.( + { + config: contents + }, + [] + ); + await visitObject(contents, { + instances: noop, + analytics: noop, + announcement: noop, + backgroundImage: async (background) => { + if (background == null) { + return; + } else if (typeof background === "string") { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: background, + visitor, + nodePath: ["background-image"] + }); + } else { + if (background.dark != null) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: background.dark, + visitor, + nodePath: ["background-image", "dark"] + }); + } + if (background.light != null) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: background.light, + visitor, + nodePath: ["background-image", "light"] + }); + } + } + }, + colors: noop, + css: async (css) => { + if (css == null) { + return; + } else if (Array.isArray(css)) { + // multiple CSS files + await Promise.all( + css.map((stylesheet, idx) => + visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: stylesheet, + visitor, + nodePath: ["css", `${idx}`], + willBeUploaded: false + }) + ) + ); + } else { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: css, + visitor, + nodePath: ["css"], + willBeUploaded: false + }); + } + }, + defaultLanguage: noop, + experimental: noop, + favicon: async (favicon) => { + if (favicon == null) { + return; + } + + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: favicon, + visitor, + nodePath: ["favicon"] + }); + }, + footerLinks: noop, + integrations: noop, + js: async (js) => { + if (js == null) { + return; + } else if (Array.isArray(js)) { + // multiple JS configs + await Promise.all( + js.map((script, idx) => + visitScript({ + absoluteFilepathToConfiguration, + visitor, + script, + nodePath: ["js", `${idx}`] + }) + ) + ); + } else { + // single JS config + await visitScript({ + absoluteFilepathToConfiguration, + visitor, + script: js, + nodePath: ["js"] + }); + } + }, + landingPage: noop, + layout: noop, + logo: async (logo) => { + if (contents.logo?.dark != null) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: contents.logo.dark, + visitor, + nodePath: ["logo", "dark"] + }); + } + if (contents.logo?.light != null) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: contents.logo.light, + visitor, + nodePath: ["logo", "light"] + }); + } + }, + metadata: noop, + navbarLinks: noop, + navigation: async (navigation) => { + if (navigation == null) { + return; + } + + await visitNavigationAst({ + absolutePathToFernFolder, + navigation, + visitor, + nodePath: ["navigation"], + absoluteFilepathToConfiguration, + loadAPIWorkspace, + context + }); + }, + redirects: noop, + tabs: noop, + title: noop, + typography: async (typography) => { + if (typography == null) { + return; + } + await visitObject(typography, { + bodyFont: async (body) => { + if (body == null) { + return; + } + await visitFontConfig({ + absoluteFilepathToConfiguration, + visitor, + font: body, + nodePath: ["typography", "bodyFont"] + }); + }, + codeFont: async (code) => { + if (code == null) { + return; + } + await visitFontConfig({ + absoluteFilepathToConfiguration, + visitor, + font: code, + nodePath: ["typography", "codeFont"] + }); + }, + headingsFont: async (headings) => { + if (headings == null) { + return; + } + await visitFontConfig({ + absoluteFilepathToConfiguration, + visitor, + font: headings, + nodePath: ["typography", "headingsFont"] + }); + } + }); + }, + versions: async (versions) => { + if (versions == null) { + return; + } + + await Promise.all( + versions.map(async (version, idx) => { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: version.path, + visitor, + nodePath: ["versions", `${idx}`], + willBeUploaded: false + }); + const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), version.path); + const content = yaml.load((await readFile(absoluteFilepath)).toString()); + if (await doesPathExist(absoluteFilepath)) { + await visitor.versionFile?.( + { + path: version.path, + content + }, + [version.path] + ); + } + const parsedVersionFile = await validateVersionConfigFileSchema({ value: content }); + if (parsedVersionFile.type === "success") { + await visitNavigationAst({ + absolutePathToFernFolder, + navigation: parsedVersionFile.contents.navigation, + visitor, + nodePath: ["navigation"], + absoluteFilepathToConfiguration: absoluteFilepath, + loadAPIWorkspace, + context + }); + } + }) + ); + } + }); +} + +async function visitFontConfig({ + absoluteFilepathToConfiguration, + visitor, + font, + nodePath +}: { + absoluteFilepathToConfiguration: AbsoluteFilePath; + visitor: Partial; + font: docsYml.RawSchemas.FontConfig; + nodePath: NodePath; +}): Promise { + if (font.path != null) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: font.path, + visitor, + nodePath, + willBeUploaded: true + }); + } + + for (const path of font.paths ?? []) { + if (typeof path === "string") { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: path, + visitor, + nodePath, + willBeUploaded: true + }); + } else { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: path.path, + visitor, + nodePath, + willBeUploaded: true + }); + } + } +} + +async function visitScript({ + absoluteFilepathToConfiguration, + visitor, + script, + nodePath +}: { + absoluteFilepathToConfiguration: AbsoluteFilePath; + visitor: Partial; + script: docsYml.RawSchemas.JsConfigOptions; + nodePath: NodePath; +}) { + const rawUnresolvedFilepath = typeof script === "string" ? script : "path" in script ? script.path : null; + + if (rawUnresolvedFilepath) { + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath, + visitor, + nodePath, + willBeUploaded: true + }); + } +} diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitFilepath.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitFilepath.ts new file mode 100644 index 00000000000..42717c2a4cf --- /dev/null +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitFilepath.ts @@ -0,0 +1,27 @@ +import { NodePath } from "@fern-api/fern-definition-schema"; +import { AbsoluteFilePath, dirname, resolve } from "@fern-api/fs-utils"; +import { DocsConfigFileAstVisitor } from "./DocsConfigFileAstVisitor"; + +export async function visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath, + visitor, + nodePath, + willBeUploaded = true +}: { + absoluteFilepathToConfiguration: AbsoluteFilePath; + rawUnresolvedFilepath: string; + visitor: Partial; + nodePath: NodePath; + willBeUploaded?: boolean; +}): Promise { + const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), rawUnresolvedFilepath); + await visitor.filepath?.( + { + absoluteFilepath, + value: rawUnresolvedFilepath, + willBeUploaded + }, + nodePath + ); +} diff --git a/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts new file mode 100644 index 00000000000..5681048f593 --- /dev/null +++ b/packages/cli/yaml/docs-validator/src/docsAst/visitNavigationAst.ts @@ -0,0 +1,200 @@ +import { docsYml } from "@fern-api/configuration"; +import { NodePath } from "@fern-api/fern-definition-schema"; +import { AbsoluteFilePath, dirname, doesPathExist, relative, resolve } from "@fern-api/fs-utils"; +import { APIWorkspaceLoader } from "./APIWorkspaceLoader"; +import { DocsConfigFileAstVisitor } from "./DocsConfigFileAstVisitor"; +import { visitFilepath } from "./visitFilepath"; +import { readFile } from "fs/promises"; +import { visitObject, noop } from "@fern-api/core-utils"; +import { TaskContext } from "@fern-api/task-context"; +import { parseImagePaths } from "@fern-api/docs-markdown-utils"; + +export declare namespace visitNavigationAst { + interface Args { + absolutePathToFernFolder: AbsoluteFilePath; + navigation: docsYml.RawSchemas.NavigationConfig; + visitor: Partial; + nodePath: NodePath; + absoluteFilepathToConfiguration: AbsoluteFilePath; + loadAPIWorkspace: APIWorkspaceLoader; + context: TaskContext; + } +} + +export async function visitNavigationAst({ + absolutePathToFernFolder, + navigation, + loadAPIWorkspace, + visitor, + absoluteFilepathToConfiguration, + context, + nodePath +}: visitNavigationAst.Args): Promise { + if (navigationConfigIsTabbed(navigation)) { + await Promise.all( + navigation.map(async (tab, tabIdx) => { + if (tab.layout != null) { + await Promise.all( + tab.layout.map(async (item, itemIdx) => { + await visitNavigationItem({ + absolutePathToFernFolder, + navigationItem: item, + visitor, + nodePath: [...nodePath, `${tabIdx}`, "layout", `${itemIdx}`], + absoluteFilepathToConfiguration, + loadAPIWorkspace, + context + }); + }) + ); + } + }) + ); + } else { + await Promise.all( + navigation.map(async (item, itemIdx) => { + await visitNavigationItem({ + absolutePathToFernFolder, + navigationItem: item, + visitor, + nodePath: [...nodePath, `${itemIdx}`], + absoluteFilepathToConfiguration, + loadAPIWorkspace, + context + }); + }) + ); + } +} +async function visitNavigationItem({ + absolutePathToFernFolder, + navigationItem, + visitor, + nodePath, + absoluteFilepathToConfiguration, + loadAPIWorkspace, + context +}: { + absolutePathToFernFolder: AbsoluteFilePath; + navigationItem: docsYml.RawSchemas.NavigationItem; + visitor: Partial; + nodePath: NodePath; + absoluteFilepathToConfiguration: AbsoluteFilePath; + loadAPIWorkspace: APIWorkspaceLoader; + context: TaskContext; +}): Promise { + await visitObject(navigationItem, { + alphabetized: noop, + api: noop, + apiName: noop, + audiences: noop, + displayErrors: noop, + snippets: noop, + summary: noop, + title: noop, + layout: noop, + icon: noop, + slug: noop, + hidden: noop, + skipSlug: noop, + paginated: noop, + playground: noop, + flattened: noop, + path: async (path: string | undefined): Promise => { + if (path == null) { + return; + } + + await visitFilepath({ + absoluteFilepathToConfiguration, + rawUnresolvedFilepath: path, + visitor, + nodePath: [...nodePath, "path"], + willBeUploaded: false + }); + }, + page: noop, + contents: async (items: docsYml.RawSchemas.NavigationItem[] | undefined): Promise => { + if (items == null) { + return; + } + items.map(async (item, idx) => { + await visitNavigationItem({ + absolutePathToFernFolder, + navigationItem: item, + visitor, + nodePath: [...nodePath, "contents", `${idx}`], + absoluteFilepathToConfiguration, + loadAPIWorkspace, + context + }); + }); + } + }); + + if (navigationItemIsPage(navigationItem)) { + const absoluteFilepath = resolve(dirname(absoluteFilepathToConfiguration), navigationItem.path); + if (await doesPathExist(absoluteFilepath)) { + const content = (await readFile(absoluteFilepath)).toString(); + await visitor.markdownPage?.( + { + title: navigationItem.page, + content, + absoluteFilepath + }, + [...nodePath, navigationItem.path] + ); + + try { + const { filepaths } = parseImagePaths(content, { + absolutePathToFernFolder, + absolutePathToMdx: absoluteFilepath + }); + + // visit each media filepath in each markdown file + for (const filepath of filepaths) { + await visitor.filepath?.( + { + absoluteFilepath: filepath, + value: relative(absolutePathToFernFolder, filepath), + willBeUploaded: true + }, + [...nodePath, navigationItem.path] + ); + } + } catch (err) {} + } + } + + if (navigationItemIsApi(navigationItem)) { + const workspace = loadAPIWorkspace(navigationItem.apiName != null ? navigationItem.apiName : undefined); + if (workspace != null) { + await visitor.apiSection?.( + { + config: navigationItem, + workspace, + context + }, + [...nodePath, "api"] + ); + } + } +} + +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; +} + +function navigationItemIsApi( + item: docsYml.RawSchemas.NavigationItem +): item is docsYml.RawSchemas.ApiReferenceConfiguration { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (item as docsYml.RawSchemas.ApiReferenceConfiguration).api != null; +} + +function navigationConfigIsTabbed( + config: docsYml.RawSchemas.NavigationConfig +): config is docsYml.RawSchemas.TabbedNavigationConfig { + return (config as docsYml.RawSchemas.TabbedNavigationConfig)[0]?.tab != null; +} diff --git a/packages/cli/yaml/docs-validator/src/index.ts b/packages/cli/yaml/docs-validator/src/index.ts index bbf51bdce26..4e953ce7d81 100644 --- a/packages/cli/yaml/docs-validator/src/index.ts +++ b/packages/cli/yaml/docs-validator/src/index.ts @@ -7,5 +7,5 @@ export { validateVersionConfigFileSchema } from "./docsAst/validateVersionConfig export { getReferencedMarkdownFiles } from "./rules/valid-markdown-link/valid-markdown-link"; export { FrontmatterSchema } from "./rules/valid-markdown/valid-markdown"; export { validateDocsWorkspace } from "./validateDocsWorkspace"; -export { visitDocsConfigFileYamlAst } from "./docsAst/visitDocsConfigFileAst"; +export { visitDocsConfigFileYamlAst } from "./docsAst/visitDocsConfigFileYamlAst"; export { type APIWorkspaceLoader } from "./docsAst/APIWorkspaceLoader"; diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-markdown/valid-markdown.ts b/packages/cli/yaml/docs-validator/src/rules/valid-markdown/valid-markdown.ts index 1f86ec64233..356da9c68b6 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-markdown/valid-markdown.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-markdown/valid-markdown.ts @@ -4,6 +4,7 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import { z } from "zod"; import { Rule } from "../../Rule"; +import { parseMarkdownToTree } from "@fern-api/docs-markdown-utils"; export const ValidMarkdownRule: Rule = { name: "valid-markdown", @@ -64,6 +65,8 @@ export const FrontmatterSchema = z.object({ async function parseMarkdown({ markdown }: { markdown: string }): Promise { try { + parseMarkdownToTree(markdown); + const parsed = await serialize(markdown, { scope: {}, mdxOptions: { diff --git a/packages/cli/yaml/docs-validator/src/validateDocsWorkspace.ts b/packages/cli/yaml/docs-validator/src/validateDocsWorkspace.ts index bdf76261d4e..18917c58cff 100644 --- a/packages/cli/yaml/docs-validator/src/validateDocsWorkspace.ts +++ b/packages/cli/yaml/docs-validator/src/validateDocsWorkspace.ts @@ -4,7 +4,7 @@ import { TaskContext } from "@fern-api/task-context"; import { DocsWorkspace } from "@fern-api/workspace-loader"; import { createDocsConfigFileAstVisitorForRules } from "./createDocsConfigFileAstVisitorForRules"; import { APIWorkspaceLoader } from "./docsAst/APIWorkspaceLoader"; -import { visitDocsConfigFileYamlAst } from "./docsAst/visitDocsConfigFileAst"; +import { visitDocsConfigFileYamlAst } from "./docsAst/visitDocsConfigFileYamlAst"; import { getAllRules } from "./getAllRules"; import { Rule } from "./Rule"; import { ValidationViolation } from "./ValidationViolation"; @@ -41,14 +41,17 @@ export async function runRulesOnDocsWorkspace({ } }); - await visitDocsConfigFileYamlAst( - workspace.config, - astVisitor, - join(workspace.absoluteFilePath, RelativeFilePath.of(DOCS_CONFIGURATION_FILENAME)), - workspace.absoluteFilePath, + await visitDocsConfigFileYamlAst({ + contents: workspace.config, + visitor: astVisitor, + absoluteFilepathToConfiguration: join( + workspace.absoluteFilePath, + RelativeFilePath.of(DOCS_CONFIGURATION_FILENAME) + ), + absolutePathToFernFolder: workspace.absoluteFilePath, context, - loadApiWorkspace - ); + loadAPIWorkspace: loadApiWorkspace + }); return violations; } diff --git a/packages/commons/core-utils/src/ObjectPropertiesVisitor.ts b/packages/commons/core-utils/src/ObjectPropertiesVisitor.ts index a86075adc78..5ec5d19bd20 100644 --- a/packages/commons/core-utils/src/ObjectPropertiesVisitor.ts +++ b/packages/commons/core-utils/src/ObjectPropertiesVisitor.ts @@ -4,7 +4,7 @@ export type ObjectPropertiesVisitor = { [K in keyof T]-?: (value: T[K]) => R; }; -export async function visitObject>( +export async function visitObject( object: T, visitor: ObjectPropertiesVisitor> ): Promise {