diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07108c924f..e07b67eb03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,6 +162,13 @@ type LinkItem = { * Set to "_blank" to open link in a new tab */ target?: '_blank' + + /** + * Limit this page to only show when the user has one of the specified sdks active + * + * @example ['nextjs', 'react'] + */ + sdk?: string[] } type SubNavItem = { /** @@ -201,6 +208,13 @@ type SubNavItem = { * @default false */ collapse?: boolean + + /** + * Limit this group to only show when the user has one of the specified sdks active + * + * @example ['nextjs', 'react'] + */ + sdk?: string[] } ``` diff --git a/package-lock.json b/package-lock.json index e6a024e745..70cb2b12de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "remark-mdx": "^3.0.1", "tsx": "^4.19.2", "typescript": "^5.7.3", + "unist-util-filter": "^5.0.1", "unist-util-map": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.1", @@ -3896,6 +3897,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-filter": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-5.0.1.tgz", + "integrity": "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + } + }, "node_modules/unist-util-is": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", diff --git a/package.json b/package.json index 394885d975..639fc027a6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "remark-mdx": "^3.0.1", "tsx": "^4.19.2", "typescript": "^5.7.3", + "unist-util-filter": "^5.0.1", "unist-util-map": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.1", diff --git a/scripts/build-docs.test.ts b/scripts/build-docs.test.ts index 955a66c992..590ffaf9e2 100644 --- a/scripts/build-docs.test.ts +++ b/scripts/build-docs.test.ts @@ -4,7 +4,8 @@ import os from 'node:os' import { glob } from 'glob' import { describe, expect, onTestFinished, test } from 'vitest' -import { build, createConfig } from './build-docs' +import { build } from './build-docs' +import { createConfig } from './lib/config' const tempConfig = { // Set to true to use local repo temp directory instead of system temp @@ -91,32 +92,64 @@ async function createTempFiles( } } +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function readFile(filePath: string): Promise { + return normalizeString(await fs.readFile(filePath, 'utf-8')) +} + +function normalizeString(str: string): string { + return str.replace(/\r\n/g, '\n').trim() +} + +function treeDir(baseDir: string) { + return glob('**/*', { + cwd: baseDir, + nodir: true, // Only return files, not directories + }) +} + const baseConfig = { docsPath: '../docs', + baseDocsLink: '/docs/', manifestPath: '../docs/manifest.json', partialsPath: '../docs/_partials', typedocPath: '../typedoc', - ignorePaths: ['/docs/_partials'], - ignoreWarnings: {}, + distPath: '../dist', + ignoreLinks: [], + ignoreWarnings: { + docs: {}, + partials: {}, + typedoc: {}, + }, manifestOptions: { wrapDefault: true, collapseDefault: false, hideTitleDefault: false, }, + cleanDist: false, } -test('Basic build test with simple files', async () => { - // Create temp environment with minimal files array - const { tempDir, pathJoin } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], - }), - }, - { - path: './docs/simple-test.mdx', - content: `--- +describe('Basic Functionality', () => { + test('Basic build test with simple files', async () => { + // Create temp environment with minimal files array + const { tempDir, pathJoin } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], + }), + }, + { + path: './docs/simple-test.mdx', + content: `--- title: Simple Test description: This is a simple test page --- @@ -124,63 +157,142 @@ description: This is a simple test page # Simple Test Page Testing with a simple page.`, - }, - ]) + }, + ]) - const output = await build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['nextjs', 'react'], - }), - ) + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['nextjs', 'react'], + }), + ) - expect(output).toBe('') -}) + expect(output).toBe('') + }) -test('Warning on missing description in frontmatter', async () => { - // Create temp environment with minimal files array - const { tempDir, pathJoin } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], - }), - }, - { - path: './docs/simple-test.mdx', - content: `--- + test('Warning on missing description in frontmatter', async () => { + // Create temp environment with minimal files array + const { tempDir, pathJoin } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], + }), + }, + { + path: './docs/simple-test.mdx', + content: `--- title: Simple Test --- # Simple Test Page Testing with a simple page.`, - }, - ]) + }, + ]) - const output = await build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['nextjs', 'react'], - }), - ) + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['nextjs', 'react'], + }), + ) - expect(output).toContain('warning Frontmatter should have a "description" property') + expect(output).toContain('warning Frontmatter should have a "description" property') + }) }) -test('Invalid SDK in frontmatter fails the build', async () => { - const { tempDir, pathJoin } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], +describe('Manifest Validation', () => { + test('should fail build with completely malformed manifest JSON', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: '{invalid json structure', + }, + { + path: './docs/simple-test.mdx', + content: `--- +title: Simple Test +--- + +# Simple Test`, + }, + ]) + + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react'], }), - }, - { - path: './docs/simple-test.mdx', - content: `--- + ) + + await expect(promise).rejects.toThrow('Failed to parse manifest:') + }) + + test('Check link and hash in partial is valid', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [ + [ + { title: 'Page 1', href: '/docs/page-1' }, + { title: 'Page 2', href: '/docs/page-2' }, + ], + ], + }), + }, + { + path: './docs/page-1.mdx', + content: `--- +title: Page 1 +--- + +`, + }, + { + path: './docs/_partials/links.mdx', + content: `[Page 2](/docs/page-2#my-heading) +[Page 2](/docs/page-3)`, + }, + { + path: './docs/page-2.mdx', + content: `--- +title: Page 2 +--- + +test`, + }, + ]) + + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react'], + }), + ) + + expect(output).toContain(`warning Hash "my-heading" not found in /docs/page-2`) + expect(output).toContain(`warning Doc /docs/page-3 not found`) + }) +}) + +describe('SDK Processing', () => { + test('Invalid SDK in frontmatter fails the build', async () => { + const { tempDir, pathJoin } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], + }), + }, + { + path: './docs/simple-test.mdx', + content: `--- title: Simple Test sdk: react, expo, coffeescript --- @@ -188,104 +300,256 @@ sdk: react, expo, coffeescript # Simple Test Page Testing with a simple page.`, - }, - ]) + }, + ]) - const promise = build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['react', 'expo'], - }), - ) + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react', 'expo'], + }), + ) - await expect(promise).rejects.toThrow(`Invalid SDK ["coffeescript"], the valid SDKs are ["react","expo"]`) -}) + await expect(promise).rejects.toThrow(`Invalid SDK ["coffeescript"], the valid SDKs are ["react","expo"]`) + }) -test('Invalid SDK in fails the build', async () => { - const { tempDir } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], - }), - }, - { - path: './docs/simple-test.mdx', - content: `--- + test('Invalid SDK in fails the build', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Simple Test', href: '/docs/simple-test' }]], + }), + }, + { + path: './docs/simple-test.mdx', + content: `--- title: Simple Test sdk: react, expo --- -# Simple Test Page - - - astro Content +# Simple Test Page + + + astro Content + + +Testing with a simple page.`, + }, + ]) + + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react', 'expo'], + }), + ) + + expect(output).toContain(`warning sdk \"astro\" in is not a valid SDK`) + }) + + test('should fail when child SDK is not in parent SDK list', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [ + [ + { + title: 'Authentication', + sdk: ['react'], + items: [ + [ + { + title: 'Login', + href: '/docs/auth/login', + sdk: ['react', 'python'], // python not in parent + }, + ], + ], + }, + ], + ], + }), + }, + { + path: './docs/auth/login.mdx', + content: `--- +title: Login +sdk: react, python +--- + +# Login Page + +Authentication login documentation.`, + }, + ]) + + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react', 'python', 'nextjs'], + }), + ) + + await expect(promise).rejects.toThrow( + 'Doc "Login" is attempting to use ["react","python"] But its being filtered down to ["react"] in the manifest.json', + ) + }) +}) + +describe('Heading Validation', () => { + test('should error on duplicate headings', async () => { + const { tempDir, pathJoin } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Duplicate Headings', href: '/docs/duplicate-headings' }]], + }), + }, + { + path: './docs/duplicate-headings.mdx', + content: `--- +title: Duplicate Headings +--- + +# Heading {{ id: 'custom-id' }} + +## Another Heading {{ id: 'custom-id' }} + +[Link to first heading](#custom-id)`, + }, + ]) + + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react'], + }), + ) + + await expect(promise).rejects.toThrow( + 'Doc "/docs/duplicate-headings" contains a duplicate heading id "custom-id", please ensure all heading ids are unique', + ) + }) + + test('should not error on duplicate headings if they are in different components', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Quickstart', href: '/docs/quickstart' }]], + }), + }, + { + path: './docs/quickstart.mdx', + content: `--- +title: Quickstart +description: Quickstart page +sdk: react, nextjs +--- + + + # Title {{ id: 'title' }} + + + + # Title {{ id: 'title' }} +`, + }, + ]) + + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react', 'nextjs'], + }), + ) + + expect(output).toBe('') + }) + + test('should error on duplicate headings if they are in different components but with the same sdk', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Quickstart', href: '/docs/quickstart' }]], + }), + }, + { + path: './docs/quickstart.mdx', + content: `--- +title: Quickstart +description: Quickstart page +sdk: react, nextjs +--- + + + # Title {{ id: 'title' }} -Testing with a simple page.`, - }, - ]) + + # Title {{ id: 'title' }} +`, + }, + ]) - const output = await build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['react', 'expo'], - }), - ) + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react', 'nextjs'], + }), + ) - expect(output).toContain(`warning sdk \"astro\" in is not a valid SDK`) -}) + await expect(promise).rejects.toThrow( + 'Doc "/docs/quickstart.mdx" contains a duplicate heading id "title", please ensure all heading ids are unique', + ) + }) -test('should fail when child SDK is not in parent SDK list', async () => { - const { tempDir } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [ - [ - { - title: 'Authentication', - sdk: ['react'], - items: [ - [ - { - title: 'Login', - href: '/docs/auth/login', - sdk: ['react', 'python'], // python not in parent - }, - ], - ], - }, - ], - ], - }), - }, - { - path: './docs/auth/login.mdx', - content: `--- -title: Login -sdk: react, python + test('should error on duplicate headings if they are in different components but with the same sdk without sdk in frontmatter', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [[{ title: 'Quickstart', href: '/docs/quickstart' }]], + }), + }, + { + path: './docs/quickstart.mdx', + content: `--- +title: Quickstart +description: Quickstart page --- -# Login Page + + # Title {{ id: 'title' }} + -Authentication login documentation.`, - }, - ]) + + # Title {{ id: 'title' }} +`, + }, + ]) - const promise = build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['react', 'python', 'nextjs'], - }), - ) + const promise = build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react'], + }), + ) - await expect(promise).rejects.toThrow( - 'Doc "Login" is attempting to use ["react","python"] But its being filtered down to ["react"] in the manifest.json', - ) + await expect(promise).rejects.toThrow( + 'Doc "/docs/quickstart.mdx" contains a duplicate heading id "title", please ensure all heading ids are unique', + ) + }) }) describe('Includes and Partials', () => { @@ -582,43 +846,46 @@ title: Simple Test expect(output).not.toContain(`warning Hash "my-heading" not found in /docs/headings`) }) - test('Check link and hash in partial is valid', async () => { - const { tempDir } = await createTempFiles([ + test('should correctly handle links with anchors to specific sections of documents', async () => { + const { tempDir, pathJoin } = await createTempFiles([ { path: './docs/manifest.json', content: JSON.stringify({ navigation: [ [ - { title: 'Page 1', href: '/docs/page-1' }, - { title: 'Page 2', href: '/docs/page-2' }, + { title: 'Source Document', href: '/docs/source-document' }, + { title: 'Target Document', href: '/docs/target-document' }, ], ], }), }, { - path: './docs/page-1.mdx', + path: './docs/source-document.mdx', content: `--- -title: Page 1 +title: Source Document --- -`, - }, - { - path: './docs/_partials/links.mdx', - content: `--- -title: Links ---- +# Source Document -[Page 2](/docs/page-2#my-heading) -[Page 2](/docs/page-3)`, +[Link to Section 1](/docs/target-document#section-1) +[Link to Section 2](/docs/target-document#section-2) +[Link to Invalid Section](/docs/target-document#invalid-section)`, }, { - path: './docs/page-2.mdx', + path: './docs/target-document.mdx', content: `--- -title: Page 2 +title: Target Document --- -test`, +# Target Document + +## Section 1 + +Content for section 1. + +## Section 2 + +Content for section 2.`, }, ]) @@ -630,8 +897,12 @@ test`, }), ) - expect(output).toContain(`warning Hash "my-heading" not found in /docs/page-2`) - expect(output).toContain(`warning Doc /docs/page-3 not found`) + // Valid links should work without warnings + expect(output).not.toContain('warning Hash "section-1" not found') + expect(output).not.toContain('warning Hash "section-2" not found') + + // Invalid link should produce a warning + expect(output).toContain('warning Hash "invalid-section" not found in /docs/target-document') }) test('Links with only a hash to the same page are valid', async () => { @@ -698,6 +969,57 @@ description: This is a test page }) describe('Path and File Handling', () => { + test('should ignore paths specified in ignorePaths during processing', async () => { + const { tempDir } = await createTempFiles([ + { + path: './docs/manifest.json', + content: JSON.stringify({ + navigation: [ + [ + { title: 'Core Guide', href: '/docs/core-guide' }, + { title: 'Scoped Guide', href: '/docs/scoped-guide' }, + ], + ], + }), + }, + { + path: './docs/_partials/ignored-partial.mdx', + content: `[Ignored Guide](/docs/ignored/ignored-guide)`, + }, + { + path: './docs/core-guide.mdx', + content: `--- +title: Core Guide +description: Not sdk specific guide +--- + + +[Ignored Guide](/docs/ignored/ignored-guide)`, + }, + { + path: './docs/scoped-guide.mdx', + content: `--- +title: Scoped Guide +description: guide specific to react +sdk: react +--- + +[Ignored Guide](/docs/ignored/ignored-guide)`, + }, + ]) + + const output = await build( + createConfig({ + ...baseConfig, + basePath: tempDir, + validSdks: ['react'], + ignoreLinks: ['/docs/ignored'], + }), + ) + + expect(output).toBe('') + }) + test('should detect file path conflicts when a core doc path matches an SDK path', async () => { const { tempDir } = await createTempFiles([ { @@ -902,123 +1224,9 @@ This document doesn't have the referenced header.`, // Should report warning about missing hash expect(output).toContain('warning Hash "non-existent-header" not found in /docs/invalid-reference') }) - - test('should complete build workflow when errors are present in some files', async () => { - const { tempDir, pathJoin } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [ - [ - { title: 'Valid Document', href: '/docs/valid-document' }, - { title: 'Document with Warnings', href: '/docs/document-with-warnings' }, - ], - ], - }), - }, - { - path: './docs/valid-document.mdx', - content: `--- -title: Valid Document ---- - -# Valid Document - -This is a completely valid document.`, - }, - { - path: './docs/document-with-warnings.mdx', - content: `--- -title: Document with Warnings ---- - -# Document with Warnings - -[Broken Link](/docs/non-existent-document) - - - This content has an invalid SDK. -`, - }, - ]) - - // Should complete with warnings - const output = await build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['react'], - }), - ) - - // Check that warnings were reported - expect(output).toContain('warning Doc /docs/non-existent-document not found') - expect(output).toContain('warning sdk "invalid-sdk" in is not a valid SDK') - }) -}) - -describe('Advanced Features', () => { - test('should correctly handle links with anchors to specific sections of documents', async () => { - const { tempDir, pathJoin } = await createTempFiles([ - { - path: './docs/manifest.json', - content: JSON.stringify({ - navigation: [ - [ - { title: 'Source Document', href: '/docs/source-document' }, - { title: 'Target Document', href: '/docs/target-document' }, - ], - ], - }), - }, - { - path: './docs/source-document.mdx', - content: `--- -title: Source Document ---- - -# Source Document - -[Link to Section 1](/docs/target-document#section-1) -[Link to Section 2](/docs/target-document#section-2) -[Link to Invalid Section](/docs/target-document#invalid-section)`, - }, - { - path: './docs/target-document.mdx', - content: `--- -title: Target Document ---- - -# Target Document - -## Section 1 - -Content for section 1. - -## Section 2 - -Content for section 2.`, - }, - ]) - - const output = await build( - createConfig({ - ...baseConfig, - basePath: tempDir, - validSdks: ['react'], - }), - ) - - // Valid links should work without warnings - expect(output).not.toContain('warning Hash "section-1" not found') - expect(output).not.toContain('warning Hash "section-2" not found') - - // Invalid link should produce a warning - expect(output).toContain('warning Hash "invalid-section" not found in /docs/target-document') - }) }) -describe('configuration', () => { +describe('Configuration Options', () => { describe('ignoreWarnings', () => { test('Should ignore certain warnings for a file when set', async () => { const { tempDir } = await createTempFiles([ @@ -1045,7 +1253,11 @@ description: This page has a description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/index.mdx': ['doc-not-in-manifest'], + docs: { + 'index.mdx': ['doc-not-in-manifest'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1088,7 +1300,11 @@ description: This page has a description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/problem-file.mdx': ['doc-not-in-manifest', 'link-doc-not-found', 'invalid-sdk-in-if'], + docs: { + 'problem-file.mdx': ['doc-not-in-manifest', 'link-doc-not-found', 'invalid-sdk-in-if'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1134,8 +1350,12 @@ description: This page has a description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/file1.mdx': ['doc-not-in-manifest', 'link-doc-not-found'], - '/docs/file2.mdx': ['doc-not-in-manifest', 'link-doc-not-found'], + docs: { + 'file1.mdx': ['doc-not-in-manifest', 'link-doc-not-found'], + 'file2.mdx': ['doc-not-in-manifest', 'link-doc-not-found'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1184,7 +1404,11 @@ description: This page has a description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/partial-ignore.mdx': ['link-doc-not-found'], + docs: { + 'partial-ignore.mdx': ['link-doc-not-found'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1228,11 +1452,15 @@ description: This page has a description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/component-issues.mdx': [ - 'doc-not-in-manifest', - 'component-missing-attribute', - 'include-src-not-partials', - ], + docs: { + 'component-issues.mdx': [ + 'doc-not-in-manifest', + 'component-missing-attribute', + 'include-src-not-partials', + ], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1268,7 +1496,11 @@ title: Missing Description basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/missing-description.mdx': ['frontmatter-missing-description'], + docs: { + 'missing-description.mdx': ['frontmatter-missing-description'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1319,7 +1551,11 @@ description: The page being linked to basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/source-page.mdx': ['link-hash-not-found'], + docs: { + 'source-page.mdx': ['link-hash-not-found'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1372,7 +1608,11 @@ description: This page has a description basePath: tempDir, validSdks: ['react', 'nodejs'], ignoreWarnings: { - '/docs/sdk-doc.mdx': ['doc-sdk-filtered-by-parent'], + docs: { + 'sdk-doc.mdx': ['doc-sdk-filtered-by-parent'], + }, + partials: {}, + typedoc: {}, }, }), ) @@ -1412,7 +1652,11 @@ description: Test page with partial basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - 'docs/_partials/test-partial.mdx': ['link-doc-not-found'], + partials: { + 'test-partial.mdx': ['link-doc-not-found'], + }, + docs: {}, + typedoc: {}, }, }), ) @@ -1593,7 +1837,11 @@ interface Client { basePath: tempDir, validSdks: ['react'], ignoreWarnings: { - '/docs/api-doc.mdx': ['typedoc-not-found'], + docs: { + 'api-doc.mdx': ['typedoc-not-found'], + }, + partials: {}, + typedoc: {}, }, }), ) diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts index 817d5bae37..a6a9a43a89 100644 --- a/scripts/build-docs.ts +++ b/scripts/build-docs.ts @@ -2,891 +2,106 @@ // Validates // - The manifest -// - The markdown files contents (including frontmatter) -// - Links (including hashes) between docs are valid +// - The markdown files contents (including required frontmatter fields) +// - Links (including hashes) between docs are valid and point to existing headings // - The sdk filtering in the manifest // - The sdk filtering in the frontmatter // - The sdk filtering in the component // - Checks that the sdk is available in the manifest // - Checks that the sdk is available in the frontmatter +// - Validates sdk values against the list of valid SDKs +// - URL encoding (prevents browser encoding issues) +// - File existence for both docs and partials +// - Path conflicts (prevents SDK name conflicts in paths) -import fs from 'node:fs/promises' import path from 'node:path' -import remarkMdx from 'remark-mdx' import { remark } from 'remark' -import { visit as mdastVisit } from 'unist-util-visit' -import { map as mdastMap } from 'unist-util-map' import remarkFrontmatter from 'remark-frontmatter' -import yaml from 'yaml' -import { slugifyWithCounter } from '@sindresorhus/slugify' -import { toString } from 'mdast-util-to-string' +import remarkMdx from 'remark-mdx' +import { Node } from 'unist' +import { filter as mdastFilter } from 'unist-util-filter' +import { visit as mdastVisit } from 'unist-util-visit' import reporter from 'vfile-reporter' -import readdirp from 'readdirp' -import { z } from 'zod' -import { fromError, type ValidationError } from 'zod-validation-error' -import { Node, Position } from 'unist' -import { existsSync } from 'node:fs' - -const errorMessages = { - // Manifest errors - 'manifest-parse-error': (error: ValidationError): string => `Failed to parse manifest: ${error}`, - - // Component errors - 'component-no-props': (componentName: string): string => `<${componentName} /> component has no props`, - 'component-attributes-not-array': (componentName: string): string => - `<${componentName} /> node attributes is not an array (this is a bug with the build script, please report)`, - 'component-missing-attribute': (componentName: string, propName: string): string => - `<${componentName} /> component has no "${propName}" attribute`, - 'component-attribute-no-value': (componentName: string, propName: string): string => - `<${componentName} /> attribute "${propName}" has no value (this is a bug with the build script, please report)`, - 'component-attribute-unsupported-type': (componentName: string, propName: string): string => - `<${componentName} /> attribute "${propName}" has an unsupported value type`, - - // SDK errors - 'invalid-sdks-in-if': (invalidSDKs: string[]): string => - `sdks "${invalidSDKs.join('", "')}" in are not valid SDKs`, - 'invalid-sdk-in-if': (sdk: string): string => `sdk "${sdk}" in is not a valid SDK`, - 'invalid-sdk-in-frontmatter': (invalidSDKs: string[], validSdks: SDK[]): string => - `Invalid SDK ${JSON.stringify(invalidSDKs)}, the valid SDKs are ${JSON.stringify(validSdks)}`, - 'if-component-sdk-not-in-frontmatter': (sdk: SDK, docSdk: SDK[]): string => - ` component is attempting to filter to sdk "${sdk}" but it is not available in the docs frontmatter ["${docSdk.join('", "')}"], if this is a mistake please remove it from the otherwise update the frontmatter to include "${sdk}"`, - 'if-component-sdk-not-in-manifest': (sdk: SDK, href: string): string => - ` component is attempting to filter to sdk "${sdk}" but it is not available in the manifest.json for ${href}, if this is a mistake please remove it from the otherwise update the manifest.json to include "${sdk}"`, - 'doc-sdk-filtered-by-parent': (title: string, docSDK: SDK[], parentSDK: SDK[]): string => - `Doc "${title}" is attempting to use ${JSON.stringify(docSDK)} But its being filtered down to ${JSON.stringify(parentSDK)} in the manifest.json`, - 'group-sdk-filtered-by-parent': (title: string, groupSDK: SDK[], parentSDK: SDK[]): string => - `Group "${title}" is attempting to use ${JSON.stringify(groupSDK)} But its being filtered down to ${JSON.stringify(parentSDK)} in the manifest.json`, - - // Document structure errors - 'doc-not-in-manifest': (): string => - 'This doc is not in the manifest.json, but will still be publicly accessible and other docs can link to it', - 'invalid-href-encoding': (href: string): string => - `Href "${href}" contains characters that will be encoded by the browser, please remove them`, - 'frontmatter-missing-title': (): string => 'Frontmatter must have a "title" property', - 'frontmatter-missing-description': (): string => 'Frontmatter should have a "description" property', - 'frontmatter-parse-failed': (href: string): string => `Frontmatter parsing failed for ${href}`, - 'doc-not-found': (title: string, href: string): string => - `Doc "${title}" in manifest.json not found in the docs folder at ${href}.mdx`, - 'sdk-path-conflict': (href: string, path: string): string => - `Doc "${href}" is attempting to write out a doc to ${path} but the first part of the path is a valid SDK, this causes a file path conflict.`, - - // Include component errors - 'include-src-not-partials': (): string => ` prop "src" must start with "_partials/"`, - 'partial-not-found': (src: string): string => `Partial /docs/${src}.mdx not found`, - 'partials-inside-partials': (): string => - 'Partials inside of partials is not yet supported (this is a bug with the build script, please report)', - - // Link validation errors - 'link-doc-not-found': (url: string): string => `Doc ${url} not found`, - 'link-hash-not-found': (hash: string, url: string): string => `Hash "${hash}" not found in ${url}`, - - // File reading errors - 'file-read-error': (filePath: string): string => `file ${filePath} doesn't exist`, - 'partial-read-error': (path: string): string => `Failed to read in ${path} from partials file`, - 'markdown-read-error': (href: string): string => `Attempting to read in ${href}.mdx failed`, - 'partial-parse-error': (path: string): string => `Failed to parse the content of ${path}`, - - // Typedoc errors - 'typedoc-folder-not-found': (path: string): string => - `Typedoc folder ${path} not found, run "npm run typedoc:download"`, - 'typedoc-read-error': (filePath: string): string => `Failed to read in ${filePath} from typedoc file`, - 'typedoc-parse-error': (filePath: string): string => `Failed to parse ${filePath} from typedoc file`, - 'typedoc-not-found': (filePath: string): string => `Typedoc ${filePath} not found`, -} as const - -type WarningCode = keyof typeof errorMessages - -// Helper function to check if a warning should be ignored -const shouldIgnoreWarning = (config: BuildConfig, filePath: string, warningCode: WarningCode): boolean => { - if (!config.ignoreWarnings) { - return false - } - - const ignoreList = config.ignoreWarnings[filePath] - if (!ignoreList) { - return false - } - - return ignoreList.includes(warningCode) -} - -const safeMessage = >( - config: BuildConfig, - vfile: VFile, - filePath: string, - warningCode: TCode, - args: TArgs, - position?: Position, -) => { - if (!shouldIgnoreWarning(config, filePath, warningCode)) { - // @ts-expect-error - TypeScript has trouble with spreading args into the function - const message = errorMessages[warningCode](...args) - vfile.message(message, position) - } -} - -const safeFail = >( - config: BuildConfig, - vfile: VFile, - filePath: string, - warningCode: TCode, - args: TArgs, - position?: Position, -) => { - if (!shouldIgnoreWarning(config, filePath, warningCode)) { - // @ts-expect-error - TypeScript has trouble with spreading args into the function - const message = errorMessages[warningCode](...args) - vfile.fail(message, position) - } -} - -const VALID_SDKS = [ - 'nextjs', - 'react', - 'js-frontend', - 'chrome-extension', - 'expo', - 'ios', - 'nodejs', - 'expressjs', - 'fastify', - 'react-router', - 'remix', - 'tanstack-react-start', - 'go', - 'astro', - 'nuxt', - 'vue', - 'ruby', - 'python', - 'js-backend', - 'sdk-development', - 'community-sdk', -] as const - -type SDK = (typeof VALID_SDKS)[number] - -const sdk = z.enum(VALID_SDKS) - -const icon = z.enum([ - 'apple', - 'application-2', - 'arrow-up-circle', - 'astro', - 'angular', - 'block', - 'bolt', - 'book', - 'box', - 'c-sharp', - 'chart', - 'checkmark-circle', - 'chrome', - 'clerk', - 'code-bracket', - 'cog-6-teeth', - 'door', - 'elysia', - 'expressjs', - 'globe', - 'go', - 'home', - 'hono', - 'javascript', - 'koa', - 'link', - 'linkedin', - 'lock', - 'nextjs', - 'nodejs', - 'plug', - 'plus-circle', - 'python', - 'react', - 'redwood', - 'remix', - 'react-router', - 'rocket', - 'route', - 'ruby', - 'rust', - 'speedometer', - 'stacked-rectangle', - 'solid', - 'svelte', - 'tanstack', - 'user-circle', - 'user-dotted-circle', - 'vue', - 'x', - 'expo', - 'nuxt', - 'fastify', -]) - -type Icon = z.infer - -const tag = z.enum(['(Beta)', '(Community)']) - -type Tag = z.infer - -type ManifestItem = { - title: string - href: string - tag?: Tag - wrap?: boolean - icon?: Icon - target?: '_blank' - sdk?: SDK[] -} - -type ManifestGroup = { - title: string - items: Manifest - collapse?: boolean - tag?: Tag - wrap?: boolean - icon?: Icon - hideTitle?: boolean - sdk?: SDK[] -} - -type Manifest = (ManifestItem | ManifestGroup)[][] - -// Create manifest schema based on config -const createManifestSchema = (config: BuildConfig) => { - const manifestItem: z.ZodType = z - .object({ - title: z.string(), - href: z.string(), - tag: tag.optional(), - wrap: z.boolean().default(config.manifestOptions.wrapDefault), - icon: icon.optional(), - target: z.enum(['_blank']).optional(), - sdk: z.array(sdk).optional(), - }) - .strict() - - const manifestGroup: z.ZodType = z - .object({ - title: z.string(), - items: z.lazy(() => manifestSchema), - collapse: z.boolean().default(config.manifestOptions.collapseDefault), - tag: tag.optional(), - wrap: z.boolean().default(config.manifestOptions.wrapDefault), - icon: icon.optional(), - hideTitle: z.boolean().default(config.manifestOptions.hideTitleDefault), - sdk: z.array(sdk).optional(), - }) - .strict() - - const manifestSchema: z.ZodType = z.array(z.array(z.union([manifestItem, manifestGroup]))) - return { - manifestItem, - manifestGroup, - manifestSchema, - } -} - -const isValidSdk = - (config: BuildConfig) => - (sdk: string): sdk is SDK => { - return config.validSdks.includes(sdk as SDK) - } - -const isValidSdks = - (config: BuildConfig) => - (sdks: string[]): sdks is SDK[] => { - return sdks.every(isValidSdk(config)) - } - -const readManifest = (config: BuildConfig) => async (): Promise => { - const { manifestSchema } = createManifestSchema(config) - const unsafe_manifest = await fs.readFile(config.manifestFilePath, { encoding: 'utf-8' }) - - const manifest = await manifestSchema.safeParseAsync(JSON.parse(unsafe_manifest).navigation) - - if (manifest.success === true) { - return manifest.data - } - - throw new Error(errorMessages['manifest-parse-error'](fromError(manifest.error))) -} - -const readMarkdownFile = (config: BuildConfig) => async (docPath: string) => { - const filePath = path.join(config.docsPath, docPath) - - try { - const fileContent = await fs.readFile(filePath, { encoding: 'utf-8' }) - return [null, fileContent] as const - } catch (error) { - return [new Error(errorMessages['file-read-error'](filePath), { cause: error }), null] as const - } -} - -const readDocsFolder = (config: BuildConfig) => async () => { - return readdirp.promise(config.docsPath, { - type: 'files', - fileFilter: (entry) => - config.ignorePaths.some((ignoreItem) => `/docs/${entry.path}`.startsWith(ignoreItem)) === false && - entry.path.endsWith('.mdx'), - }) -} - -const readPartialsFolder = (config: BuildConfig) => async () => { - return readdirp.promise(config.partialsPath, { - type: 'files', - fileFilter: '*.mdx', - }) -} +import { createConfig, type BuildConfig } from './lib/config' +import { errorMessages, shouldIgnoreWarning } from './lib/error-messages' +import { readDocsFolder } from './lib/io' +import { flattenTree, ManifestGroup, readManifest, traverseTree, traverseTreeItemsFirst } from './lib/manifest' +import { parseInMarkdownFile } from './lib/markdown' +import { readPartialsFolder, readPartialsMarkdown } from './lib/partials' +import { isValidSdk, VALID_SDKS, type SDK } from './lib/schemas' +import { DocsMap } from './lib/store' +import { readTypedocsFolder, readTypedocsMarkdown } from './lib/typedoc' + +import { documentHasIfComponents } from './lib/utils/documentHasIfComponents' +import { extractComponentPropValueFromNode } from './lib/utils/extractComponentPropValueFromNode' +import { extractSDKsFromIfProp } from './lib/utils/extractSDKsFromIfProp' +import { removeMdxSuffix } from './lib/utils/removeMdxSuffix' + +import { filterOtherSDKsContentOut } from './lib/plugins/filterOtherSDKsContentOut' +import { validateIfComponents } from './lib/plugins/validateIfComponents' +import { validateLinks } from './lib/plugins/validateLinks' +import { validateUniqueHeadings } from './lib/plugins/validateUniqueHeadings' -const readPartialsMarkdown = (config: BuildConfig) => async (paths: string[]) => { - const readFile = readMarkdownFile(config) - - return Promise.all( - paths.map(async (markdownPath) => { - const fullPath = path.join(config.docsRelativePath, config.partialsRelativePath, markdownPath) - - const [error, content] = await readFile(fullPath) - - if (error) { - throw new Error(errorMessages['partial-read-error'](fullPath), { cause: error }) - } - - let partialNode: Node | null = null - - const partialContentVFile = await markdownProcessor() - .use(() => (tree, vfile) => { - mdastVisit( - tree, - (node) => - (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && - 'name' in node && - node.name === 'Include', - (node) => { - safeFail(config, vfile, fullPath, 'partials-inside-partials', [], node.position) - }, - ) - - partialNode = tree - }) - .process({ - path: `docs/_partials/${markdownPath}`, - value: content, - }) - - const partialContentReport = reporter([partialContentVFile], { quiet: true }) - - if (partialContentReport !== '') { - console.error(partialContentReport) - process.exit(1) - } - - if (partialNode === null) { - throw new Error(errorMessages['partial-parse-error'](markdownPath)) - } - - return { - path: markdownPath, - content, - vfile: partialContentVFile, - node: partialNode as Node, - } - }), - ) +// Only invokes the main function if we run the script directly eg npm run build, bun run ./scripts/build-docs.ts +if (require.main === module) { + main() } -const readTypedocsFolder = (config: BuildConfig) => async () => { - return readdirp.promise(config.typedocPath, { - type: 'files', - fileFilter: '*.mdx', +async function main() { + const config = createConfig({ + basePath: __dirname, + docsPath: '../docs', + baseDocsLink: '/docs/', + manifestPath: '../docs/manifest.json', + partialsPath: '../docs/_partials', + typedocPath: '../clerk-typedoc', + ignoreLinks: [ + '/docs/core-1', + '/docs/reference/backend-api', + '/docs/reference/frontend-api', + '/pricing', + '/support', + '/discord', + '/contact', + '/contact/sales', + '/contact/support', + '/blog', + '/changelog/2024-04-19', + ], + ignoreWarnings: { + docs: { + 'index.mdx': ['doc-not-in-manifest'], + 'guides/overview.mdx': ['doc-not-in-manifest'], + 'quickstarts/overview.mdx': ['doc-not-in-manifest'], + 'references/overview.mdx': ['doc-not-in-manifest'], + 'maintenance-mode.mdx': ['doc-not-in-manifest'], + 'deployments/staging-alternatives.mdx': ['doc-not-in-manifest'], + 'references/nextjs/usage-with-older-versions.mdx': ['doc-not-in-manifest'], + }, + typedoc: { + 'types/active-session-resource.mdx': ['link-hash-not-found'], + 'types/pending-session-resource.mdx': ['link-hash-not-found'], + }, + partials: {}, + }, + validSdks: VALID_SDKS, + manifestOptions: { + wrapDefault: true, + collapseDefault: false, + hideTitleDefault: false, + }, }) -} - -const readTypedocsMarkdown = (config: BuildConfig) => async (paths: string[]) => { - const readFile = readMarkdownFile(config) - - return Promise.all( - paths.map(async (filePath) => { - const typedocPath = path.join(config.typedocRelativePath, filePath) - - const [error, content] = await readFile(typedocPath) - - if (error) { - throw new Error(errorMessages['typedoc-read-error'](typedocPath), { cause: error }) - } - - let node: Node | null = null - - const vfile = await remark() - .use(() => (tree) => { - node = tree - }) - .process({ - path: typedocPath, - value: content, - }) - - if (node === null) { - throw new Error(errorMessages['typedoc-parse-error'](typedocPath)) - } - - return { - path: `${removeMdxSuffix(filePath)}.mdx`, - content, - vfile, - node: node as Node, - } - }), - ) -} - -const markdownProcessor = remark().use(remarkFrontmatter).use(remarkMdx).freeze() - -type VFile = Awaited> - -const removeMdxSuffix = (filePath: string) => { - if (filePath.endsWith('.mdx')) { - return filePath.slice(0, -4) - } - return filePath -} - -type BlankTree }> = Array> - -const traverseTree = async < - Tree extends { items: BlankTree }, - InItem extends Extract, - InGroup extends Extract }>, - OutItem extends { href: string }, - OutGroup extends { items: BlankTree }, - OutTree extends BlankTree, ->( - tree: Tree, - itemCallback: (item: InItem, tree: Tree) => Promise = async (item) => item, - groupCallback: (group: InGroup, tree: Tree) => Promise = async (group) => group, - errorCallback?: (item: InItem | InGroup, error: Error) => void | Promise, -): Promise => { - const result = await Promise.all( - tree.items.map(async (group) => { - return await Promise.all( - group.map(async (item) => { - try { - if ('href' in item) { - return await itemCallback(item, tree) - } - - if ('items' in item && Array.isArray(item.items)) { - const newGroup = await groupCallback(item, tree) - - if (newGroup === null) return null - - // @ts-expect-error - OutGroup should always contain "items" property, so this is safe - const newItems = (await traverseTree(newGroup, itemCallback, groupCallback, errorCallback)).map((group) => - group.filter((item): item is NonNullable => item !== null), - ) - - return { - ...newGroup, - items: newItems, - } - } - - return item as OutItem - } catch (error) { - if (error instanceof Error && errorCallback !== undefined) { - errorCallback(item, error) - } else { - throw error - } - } - }), - ) - }), - ) - - return result.map((group) => - group.filter((item): item is NonNullable => item !== null), - ) as unknown as OutTree -} - -function flattenTree< - Tree extends BlankTree, - InItem extends Extract, - InGroup extends Extract }>, ->(tree: Tree): InItem[] { - const result: InItem[] = [] - - for (const group of tree) { - for (const itemOrGroup of group) { - if ('href' in itemOrGroup) { - // It's an item - result.push(itemOrGroup) - } else if ('items' in itemOrGroup && Array.isArray(itemOrGroup.items)) { - // It's a group with its own sub-tree, flatten it - result.push(...flattenTree(itemOrGroup.items)) - } - } - } - - return result -} - -const extractComponentPropValueFromNode = ( - config: BuildConfig, - node: Node, - vfile: VFile | undefined, - componentName: string, - propName: string, - required = true, - filePath: string, -): string | undefined => { - // Check if it's an MDX component - if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') { - return undefined - } - // Check if it's the correct component - if (!('name' in node)) return undefined - if (node.name !== componentName) return undefined - - // Check for attributes - if (!('attributes' in node)) { - if (vfile) { - safeMessage(config, vfile, filePath, 'component-no-props', [componentName], node.position) - } - return undefined - } - - if (!Array.isArray(node.attributes)) { - if (vfile) { - safeMessage(config, vfile, filePath, 'component-attributes-not-array', [componentName], node.position) - } - return undefined - } - - // Find the requested prop - const propAttribute = node.attributes.find((attribute) => attribute.name === propName) - - if (propAttribute === undefined) { - if (required === true && vfile) { - safeMessage(config, vfile, filePath, 'component-missing-attribute', [componentName, propName], node.position) - } - return undefined - } - - const value = propAttribute.value - - if (value === undefined) { - if (required === true && vfile) { - safeMessage(config, vfile, filePath, 'component-attribute-no-value', [componentName, propName], node.position) - } - return undefined - } - - // Handle both string values and object values (like JSX expressions) - if (typeof value === 'string') { - return value - } else if (typeof value === 'object' && 'value' in value) { - return value.value - } + const output = await build(config) - if (vfile) { - safeMessage( - config, - vfile, - filePath, - 'component-attribute-unsupported-type', - [componentName, propName], - node.position, - ) + if (output !== '') { + console.info(output) + process.exit(1) } - return undefined } -const extractSDKsFromIfProp = - (config: BuildConfig) => (node: Node, vfile: VFile | undefined, sdkProp: string, filePath: string) => { - const isValidItem = isValidSdk(config) - const isValidItems = isValidSdks(config) - - if (sdkProp.includes('", "') || sdkProp.includes("', '") || sdkProp.includes('["') || sdkProp.includes('"]')) { - const sdks = JSON.parse(sdkProp.replaceAll("'", '"')) as string[] - if (isValidItems(sdks)) { - return sdks - } else { - const invalidSDKs = sdks.filter((sdk) => !isValidItem(sdk)) - if (vfile) { - safeMessage(config, vfile, filePath, 'invalid-sdks-in-if', [invalidSDKs], node.position) - } - } - } else { - if (isValidItem(sdkProp)) { - return [sdkProp] - } else { - if (vfile) { - safeMessage(config, vfile, filePath, 'invalid-sdk-in-if', [sdkProp], node.position) - } - } - } - } - -const parseInMarkdownFile = - (config: BuildConfig) => - async ( - href: string, - partials: { path: string; content: string; node: Node }[], - typedocs: { path: string; content: string; node: Node }[], - inManifest: boolean, - ) => { - const readFile = readMarkdownFile(config) - const [error, fileContent] = await readFile(`${href}.mdx`.replace('/docs/', '')) - - if (error !== null) { - throw new Error(errorMessages['markdown-read-error'](href), { - cause: error, - }) - } - - type Frontmatter = { - title: string - description?: string - sdk?: SDK[] - } - - let frontmatter: Frontmatter | undefined = undefined - - const slugify = slugifyWithCounter() - const headingsHashs: Array = [] - const filePath = `${href}.mdx` - - const vfile = await markdownProcessor() - .use(() => (tree, vfile) => { - if (inManifest === false) { - safeMessage(config, vfile, filePath, 'doc-not-in-manifest', []) - } - - if (href !== encodeURI(href)) { - safeFail(config, vfile, filePath, 'invalid-href-encoding', [href]) - } - }) - .use(() => (tree, vfile) => { - mdastVisit( - tree, - (node) => node.type === 'yaml' && 'value' in node, - (node) => { - if (!('value' in node)) return - if (typeof node.value !== 'string') return - - const frontmatterYaml: Record<'title' | 'description' | 'sdk', string | undefined> = yaml.parse(node.value) - - const frontmatterSDKs = frontmatterYaml.sdk?.split(', ') - - if (frontmatterSDKs !== undefined && isValidSdks(config)(frontmatterSDKs) === false) { - const invalidSDKs = frontmatterSDKs.filter((sdk) => isValidSdk(config)(sdk) === false) - safeFail( - config, - vfile, - filePath, - 'invalid-sdk-in-frontmatter', - [invalidSDKs, config.validSdks as SDK[]], - node.position, - ) - return - } - - if (frontmatterYaml.title === undefined) { - safeFail(config, vfile, filePath, 'frontmatter-missing-title', [], node.position) - return - } - - if (frontmatterYaml.description === undefined) { - safeMessage(config, vfile, filePath, 'frontmatter-missing-description', [], node.position) - } - - frontmatter = { - title: frontmatterYaml.title, - description: frontmatterYaml.description, - sdk: frontmatterSDKs, - } - }, - ) - - if (frontmatter === undefined) { - safeFail(config, vfile, filePath, 'frontmatter-parse-failed', [href]) - return - } - }) - // Validate the - .use(() => (tree, vfile) => { - return mdastVisit(tree, (node) => { - const partialSrc = extractComponentPropValueFromNode(config, node, vfile, 'Include', 'src', true, filePath) - - if (partialSrc === undefined) return - - if (partialSrc.startsWith('_partials/') === false) { - safeMessage(config, vfile, filePath, 'include-src-not-partials', [], node.position) - return - } - - const partial = partials.find( - (partial) => `_partials/${partial.path}` === `${removeMdxSuffix(partialSrc)}.mdx`, - ) - - if (partial === undefined) { - safeMessage(config, vfile, filePath, 'partial-not-found', [removeMdxSuffix(partialSrc)], node.position) - return - } - - return - }) - }) - // Validate the - .use(() => (tree, vfile) => { - return mdastVisit(tree, (node) => { - const typedocSrc = extractComponentPropValueFromNode(config, node, vfile, 'Typedoc', 'src', true, filePath) - - if (typedocSrc === undefined) return - - const typedocFolderExists = existsSync(config.typedocPath) - - if (typedocFolderExists === false) { - throw new Error(errorMessages['typedoc-folder-not-found'](config.typedocPath)) - } - - const typedoc = typedocs.find((typedoc) => typedoc.path === `${removeMdxSuffix(typedocSrc)}.mdx`) - - if (typedoc === undefined) { - safeMessage( - config, - vfile, - filePath, - 'typedoc-not-found', - [`${removeMdxSuffix(typedocSrc)}.mdx`], - node.position, - ) - return - } - - return - }) - }) - .process({ - path: `${href.substring(1)}.mdx`, - value: fileContent, - }) - - // This needs to be done separately as some further validation expects the partials to not be embedded - // but we need to embed it to get all the headings to check - await markdownProcessor() - // Embed the partial - .use(() => (tree, vfile) => { - return mdastMap(tree, (node) => { - const partialSrc = extractComponentPropValueFromNode(config, node, vfile, 'Include', 'src', true, filePath) - - if (partialSrc === undefined) return node - - if (partialSrc.startsWith('_partials/') === false) { - safeMessage(config, vfile, filePath, 'include-src-not-partials', [], node.position) - return node - } - - const partial = partials.find( - (partial) => `_partials/${partial.path}` === `${removeMdxSuffix(partialSrc)}.mdx`, - ) - - if (partial === undefined) { - safeMessage(config, vfile, filePath, 'partial-not-found', [removeMdxSuffix(partialSrc)], node.position) - return node - } - - return Object.assign(node, partial.node) - }) - }) - // Embed the typedoc - .use(() => (tree, vfile) => { - return mdastMap(tree, (node) => { - const typedocSrc = extractComponentPropValueFromNode(config, node, vfile, 'Typedoc', 'src', true, filePath) - - if (typedocSrc === undefined) return node - - const typedoc = typedocs.find((typedoc) => typedoc.path === `${removeMdxSuffix(typedocSrc)}.mdx`) - - if (typedoc === undefined) { - safeMessage( - config, - vfile, - filePath, - 'typedoc-not-found', - [`${removeMdxSuffix(typedocSrc)}.mdx`], - node.position, - ) - return node - } - - return Object.assign(node, typedoc.node) - }) - }) - // extract out the headings to check hashes in links - .use(() => (tree) => { - mdastVisit( - tree, - (node) => node.type === 'heading', - (node) => { - // @ts-expect-error - If the heading has a id in it, this will pick it up - // eg # test {{ id: 'my-heading' }} - // This is for remapping the hash to the custom id - const id = node?.children - ?.find( - (child: unknown) => - typeof child === 'object' && child !== null && 'type' in child && child?.type === 'mdxTextExpression', - ) - ?.data?.estree?.body?.find( - (child: unknown) => - typeof child === 'object' && - child !== null && - 'type' in child && - child?.type === 'ExpressionStatement', - ) - ?.expression?.properties?.find( - (prop: unknown) => - typeof prop === 'object' && - prop !== null && - 'key' in prop && - typeof prop.key === 'object' && - prop.key !== null && - 'name' in prop.key && - prop.key.name === 'id', - )?.value?.value as string | undefined - - if (id !== undefined) { - headingsHashs.push(id) - } else { - const slug = slugify(toString(node).trim()) - headingsHashs.push(slug) - } - }, - ) - }) - .process({ - path: `${href.substring(1)}.mdx`, - value: fileContent, - }) - - if (frontmatter === undefined) { - throw new Error(errorMessages['frontmatter-parse-failed'](href)) - } - - return { - href, - sdk: (frontmatter as Frontmatter).sdk, - vfile, - headingsHashs, - frontmatter: frontmatter as Frontmatter, - } - } - -export const build = async (config: BuildConfig) => { +export async function build(config: BuildConfig) { // Apply currying to create functions pre-configured with config const getManifest = readManifest(config) const getDocsFolder = readDocsFolder(config) @@ -897,65 +112,63 @@ export const build = async (config: BuildConfig) => { const parseMarkdownFile = parseInMarkdownFile(config) const userManifest = await getManifest() - console.info('✔️ Read Manifest') + console.info('✓ Read Manifest') const docsFiles = await getDocsFolder() - console.info('✔️ Read Docs Folder') + console.info('✓ Read Docs Folder') const partials = await getPartialsMarkdown((await getPartialsFolder()).map((item) => item.path)) - console.info(`✔️ Read ${partials.length} Partials`) + console.info(`✓ Loaded in ${partials.length} partials`) const typedocs = await getTypedocsMarkdown((await getTypedocsFolder()).map((item) => item.path)) - console.info(`✔️ Read ${typedocs.length} Typedocs`) + console.info(`✓ Read ${typedocs.length} Typedocs`) - const docsMap = new Map>>() + const docsMap: DocsMap = new Map() const docsInManifest = new Set() // Grab all the docs links in the manifest await traverseTree({ items: userManifest }, async (item) => { - if (!item.href?.startsWith('/docs/')) return item + if (!item.href?.startsWith(config.baseDocsLink)) return item if (item.target !== undefined) return item - const ignore = config.ignorePaths.some((ignoreItem) => item.href.startsWith(ignoreItem)) + const ignore = config.ignoredLink(item.href) if (ignore === true) return item docsInManifest.add(item.href) return item }) - console.info('✔️ Parsed in Manifest') + console.info('✓ Parsed in Manifest') // Read in all the docs const docsArray = await Promise.all( docsFiles.map(async (file) => { - const href = removeMdxSuffix(`/docs/${file.path}`) - + const href = removeMdxSuffix(`${config.baseDocsLink}${file.path}`) const inManifest = docsInManifest.has(href) - const markdownFile = await parseMarkdownFile(href, partials, typedocs, inManifest) + const markdownFile = await parseMarkdownFile(href, partials, typedocs, inManifest, 'docs') docsMap.set(href, markdownFile) - return markdownFile }), ) - console.info(`✔️ Loaded in ${docsArray.length} docs`) + console.info(`✓ Loaded in ${docsArray.length} docs`) // Goes through and grabs the sdk scoping out of the manifest - const sdkScopedManifest = await traverseTree( + const sdkScopedManifestFirstPass = await traverseTree( { items: userManifest, sdk: undefined as undefined | SDK[] }, async (item, tree) => { - if (!item.href?.startsWith('/docs/')) return item + if (!item.href?.startsWith(config.baseDocsLink)) return item if (item.target !== undefined) return item - const ignore = config.ignorePaths.some((ignoreItem) => item.href.startsWith(ignoreItem)) + const ignore = config.ignoredLink(item.href) if (ignore === true) return item // even thou we are not processing them, we still need to keep them const doc = docsMap.get(item.href) if (doc === undefined) { const filePath = `${item.href}.mdx` - if (!shouldIgnoreWarning(config, filePath, 'doc-not-found')) { + if (!shouldIgnoreWarning(config, filePath, 'docs', 'doc-not-found')) { throw new Error(errorMessages['doc-not-found'](item.title, item.href)) } return item @@ -973,7 +186,7 @@ export const build = async (config: BuildConfig) => { if (docSDK !== undefined && parentSDK !== undefined) { if (docSDK.every((sdk) => parentSDK?.includes(sdk)) === false) { const filePath = `${item.href}.mdx` - if (!shouldIgnoreWarning(config, filePath, 'doc-sdk-filtered-by-parent')) { + if (!shouldIgnoreWarning(config, filePath, 'docs', 'doc-sdk-filtered-by-parent')) { throw new Error(errorMessages['doc-sdk-filtered-by-parent'](item.title, docSDK, parentSDK)) } } @@ -1000,15 +213,6 @@ export const build = async (config: BuildConfig) => { // This is the sdk of the parent group const parentSDK = tree.sdk - if (groupSDK !== undefined && parentSDK !== undefined) { - if (groupSDK.every((sdk) => parentSDK?.includes(sdk)) === false) { - const filePath = `/docs/groups/${details.title}.mdx` - if (!shouldIgnoreWarning(config, filePath, 'group-sdk-filtered-by-parent')) { - throw new Error(errorMessages['group-sdk-filtered-by-parent'](details.title, groupSDK, parentSDK)) - } - } - } - // If there are no children items, then the we either use the group we are looking at sdks if its defined, or its parent group if (groupsItemsCombinedSDKs.length === 0) { return { ...details, sdk: groupSDK ?? parentSDK, items } as ManifestGroup @@ -1034,182 +238,160 @@ export const build = async (config: BuildConfig) => { throw error }, ) - console.info('✔️ Applied manifest sdk scoping') - const flatSDKScopedManifest = flattenTree(sdkScopedManifest) - - const partialsVFiles = await Promise.all( - partials.map(async (partial) => { - const partialPath = `docs/_partials/${partial.path}` - return await markdownProcessor() - // validate links in partials to docs are valid - .use(() => (tree, vfile) => { - return mdastVisit(tree, (node) => { - if (node.type !== 'link') return - if (!('url' in node)) return - if (typeof node.url !== 'string') return - if (!node.url.startsWith('/docs/')) return - if (!('children' in node)) return - - const [url, hash] = removeMdxSuffix(node.url).split('#') - - const ignore = config.ignorePaths.some((ignoreItem) => url.startsWith(ignoreItem)) - if (ignore === true) return - - const doc = docsMap.get(url) - - if (doc === undefined) { - safeMessage(config, vfile, partialPath, 'link-doc-not-found', [url], node.position) - return - } - - if (hash !== undefined) { - const hasHash = doc.headingsHashs.includes(hash) - - if (hasHash === false) { - safeMessage(config, vfile, partialPath, 'link-hash-not-found', [hash, url], node.position) - } - } - }) - }) - .process(partial.vfile) - }), - ) - console.info(`✔️ Validated all partials`) + const sdkScopedManifest = await traverseTreeItemsFirst( + { items: sdkScopedManifestFirstPass, sdk: undefined as undefined | SDK[] }, + async (item, tree) => item, + async ({ items, ...details }, tree) => { + // This takes all the children items, grabs the sdks out of them, and combines that in to a list + const groupsItemsCombinedSDKs = (() => { + const sdks = items?.flatMap((item) => item.flatMap((item) => item.sdk)) - const typedocVFiles = await Promise.all( - typedocs.map(async (typedoc) => { - const filePath = path.join(config.typedocRelativePath, typedoc.path) + if (sdks === undefined) return [] - return await remark() - // validate links in partials to docs are valid - .use(() => (tree, vfile) => { - return mdastVisit(tree, (node) => { - if (node.type !== 'link') return - if (!('url' in node)) return - if (typeof node.url !== 'string') return - if (!node.url.startsWith('/docs/')) return - if (!('children' in node)) return + const uniqueSDKs = Array.from(new Set(sdks)).filter((sdk): sdk is SDK => sdk !== undefined) + return uniqueSDKs + })() - const [url, hash] = removeMdxSuffix(node.url).split('#') + // This is the sdk of the group + const groupSDK = details.sdk - const ignore = config.ignorePaths.some((ignoreItem) => url.startsWith(ignoreItem)) - if (ignore === true) return + // This is the sdk of the parent group + const parentSDK = tree.sdk - const doc = docsMap.get(url) + // If there are no children items, then we either use the group we are looking at sdks if its defined, or its parent group + if (groupsItemsCombinedSDKs.length === 0) { + return { ...details, sdk: groupSDK ?? parentSDK, items } as ManifestGroup + } - if (doc === undefined) { - safeMessage(config, vfile, filePath, 'link-doc-not-found', [url], node.position) - return - } + if (groupSDK !== undefined && groupSDK.length > 0) { + return { + ...details, + sdk: groupSDK, + items, + } as ManifestGroup + } - if (hash !== undefined) { - const hasHash = doc.headingsHashs.includes(hash) + const combinedSDKs = Array.from(new Set([...(groupSDK ?? []), ...groupsItemsCombinedSDKs])) ?? [] - if (hasHash === false) { - safeMessage(config, vfile, filePath, 'link-hash-not-found', [hash, url], node.position) - } - } - }) - }) - .process(typedoc.vfile) - }), + return { + ...details, + // If there are children items, then we combine the sdks of the group and the children items sdks + sdk: combinedSDKs, + items, + } as ManifestGroup + }, + (item, error) => { + console.error('[DEBUG] Error processing item:', item.title) + console.error(error) + throw error + }, ) - console.info(`✔️ Validated all typedocs`) + console.info('✓ Applied manifest sdk scoping') - const coreVFiles = await Promise.all( - docsArray.map(async (doc) => { - const filePath = `${doc.href}.mdx` - const vfile = await markdownProcessor() - // Validate links between docs are valid - .use(() => (tree: Node, vfile: VFile) => { - return mdastVisit(tree, (node) => { - if (node.type !== 'link') return - if (!('url' in node)) return - if (typeof node.url !== 'string') return - if (!node.url.startsWith('/docs/') && !node.url.startsWith('#')) return - if (!('children' in node)) return - - let [url, hash] = removeMdxSuffix(node.url).split('#') - - if (url === '') { - // If the link is just a hash, then we need to link to the same doc - url = doc.href - } - - const ignore = config.ignorePaths.some((ignoreItem) => url.startsWith(ignoreItem)) - if (ignore === true) return - - const linkedDoc = docsMap.get(url) - - if (linkedDoc === undefined) { - safeMessage(config, vfile, filePath, 'link-doc-not-found', [url], node.position) - return - } - - if (hash !== undefined) { - const hasHash = linkedDoc.headingsHashs.includes(hash) - - if (hasHash === false) { - safeMessage(config, vfile, filePath, 'link-hash-not-found', [hash, url], node.position) - } - } + const flatSDKScopedManifest = flattenTree(sdkScopedManifest) + + const validatedPartials = await Promise.all( + partials.map(async (partial) => { + const partialPath = `${config.partialsRelativePath}/${partial.path}` + + try { + let node: Node | null = null + + const vfile = await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(validateLinks(config, docsMap, partialPath, 'partials')) + .use(() => (tree, vfile) => { + node = tree }) - }) - // Validate the components - .use(() => (tree, vfile) => { - mdastVisit(tree, (node) => { - const sdk = extractComponentPropValueFromNode(config, node, vfile, 'If', 'sdk', false, filePath) + .process(partial.vfile) - if (sdk === undefined) return + if (node === null) { + throw new Error(errorMessages['partial-parse-error'](partial.path)) + } - const sdksFilter = extractSDKsFromIfProp(config)(node, vfile, sdk, filePath) + return { + ...partial, + node: node as Node, + vfile, + } + } catch (error) { + console.error(`✗ Error validating partial: ${partial.path}`) + throw error + } + }), + ) + console.info(`✓ Validated all partials`) - if (sdksFilter === undefined) return + const validatedTypedocs = await Promise.all( + typedocs.map(async (typedoc) => { + const filePath = path.join(config.typedocRelativePath, typedoc.path) - const manifestItems = flatSDKScopedManifest.filter((item) => item.href === doc.href) + try { + let node: Node | null = null - const availableSDKs = manifestItems.flatMap((item) => item.sdk).filter(Boolean) + const vfile = await remark() + .use(remarkMdx) + .use(validateLinks(config, docsMap, filePath, 'typedoc')) + .use(() => (tree, vfile) => { + node = tree + }) + .process(typedoc.vfile) - // The doc doesn't exist in the manifest so we are skipping it - if (manifestItems.length === 0) return + if (node === null) { + throw new Error(errorMessages['typedoc-parse-error'](typedoc.path)) + } - sdksFilter.forEach((sdk) => { - ;(() => { - if (doc.sdk === undefined) return + return { + ...typedoc, + vfile, + node: node as Node, + } + } catch (error) { + try { + let node: Node | null = null + + const vfile = await remark() + .use(validateLinks(config, docsMap, filePath, 'typedoc')) + .use(() => (tree, vfile) => { + node = tree + }) + .process(typedoc.vfile) - const available = doc.sdk.includes(sdk) + if (node === null) { + throw new Error(errorMessages['typedoc-parse-error'](typedoc.path)) + } - if (available === false) { - safeFail( - config, - vfile, - filePath, - 'if-component-sdk-not-in-frontmatter', - [sdk, doc.sdk], - node.position, - ) - } - })() - ;(() => { - // The doc is generic so we are skipping it - if (availableSDKs.length === 0) return + return { + ...typedoc, + vfile, + node: node as Node, + } + } catch (error) { + console.error(error) + throw new Error(errorMessages['typedoc-parse-error'](typedoc.path)) + } + } + }), + ) + console.info(`✓ Validated all typedocs`) - const available = availableSDKs.includes(sdk) + const coreVFiles = await Promise.all( + docsArray.map(async (doc) => { + const filePath = `${doc.href}.mdx` - if (available === false) { - safeFail(config, vfile, filePath, 'if-component-sdk-not-in-manifest', [sdk, doc.href], node.position) - } - })() - }) - }) - }) + const vfile = await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(validateLinks(config, docsMap, filePath, 'docs', doc)) + .use(validateIfComponents(config, filePath, doc, flatSDKScopedManifest)) .process(doc.vfile) - const distFilePath = `${doc.href.replace('/docs/', '')}.mdx` + const distFilePath = `${doc.href.replace(config.baseDocsLink, '')}.mdx` if (isValidSdk(config)(distFilePath.split('/')[0])) { - if (!shouldIgnoreWarning(config, filePath, 'sdk-path-conflict')) { + if (!shouldIgnoreWarning(config, filePath, 'docs', 'sdk-path-conflict')) { throw new Error(errorMessages['sdk-path-conflict'](doc.href, distFilePath)) } } @@ -1218,112 +400,94 @@ export const build = async (config: BuildConfig) => { }), ) - console.info(`✔️ Validated all docs`) + console.info(`✓ Validated all core docs`) + + const sdkSpecificVFiles = await Promise.all( + config.validSdks.map(async (targetSdk) => { + const vFiles = await Promise.all( + docsArray.map(async (doc) => { + if (doc.sdk === undefined) return null // skip core docs + if (doc.sdk.includes(targetSdk) === false) return null // skip docs that are not for the target sdk + + const filePath = `${doc.href}.mdx` + const vfile = await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(filterOtherSDKsContentOut(config, filePath, targetSdk)) + .use(validateUniqueHeadings(config, filePath, 'docs')) + .process({ + ...doc.vfile, + messages: [], // reset the messages, otherwise they will be duplicated + }) - return reporter([...coreVFiles, ...partialsVFiles, ...typedocVFiles], { quiet: true }) -} + return vfile + }), + ) -type BuildConfigOptions = { - basePath: string - validSdks: readonly SDK[] - docsPath: string - manifestPath: string - partialsPath: string - typedocPath: string - ignorePaths: string[] - ignoreWarnings?: Record - manifestOptions: { - wrapDefault: boolean - collapseDefault: boolean - hideTitleDefault: boolean - } -} + return vFiles + }), + ) -type BuildConfig = ReturnType + const docsWithOnlyIfComponents = docsArray.filter((doc) => doc.sdk === undefined && documentHasIfComponents(doc.node)) + const extractSDKsFromIfComponent = extractSDKsFromIfProp(config) -// Takes the basePath and resolves the relative paths to be absolute paths -export function createConfig(config: BuildConfigOptions) { - const resolve = (relativePath: string) => { - return path.isAbsolute(relativePath) ? relativePath : path.join(config.basePath, relativePath) - } + for (const doc of docsWithOnlyIfComponents) { + const filePath = `${doc.href}.mdx` - return { - basePath: config.basePath, - validSdks: config.validSdks, + // Extract all SDK values from all components + const availableSDKs = new Set() - docsRelativePath: config.docsPath, - docsPath: resolve(config.docsPath), + mdastVisit(doc.node, (node) => { + const sdkProp = extractComponentPropValueFromNode(config, node, undefined, 'If', 'sdk', true, 'docs', filePath) - manifestRelativePath: config.manifestPath, - manifestFilePath: resolve(config.manifestPath), + if (sdkProp === undefined) return - partialsRelativePath: config.partialsPath, - partialsPath: resolve(config.partialsPath), + const sdks = extractSDKsFromIfComponent(node, undefined, sdkProp, 'docs', filePath) - typedocRelativePath: config.typedocPath, - typedocPath: resolve(config.typedocPath), + if (sdks === undefined) return - ignorePaths: config.ignorePaths, - ignoreWarnings: config.ignoreWarnings || {}, - manifestOptions: config.manifestOptions ?? { - wrapDefault: true, - collapseDefault: false, - hideTitleDefault: false, - }, - } -} + sdks.forEach((sdk) => availableSDKs.add(sdk)) + }) -const main = async () => { - const config = createConfig({ - basePath: __dirname, - docsPath: '../docs', - manifestPath: '../docs/manifest.json', - partialsPath: '../docs/_partials', - typedocPath: '../clerk-typedoc', - ignorePaths: [ - '/docs/core-1', - '/pricing', - '/docs/reference/backend-api', - '/docs/reference/frontend-api', - '/support', - '/discord', - '/contact', - '/contact/sales', - '/contact/support', - '/blog', - '/changelog/2024-04-19', - '/docs/_partials', - ], - ignoreWarnings: { - '/docs/index.mdx': ['doc-not-in-manifest'], - '/docs/guides/overview.mdx': ['doc-not-in-manifest'], - '/docs/quickstarts/overview.mdx': ['doc-not-in-manifest'], - '/docs/references/overview.mdx': ['doc-not-in-manifest'], - '/docs/maintenance-mode.mdx': ['doc-not-in-manifest'], - '/docs/deployments/staging-alternatives.mdx': ['doc-not-in-manifest'], - '/docs/references/nextjs/usage-with-older-versions.mdx': ['doc-not-in-manifest'], - - // Typedoc warnings - '../clerk-typedoc/types/active-session-resource.mdx': ['link-hash-not-found'], - '../clerk-typedoc/types/pending-session-resource.mdx': ['link-hash-not-found'], - }, - validSdks: VALID_SDKS, - manifestOptions: { - wrapDefault: true, - collapseDefault: false, - hideTitleDefault: false, - }, - }) + for (const sdk of availableSDKs) { + await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(() => (inputTree) => { + return mdastFilter(inputTree, (node) => { + const sdkProp = extractComponentPropValueFromNode( + config, + node, + undefined, + 'If', + 'sdk', + false, + 'docs', + filePath, + ) + if (!sdkProp) return true - const output = await build(config) + const ifSdks = extractSDKsFromIfComponent(node, undefined, sdkProp, 'docs', filePath) - if (output !== '') { - console.info(output) - process.exit(1) + if (!ifSdks) return true + + return ifSdks.includes(sdk) + }) + }) + .use(validateUniqueHeadings(config, filePath, 'docs')) + .process({ + path: filePath, + value: String(doc.vfile), + }) + } } -} -// Only invokes the main function if we run the script directly eg npm run build, bun run ./scripts/build-docs.ts -if (require.main === module) { - main() + const flatSdkSpecificVFiles = sdkSpecificVFiles + .flatMap((vFiles) => vFiles) + .filter((item): item is NonNullable => item !== null) + + const partialsVFiles = validatedPartials.map((partial) => partial.vfile) + const typedocVFiles = validatedTypedocs.map((typedoc) => typedoc.vfile) + + return reporter([...coreVFiles, ...partialsVFiles, ...typedocVFiles, ...flatSdkSpecificVFiles], { quiet: true }) } diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts new file mode 100644 index 0000000000..4cc134ceaf --- /dev/null +++ b/scripts/lib/config.ts @@ -0,0 +1,66 @@ +// For the test suite to work effectively we need to be able to +// configure the builds, this file defines the config object + +import path from 'node:path' +import type { SDK } from './schemas' + +type BuildConfigOptions = { + basePath: string + validSdks: readonly SDK[] + docsPath: string + baseDocsLink: string + manifestPath: string + partialsPath: string + typedocPath: string + ignoreLinks: string[] + ignoreWarnings?: { + docs: Record + partials: Record + typedoc: Record + } + manifestOptions: { + wrapDefault: boolean + collapseDefault: boolean + hideTitleDefault: boolean + } +} + +export type BuildConfig = ReturnType + +// Takes the basePath and resolves the relative paths to be absolute paths +export function createConfig(config: BuildConfigOptions) { + const resolve = (relativePath: string) => { + return path.isAbsolute(relativePath) ? relativePath : path.join(config.basePath, relativePath) + } + + return { + basePath: config.basePath, + baseDocsLink: config.baseDocsLink, + validSdks: config.validSdks, + + manifestRelativePath: config.manifestPath, + manifestFilePath: resolve(config.manifestPath), + + partialsRelativePath: config.partialsPath, + partialsPath: resolve(config.partialsPath), + + docsRelativePath: config.docsPath, + docsPath: resolve(config.docsPath), + + typedocRelativePath: config.typedocPath, + typedocPath: resolve(config.typedocPath), + + ignoredLink: (url: string) => config.ignoreLinks.some((ignoreItem) => url.startsWith(ignoreItem)), + ignoreWarnings: config.ignoreWarnings ?? { + docs: {}, + partials: {}, + typedoc: {}, + }, + + manifestOptions: config.manifestOptions ?? { + wrapDefault: true, + collapseDefault: false, + hideTitleDefault: false, + }, + } +} diff --git a/scripts/lib/error-messages.ts b/scripts/lib/error-messages.ts new file mode 100644 index 0000000000..46637450af --- /dev/null +++ b/scripts/lib/error-messages.ts @@ -0,0 +1,137 @@ +// defining most of the error messages that may be thrown by the build script +// with some helper functions that check if the warning should be ignored + +import type { VFile } from 'vfile' +import type { ValidationError } from 'zod-validation-error' +import type { BuildConfig } from './config' +import type { Position } from 'unist' +import type { SDK } from './schemas' + +export const errorMessages = { + // Manifest errors + 'manifest-parse-error': (error: ValidationError | Error): string => `Failed to parse manifest: ${error}`, + + // Component errors + 'component-no-props': (componentName: string): string => `<${componentName} /> component has no props`, + 'component-attributes-not-array': (componentName: string): string => + `<${componentName} /> node attributes is not an array (this is a bug with the build script, please report)`, + 'component-missing-attribute': (componentName: string, propName: string): string => + `<${componentName} /> component has no "${propName}" attribute`, + 'component-attribute-no-value': (componentName: string, propName: string): string => + `<${componentName} /> attribute "${propName}" has no value (this is a bug with the build script, please report)`, + 'component-attribute-unsupported-type': (componentName: string, propName: string): string => + `<${componentName} /> attribute "${propName}" has an unsupported value type`, + + // SDK errors + 'invalid-sdks-in-if': (invalidSDKs: string[]): string => + `sdks "${invalidSDKs.join('", "')}" in are not valid SDKs`, + 'invalid-sdk-in-if': (sdk: string): string => `sdk "${sdk}" in is not a valid SDK`, + 'invalid-sdk-in-frontmatter': (invalidSDKs: string[], validSdks: SDK[]): string => + `Invalid SDK ${JSON.stringify(invalidSDKs)}, the valid SDKs are ${JSON.stringify(validSdks)}`, + 'if-component-sdk-not-in-frontmatter': (sdk: SDK, docSdk: SDK[]): string => + ` component is attempting to filter to sdk "${sdk}" but it is not available in the docs frontmatter ["${docSdk.join('", "')}"], if this is a mistake please remove it from the otherwise update the frontmatter to include "${sdk}"`, + 'if-component-sdk-not-in-manifest': (sdk: SDK, href: string): string => + ` component is attempting to filter to sdk "${sdk}" but it is not available in the manifest.json for ${href}, if this is a mistake please remove it from the otherwise update the manifest.json to include "${sdk}"`, + 'doc-sdk-filtered-by-parent': (title: string, docSDK: SDK[], parentSDK: SDK[]): string => + `Doc "${title}" is attempting to use ${JSON.stringify(docSDK)} But its being filtered down to ${JSON.stringify(parentSDK)} in the manifest.json`, + 'group-sdk-filtered-by-parent': (title: string, groupSDK: SDK[], parentSDK: SDK[]): string => + `Group "${title}" is attempting to use ${JSON.stringify(groupSDK)} But its being filtered down to ${JSON.stringify(parentSDK)} in the manifest.json`, + + // Document structure errors + 'doc-not-in-manifest': (): string => + 'This doc is not in the manifest.json, but will still be publicly accessible and other docs can link to it', + 'invalid-href-encoding': (href: string): string => + `Href "${href}" contains characters that will be encoded by the browser, please remove them`, + 'frontmatter-missing-title': (): string => 'Frontmatter must have a "title" property', + 'frontmatter-missing-description': (): string => 'Frontmatter should have a "description" property', + 'frontmatter-parse-failed': (href: string): string => `Frontmatter parsing failed for ${href}`, + 'doc-not-found': (title: string, href: string): string => + `Doc "${title}" in manifest.json not found in the docs folder at ${href}.mdx`, + 'doc-parse-failed': (href: string): string => `Doc "${href}" failed to parse`, + 'sdk-path-conflict': (href: string, path: string): string => + `Doc "${href}" is attempting to write out a doc to ${path} but the first part of the path is a valid SDK, this causes a file path conflict.`, + 'duplicate-heading-id': (href: string, id: string): string => + `Doc "${href}" contains a duplicate heading id "${id}", please ensure all heading ids are unique`, + + // Include component errors + 'include-src-not-partials': (): string => ` prop "src" must start with "_partials/"`, + 'partial-not-found': (src: string): string => `Partial /docs/${src}.mdx not found`, + 'partials-inside-partials': (): string => + 'Partials inside of partials is not yet supported (this is a bug with the build script, please report)', + + // Link validation errors + 'link-doc-not-found': (url: string): string => `Doc ${url} not found`, + 'link-hash-not-found': (hash: string, url: string): string => `Hash "${hash}" not found in ${url}`, + + // File reading errors + 'file-read-error': (filePath: string): string => `file ${filePath} doesn't exist`, + 'partial-read-error': (path: string): string => `Failed to read in ${path} from partials file`, + 'markdown-read-error': (href: string): string => `Attempting to read in ${href}.mdx failed`, + 'partial-parse-error': (path: string): string => `Failed to parse the content of ${path}`, + + // Typedoc errors + 'typedoc-folder-not-found': (path: string): string => + `Typedoc folder ${path} not found, run "npm run typedoc:download"`, + 'typedoc-read-error': (filePath: string): string => `Failed to read in ${filePath} from typedoc file`, + 'typedoc-parse-error': (filePath: string): string => `Failed to parse ${filePath} from typedoc file`, + 'typedoc-not-found': (filePath: string): string => `Typedoc ${filePath} not found`, +} as const + +type WarningCode = keyof typeof errorMessages +export type WarningsSection = keyof BuildConfig['ignoreWarnings'] + +// Helper function to check if a warning should be ignored +export const shouldIgnoreWarning = ( + config: BuildConfig, + filePath: string, + section: WarningsSection, + warningCode: WarningCode, +): boolean => { + const replacements = { + docs: config.baseDocsLink, + partials: config.partialsRelativePath + '/', + typedoc: config.typedocRelativePath + '/', + } + + const relativeFilePath = filePath.replace(replacements[section], '') + + const ignoreList = config.ignoreWarnings[section][relativeFilePath] + + if (!ignoreList) { + return false + } + + return ignoreList.includes(warningCode) +} + +export const safeMessage = >( + config: BuildConfig, + vfile: VFile, + filePath: string, + section: WarningsSection, + warningCode: TCode, + args: TArgs, + position?: Position, +) => { + if (!shouldIgnoreWarning(config, filePath, section, warningCode)) { + // @ts-expect-error - TypeScript has trouble with spreading args into the function + const message = errorMessages[warningCode](...args) + vfile.message(message, position) + } +} + +export const safeFail = >( + config: BuildConfig, + vfile: VFile, + filePath: string, + section: WarningsSection, + warningCode: TCode, + args: TArgs, + position?: Position, +) => { + if (!shouldIgnoreWarning(config, filePath, section, warningCode)) { + // @ts-expect-error - TypeScript has trouble with spreading args into the function + const message = errorMessages[warningCode](...args) + vfile.fail(message, position) + } +} diff --git a/scripts/lib/io.ts b/scripts/lib/io.ts new file mode 100644 index 0000000000..d64364adbd --- /dev/null +++ b/scripts/lib/io.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import readdirp from 'readdirp' +import type { BuildConfig } from './config' +import { errorMessages } from './error-messages' + +// Read in a markdown file from the docs folder +export const readMarkdownFile = (config: BuildConfig) => async (docPath: string) => { + const filePath = path.join(config.docsPath, docPath) + + try { + const fileContent = await fs.readFile(filePath, { encoding: 'utf-8' }) + return [null, fileContent] as const + } catch (error) { + return [new Error(errorMessages['file-read-error'](filePath), { cause: error }), null] as const + } +} + +// list all the docs in the docs folder +export const readDocsFolder = (config: BuildConfig) => async () => { + return readdirp.promise(config.docsPath, { + type: 'files', + fileFilter: (entry) => + // Partials are inside the docs folder, so we need to exclude them + `${config.docsRelativePath}/${entry.path}`.startsWith(config.partialsRelativePath) === false && + entry.path.endsWith('.mdx'), + }) +} + +// not exactly io, but used to parse the json using a result patten +export const parseJSON = (json: string) => { + try { + const output = JSON.parse(json) + + return [null, output as unknown] as const + } catch (error) { + return [new Error(`Failed to parse JSON`, { cause: error }), null] as const + } +} diff --git a/scripts/lib/manifest.ts b/scripts/lib/manifest.ts new file mode 100644 index 0000000000..fa2d3f27f3 --- /dev/null +++ b/scripts/lib/manifest.ts @@ -0,0 +1,224 @@ +// parsing and traversing the manifest.json file +// main thing here is that the tree uses double arrays + +import fs from 'node:fs/promises' +import { z } from 'zod' +import { fromError } from 'zod-validation-error' +import type { BuildConfig } from './config' +import { errorMessages } from './error-messages' +import { parseJSON } from './io' +import { icon, sdk, tag, type Icon, type SDK, type Tag } from './schemas' + +// read in the manifest + +export const readManifest = (config: BuildConfig) => async (): Promise => { + const { manifestSchema } = createManifestSchema(config) + const unsafe_manifest = await fs.readFile(config.manifestFilePath, { encoding: 'utf-8' }) + + const [error, json] = parseJSON(unsafe_manifest) + + if (error) { + throw new Error(errorMessages['manifest-parse-error'](error)) + } + + const manifest = await z.object({ navigation: manifestSchema }).safeParseAsync(json) + + if (manifest.success === true) { + return manifest.data.navigation + } + + throw new Error(errorMessages['manifest-parse-error'](fromError(manifest.error))) +} + +// verify the manifest is valid + +export type ManifestItem = { + title: string + href: string + tag?: Tag + wrap?: boolean + icon?: Icon + target?: '_blank' + sdk?: SDK[] +} + +export type ManifestGroup = { + title: string + items: Manifest + collapse?: boolean + tag?: Tag + wrap?: boolean + icon?: Icon + hideTitle?: boolean + sdk?: SDK[] +} + +type Manifest = (ManifestItem | ManifestGroup)[][] + +// Create manifest schema based on config +const createManifestSchema = (config: BuildConfig) => { + const manifestItem: z.ZodType = z + .object({ + title: z.string(), + href: z.string(), + tag: tag.optional(), + wrap: z.boolean().default(config.manifestOptions.wrapDefault), + icon: icon.optional(), + target: z.enum(['_blank']).optional(), + sdk: z.array(sdk).optional(), + }) + .strict() + + const manifestGroup: z.ZodType = z + .object({ + title: z.string(), + items: z.lazy(() => manifestSchema), + collapse: z.boolean().default(config.manifestOptions.collapseDefault), + tag: tag.optional(), + wrap: z.boolean().default(config.manifestOptions.wrapDefault), + icon: icon.optional(), + hideTitle: z.boolean().default(config.manifestOptions.hideTitleDefault), + sdk: z.array(sdk).optional(), + }) + .strict() + + const manifestSchema: z.ZodType = z.array(z.array(z.union([manifestItem, manifestGroup]))) + + return { + manifestItem, + manifestGroup, + manifestSchema, + } +} + +// helper functions for traversing the manifest tree + +export type BlankTree }> = Array> + +export const traverseTree = async < + Tree extends { items: BlankTree }, + InItem extends Extract, + InGroup extends Extract }>, + OutItem extends { href: string }, + OutGroup extends { items: BlankTree }, + OutTree extends BlankTree, +>( + tree: Tree, + itemCallback: (item: InItem, tree: Tree) => Promise = async (item) => item, + groupCallback: (group: InGroup, tree: Tree) => Promise = async (group) => group, + errorCallback?: (item: InItem | InGroup, error: Error) => void | Promise, +): Promise => { + const result = await Promise.all( + tree.items.map(async (group) => { + return await Promise.all( + group.map(async (item) => { + try { + if ('href' in item) { + return await itemCallback(item, tree) + } + + if ('items' in item && Array.isArray(item.items)) { + const newGroup = await groupCallback(item, tree) + + if (newGroup === null) return null + + // @ts-expect-error - OutGroup should always contain "items" property, so this is safe + const newItems = (await traverseTree(newGroup, itemCallback, groupCallback, errorCallback)).map((group) => + group.filter((item): item is NonNullable => item !== null), + ) + + return { + ...newGroup, + items: newItems, + } + } + + return item as OutItem + } catch (error) { + if (error instanceof Error && errorCallback !== undefined) { + errorCallback(item, error) + } else { + throw error + } + } + }), + ) + }), + ) + + return result.map((group) => + group.filter((item): item is NonNullable => item !== null), + ) as unknown as OutTree +} + +export const traverseTreeItemsFirst = async < + Tree extends { items: BlankTree }, + InItem extends Extract, + InGroup extends Extract }>, + OutItem extends { href: string }, + OutGroup extends { items: BlankTree }, + OutTree extends BlankTree, +>( + tree: Tree, + itemCallback: (item: InItem, tree: Tree) => Promise = async (item) => item, + groupCallback: (group: InGroup, tree: Tree) => Promise = async (group) => group, + errorCallback?: (item: InItem | InGroup, error: Error) => void | Promise, +): Promise => { + const result = await Promise.all( + tree.items.map(async (group) => { + return await Promise.all( + group.map(async (item) => { + try { + if ('href' in item) { + return await itemCallback(item, tree) + } + + if ('items' in item && Array.isArray(item.items)) { + const newItems = (await traverseTreeItemsFirst(item, itemCallback, groupCallback, errorCallback)).map( + (group) => group.filter((item): item is NonNullable => item !== null), + ) + + const newGroup = await groupCallback({ ...item, items: newItems }, tree) + + return newGroup + } + + return item as OutItem + } catch (error) { + if (error instanceof Error && errorCallback !== undefined) { + errorCallback(item, error) + } else { + throw error + } + } + }), + ) + }), + ) + + return result.map((group) => + group.filter((item): item is NonNullable => item !== null), + ) as unknown as OutTree +} + +export function flattenTree< + Tree extends BlankTree, + InItem extends Extract, + InGroup extends Extract }>, +>(tree: Tree): InItem[] { + const result: InItem[] = [] + + for (const group of tree) { + for (const itemOrGroup of group) { + if ('href' in itemOrGroup) { + // It's an item + result.push(itemOrGroup) + } else if ('items' in itemOrGroup && Array.isArray(itemOrGroup.items)) { + // It's a group with its own sub-tree, flatten it + result.push(...flattenTree(itemOrGroup.items)) + } + } + } + + return result +} diff --git a/scripts/lib/markdown.ts b/scripts/lib/markdown.ts new file mode 100644 index 0000000000..69a0ad4a74 --- /dev/null +++ b/scripts/lib/markdown.ts @@ -0,0 +1,135 @@ +// responsible for reading in the markdown files and parsing them +// This is only for parsing in the main docs files, not the partials or typedocs +// - throws a warning if the doc is not in the manifest.json +// - throws a warning if the filename contains characters that will be encoded by the browser +// - extracts the frontmatter and validates it +// - title is required, will fail +// - description is required, will warn if missing +// - sdk is optional, but if present must be a valid sdk +// - validates (but does not embed) the partials and typedocs +// - extracts the headings and validates that they are unique + +import { slugifyWithCounter } from '@sindresorhus/slugify' +import { toString } from 'mdast-util-to-string' +import { remark } from 'remark' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdx from 'remark-mdx' +import { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import { type BuildConfig } from './config' +import { errorMessages, safeFail, safeMessage, type WarningsSection } from './error-messages' +import { readMarkdownFile } from './io' +import { checkPartials } from './plugins/checkPartials' +import { checkTypedoc } from './plugins/checkTypedoc' +import { extractFrontmatter, type Frontmatter } from './plugins/extractFrontmatter' +import { documentHasIfComponents } from './utils/documentHasIfComponents' +import { extractHeadingFromHeadingNode } from './utils/extractHeadingFromHeadingNode' + +export const parseInMarkdownFile = + (config: BuildConfig) => + async ( + href: string, + partials: { path: string; content: string; node: Node }[], + typedocs: { path: string; content: string; node: Node }[], + inManifest: boolean, + section: WarningsSection, + ) => { + const readFile = readMarkdownFile(config) + const [error, fileContent] = await readFile(`${href}.mdx`.replace(config.baseDocsLink, '')) + + if (error !== null) { + throw new Error(errorMessages['markdown-read-error'](href), { + cause: error, + }) + } + + let frontmatter: Frontmatter | undefined = undefined + + const slugify = slugifyWithCounter() + const headingsHashes = new Set() + const filePath = `${href}.mdx` + let node: Node | undefined = undefined + + const vfile = await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(() => (tree, vfile) => { + node = tree + + if (inManifest === false) { + safeMessage(config, vfile, filePath, section, 'doc-not-in-manifest', []) + } + + if (href !== encodeURI(href)) { + safeFail(config, vfile, filePath, section, 'invalid-href-encoding', [href]) + } + }) + .use( + extractFrontmatter(config, href, filePath, section, (fm) => { + frontmatter = fm + }), + ) + .use(checkPartials(config, partials, filePath, { reportWarnings: true, embed: false })) + .use(checkTypedoc(config, typedocs, filePath, { reportWarnings: true, embed: false })) + .process({ + path: `${href.substring(1)}.mdx`, + value: fileContent, + }) + + // This needs to be done separately as some further validation expects the partials to not be embedded + // but we need to embed it to get all the headings to check + await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(checkPartials(config, partials, filePath, { reportWarnings: true, embed: true })) + .use(checkTypedoc(config, typedocs, filePath, { reportWarnings: true, embed: true })) + // extract out the headings to check hashes in links + .use(() => (tree, vfile) => { + const documentContainsIfComponent = documentHasIfComponents(tree) + + mdastVisit( + tree, + (node) => node.type === 'heading', + (node) => { + const id = extractHeadingFromHeadingNode(node) + + if (id !== undefined) { + if (documentContainsIfComponent === false && headingsHashes.has(id)) { + safeFail(config, vfile, filePath, section, 'duplicate-heading-id', [href, id]) + } + + headingsHashes.add(id) + } else { + const slug = slugify(toString(node).trim()) + + if (documentContainsIfComponent === false && headingsHashes.has(slug)) { + safeFail(config, vfile, filePath, section, 'duplicate-heading-id', [href, slug]) + } + + headingsHashes.add(slug) + } + }, + ) + }) + .process({ + path: `${href.substring(1)}.mdx`, + value: fileContent, + }) + + if (node === undefined) { + throw new Error(errorMessages['doc-parse-failed'](href)) + } + + if (frontmatter === undefined) { + throw new Error(errorMessages['frontmatter-parse-failed'](href)) + } + + return { + href, + sdk: (frontmatter as Frontmatter).sdk, + vfile, + headingsHashes, + frontmatter: frontmatter as Frontmatter, + node: node as Node, + } + } diff --git a/scripts/lib/partials.ts b/scripts/lib/partials.ts new file mode 100644 index 0000000000..818ed757a3 --- /dev/null +++ b/scripts/lib/partials.ts @@ -0,0 +1,89 @@ +// responsible for reading in and parsing the partials markdown +// for validation see validators/checkPartials.ts +// for partials we currently do not allow them to embed other partials +// this also removes the .mdx suffix from the urls in the markdown + +import path from 'node:path' +import readdirp from 'readdirp' +import { remark } from 'remark' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdx from 'remark-mdx' +import type { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import reporter from 'vfile-reporter' +import type { BuildConfig } from './config' +import { errorMessages, safeFail } from './error-messages' +import { readMarkdownFile } from './io' + +export const readPartialsFolder = (config: BuildConfig) => async () => { + return readdirp.promise(config.partialsPath, { + type: 'files', + fileFilter: '*.mdx', + }) +} + +export const readPartial = (config: BuildConfig) => async (filePath: string) => { + const readFile = readMarkdownFile(config) + + const fullPath = path.join(config.docsRelativePath, config.partialsRelativePath, filePath) + + const [error, content] = await readFile(fullPath) + + if (error) { + throw new Error(errorMessages['partial-read-error'](fullPath), { cause: error }) + } + + let partialNode: Node | null = null + + try { + const partialContentVFile = await remark() + .use(remarkFrontmatter) + .use(remarkMdx) + .use(() => (tree) => { + partialNode = tree + }) + .use(() => (tree, vfile) => { + mdastVisit( + tree, + (node) => + (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && + 'name' in node && + node.name === 'Include', + (node) => { + safeFail(config, vfile, fullPath, 'partials', 'partials-inside-partials', [], node.position) + }, + ) + }) + .process({ + path: `docs/_partials/${filePath}`, + value: content, + }) + + const partialContentReport = reporter([partialContentVFile], { quiet: true }) + + if (partialContentReport !== '') { + console.error(partialContentReport) + process.exit(1) + } + + if (partialNode === null) { + throw new Error(errorMessages['partial-parse-error'](filePath)) + } + + return { + path: filePath, + content, + vfile: partialContentVFile, + node: partialNode as Node, + } + } catch (error) { + console.error(`✗ Error parsing partial: ${filePath}`) + throw error + } +} + +export const readPartialsMarkdown = (config: BuildConfig) => async (paths: string[]) => { + const read = readPartial(config) + + return Promise.all(paths.map(async (markdownPath) => read(markdownPath))) +} diff --git a/scripts/lib/plugins/checkPartials.ts b/scripts/lib/plugins/checkPartials.ts new file mode 100644 index 0000000000..a1f82140c0 --- /dev/null +++ b/scripts/lib/plugins/checkPartials.ts @@ -0,0 +1,74 @@ +// This validator manages the partials in the docs +// based on the options passed through it can +// - only report warnings if something ain't right +// - only embed the partials contents in to the markdown +// - both report warnings and embed the partials contents + +import type { BuildConfig } from '../config' +import type { Node } from 'unist' +import type { VFile } from 'vfile' +import { map as mdastMap } from 'unist-util-map' +import { extractComponentPropValueFromNode } from '../utils/extractComponentPropValueFromNode' +import { safeMessage } from '../error-messages' +import { removeMdxSuffix } from '../utils/removeMdxSuffix' + +export const checkPartials = + ( + config: BuildConfig, + partials: { + node: Node + path: string + }[], + filePath: string, + options: { + reportWarnings: boolean + embed: boolean + }, + ) => + () => + (tree: Node, vfile: VFile) => { + mdastMap(tree, (node) => { + const partialSrc = extractComponentPropValueFromNode( + config, + node, + vfile, + 'Include', + 'src', + true, + 'docs', + filePath, + ) + + if (partialSrc === undefined) return node + + if (partialSrc.startsWith('_partials/') === false) { + if (options.reportWarnings === true) { + safeMessage(config, vfile, filePath, 'docs', 'include-src-not-partials', [], node.position) + } + return node + } + + const partial = partials.find((partial) => `_partials/${partial.path}` === `${removeMdxSuffix(partialSrc)}.mdx`) + + if (partial === undefined) { + if (options.reportWarnings === true) { + safeMessage( + config, + vfile, + filePath, + 'docs', + 'partial-not-found', + [removeMdxSuffix(partialSrc)], + node.position, + ) + } + return node + } + + if (options.embed === true) { + return Object.assign(node, partial.node) + } + + return node + }) + } diff --git a/scripts/lib/plugins/checkTypedoc.ts b/scripts/lib/plugins/checkTypedoc.ts new file mode 100644 index 0000000000..86eff45fa3 --- /dev/null +++ b/scripts/lib/plugins/checkTypedoc.ts @@ -0,0 +1,69 @@ +// This validator manages the typedoc in the docs +// based on the options passed through it can +// - only report warnings if something ain't right +// - only embed the typedoc contents in to the markdown +// - both report warnings and embed the typedoc contents +// This validator will also ensure that the typedoc folder exists + +import type { BuildConfig } from '../config' +import type { Node } from 'unist' +import type { VFile } from 'vfile' +import { map as mdastMap } from 'unist-util-map' +import { extractComponentPropValueFromNode } from '../utils/extractComponentPropValueFromNode' +import { errorMessages, safeMessage } from '../error-messages' +import { removeMdxSuffix } from '../utils/removeMdxSuffix' +import { existsSync } from 'node:fs' + +export const checkTypedoc = + ( + config: BuildConfig, + typedocs: { path: string; node: Node }[], + filePath: string, + options: { reportWarnings: boolean; embed: boolean }, + ) => + () => + (tree: Node, vfile: VFile) => { + mdastMap(tree, (node) => { + const typedocSrc = extractComponentPropValueFromNode( + config, + node, + vfile, + 'Typedoc', + 'src', + true, + 'docs', + filePath, + ) + + if (typedocSrc === undefined) return node + + const typedocFolderExists = existsSync(config.typedocPath) + + if (typedocFolderExists === false && options.reportWarnings === true) { + throw new Error(errorMessages['typedoc-folder-not-found'](config.typedocPath)) + } + + const typedoc = typedocs.find(({ path }) => path === `${removeMdxSuffix(typedocSrc)}.mdx`) + + if (typedoc === undefined) { + if (options.reportWarnings === true) { + safeMessage( + config, + vfile, + filePath, + 'docs', + 'typedoc-not-found', + [`${removeMdxSuffix(typedocSrc)}.mdx`], + node.position, + ) + } + return node + } + + if (options.embed === true) { + return Object.assign(node, typedoc.node) + } + + return node + }) + } diff --git a/scripts/lib/plugins/extractFrontmatter.ts b/scripts/lib/plugins/extractFrontmatter.ts new file mode 100644 index 0000000000..bdbda330a6 --- /dev/null +++ b/scripts/lib/plugins/extractFrontmatter.ts @@ -0,0 +1,77 @@ +import type { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import type { VFile } from 'vfile' +import yaml from 'yaml' +import { type BuildConfig } from '../config' +import { safeFail, safeMessage, WarningsSection } from '../error-messages' +import { isValidSdk, isValidSdks, type SDK } from '../schemas' + +export type Frontmatter = { + title: string + description?: string + sdk?: SDK[] +} + +export const extractFrontmatter = + ( + config: BuildConfig, + href: string, + filePath: string, + section: WarningsSection, + callback: (frontmatter: Frontmatter) => void, + ) => + () => + (tree: Node, vfile: VFile) => { + const validateSDKs = isValidSdks(config) + + let frontmatter: Frontmatter | undefined = undefined + + mdastVisit( + tree, + (node) => node.type === 'yaml' && 'value' in node, + (node) => { + if (!('value' in node)) return + if (typeof node.value !== 'string') return + + const frontmatterYaml: Record<'title' | 'description' | 'sdk', string | undefined> = yaml.parse(node.value) + + const frontmatterSDKs = frontmatterYaml.sdk?.split(', ') + + if (frontmatterSDKs !== undefined && validateSDKs(frontmatterSDKs) === false) { + const invalidSDKs = frontmatterSDKs.filter((sdk) => isValidSdk(config)(sdk) === false) + safeFail( + config, + vfile, + filePath, + section, + 'invalid-sdk-in-frontmatter', + [invalidSDKs, config.validSdks as SDK[]], + node.position, + ) + return + } + + if (frontmatterYaml.title === undefined) { + safeFail(config, vfile, filePath, section, 'frontmatter-missing-title', [], node.position) + return + } + + if (frontmatterYaml.description === undefined) { + safeMessage(config, vfile, filePath, section, 'frontmatter-missing-description', [], node.position) + } + + frontmatter = { + title: frontmatterYaml.title, + description: frontmatterYaml.description, + sdk: frontmatterSDKs, + } + }, + ) + + if (frontmatter === undefined) { + safeFail(config, vfile, filePath, section, 'frontmatter-parse-failed', [href]) + return + } + + callback(frontmatter) + } diff --git a/scripts/lib/plugins/filterOtherSDKsContentOut.ts b/scripts/lib/plugins/filterOtherSDKsContentOut.ts new file mode 100644 index 0000000000..c4a436b366 --- /dev/null +++ b/scripts/lib/plugins/filterOtherSDKsContentOut.ts @@ -0,0 +1,31 @@ +import { filter as mdastFilter } from 'unist-util-filter' +import { type BuildConfig } from '../config' +import { type SDK } from '../schemas' +import { extractComponentPropValueFromNode } from '../utils/extractComponentPropValueFromNode' +import { extractSDKsFromIfProp } from '../utils/extractSDKsFromIfProp' +import type { Node } from 'unist' +import type { VFile } from 'vfile' + +// filter out content that is only available to other sdk's + +export const filterOtherSDKsContentOut = + (config: BuildConfig, filePath: string, targetSdk: SDK) => () => (tree: Node, vfile: VFile) => { + return mdastFilter(tree, (node) => { + // We aren't passing the vfile here as the as the warning + // should have already been reported above when we initially + // parsed the file + const sdk = extractComponentPropValueFromNode(config, node, undefined, 'If', 'sdk', true, 'docs', filePath) + + if (sdk === undefined) return true + + const sdksFilter = extractSDKsFromIfProp(config)(node, undefined, sdk, 'docs', filePath) + + if (sdksFilter === undefined) return true + + if (sdksFilter.includes(targetSdk)) { + return true + } + + return false + }) + } diff --git a/scripts/lib/plugins/validateIfComponents.ts b/scripts/lib/plugins/validateIfComponents.ts new file mode 100644 index 0000000000..511f575462 --- /dev/null +++ b/scripts/lib/plugins/validateIfComponents.ts @@ -0,0 +1,69 @@ +import { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import type { VFile } from 'vfile' +import { type BuildConfig } from '../config' +import { safeFail } from '../error-messages' +import { ManifestItem } from '../manifest' +import { type SDK } from '../schemas' +import { extractComponentPropValueFromNode } from '../utils/extractComponentPropValueFromNode' +import { extractSDKsFromIfProp } from '../utils/extractSDKsFromIfProp' + +export const validateIfComponents = + (config: BuildConfig, filePath: string, doc: { href: string; sdk?: SDK[] }, flatSDKScopedManifest: ManifestItem[]) => + () => + (tree: Node, vfile: VFile) => { + mdastVisit(tree, (node) => { + const sdk = extractComponentPropValueFromNode(config, node, vfile, 'If', 'sdk', false, 'docs', filePath) + + if (sdk === undefined) return + + const sdksFilter = extractSDKsFromIfProp(config)(node, vfile, sdk, 'docs', filePath) + + if (sdksFilter === undefined) return + + const manifestItems = flatSDKScopedManifest.filter((item) => item.href === doc.href) + + const availableSDKs = manifestItems.flatMap((item) => item.sdk).filter(Boolean) + + // The doc doesn't exist in the manifest so we are skipping it + if (manifestItems.length === 0) return + + sdksFilter.forEach((sdk) => { + ;(() => { + if (doc.sdk === undefined) return + + const available = doc.sdk.includes(sdk) + + if (available === false) { + safeFail( + config, + vfile, + filePath, + 'docs', + 'if-component-sdk-not-in-frontmatter', + [sdk, doc.sdk], + node.position, + ) + } + })() + ;(() => { + // The doc is generic so we are skipping it + if (availableSDKs.length === 0) return + + const available = availableSDKs.includes(sdk) + + if (available === false) { + safeFail( + config, + vfile, + filePath, + 'docs', + 'if-component-sdk-not-in-manifest', + [sdk, doc.href], + node.position, + ) + } + })() + }) + }) + } diff --git a/scripts/lib/plugins/validateLinks.ts b/scripts/lib/plugins/validateLinks.ts new file mode 100644 index 0000000000..7a4d3be109 --- /dev/null +++ b/scripts/lib/plugins/validateLinks.ts @@ -0,0 +1,56 @@ +// Validates +// - remove the mdx suffix from the url +// - check if the link is a valid link +// - check if the link is a link to a sdk scoped page +// - replace the link with the sdk link component if it is a link to a sdk scoped page + +import { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import type { VFile } from 'vfile' +import { type BuildConfig } from '../config' +import { safeMessage, type WarningsSection } from '../error-messages' +import { DocsMap } from '../store' +import { removeMdxSuffix } from '../utils/removeMdxSuffix' + +export const validateLinks = + (config: BuildConfig, docsMap: DocsMap, filePath: string, section: WarningsSection, doc?: { href: string }) => + () => + (tree: Node, vfile: VFile) => { + return mdastVisit(tree, (node) => { + if (node.type !== 'link') return + if (!('url' in node)) return + if (typeof node.url !== 'string') return + if (!node.url.startsWith(config.baseDocsLink) && (!node.url.startsWith('#') || doc === undefined)) return + if (!('children' in node)) return + + // we are overwriting the url with the mdx suffix removed + node.url = removeMdxSuffix(node.url) + + let [url, hash] = (node.url as string).split('#') + + if (url === '' && doc !== undefined) { + // If the link is just a hash, then we need to link to the same doc + url = doc.href + } + + const ignore = config.ignoredLink(url) + if (ignore === true) return + + const linkedDoc = docsMap.get(url) + + if (linkedDoc === undefined) { + safeMessage(config, vfile, filePath, section, 'link-doc-not-found', [url], node.position) + return + } + + if (hash !== undefined) { + const hasHash = linkedDoc.headingsHashes.has(hash) + + if (hasHash === false) { + safeMessage(config, vfile, filePath, section, 'link-hash-not-found', [hash, url], node.position) + } + } + + return + }) + } diff --git a/scripts/lib/plugins/validateUniqueHeadings.ts b/scripts/lib/plugins/validateUniqueHeadings.ts new file mode 100644 index 0000000000..463648540e --- /dev/null +++ b/scripts/lib/plugins/validateUniqueHeadings.ts @@ -0,0 +1,40 @@ +// goes through the markdown tree and ensures that all the heading ids are unique + +import { slugifyWithCounter } from '@sindresorhus/slugify' +import { toString } from 'mdast-util-to-string' +import { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import type { VFile } from 'vfile' +import { type BuildConfig } from '../config' +import { safeFail, type WarningsSection } from '../error-messages' +import { extractHeadingFromHeadingNode } from '../utils/extractHeadingFromHeadingNode' + +export const validateUniqueHeadings = + (config: BuildConfig, filePath: string, section: WarningsSection) => () => (tree: Node, vfile: VFile) => { + const headingsHashes = new Set() + const slugify = slugifyWithCounter() + + mdastVisit( + tree, + (node) => node.type === 'heading', + (node) => { + const id = extractHeadingFromHeadingNode(node) + + if (id !== undefined) { + if (headingsHashes.has(id)) { + safeFail(config, vfile, filePath, section, 'duplicate-heading-id', [filePath, id]) + } + + headingsHashes.add(id) + } else { + const slug = slugify(toString(node).trim()) + + if (headingsHashes.has(slug)) { + safeFail(config, vfile, filePath, section, 'duplicate-heading-id', [filePath, slug]) + } + + headingsHashes.add(slug) + } + }, + ) + } diff --git a/scripts/lib/schemas.ts b/scripts/lib/schemas.ts new file mode 100644 index 0000000000..6de7348ad6 --- /dev/null +++ b/scripts/lib/schemas.ts @@ -0,0 +1,106 @@ +// zod schemas for some of the basic types + +import { z } from 'zod' +import type { BuildConfig } from './config' + +export const VALID_SDKS = [ + 'nextjs', + 'react', + 'js-frontend', + 'chrome-extension', + 'expo', + 'ios', + 'nodejs', + 'expressjs', + 'fastify', + 'react-router', + 'remix', + 'tanstack-react-start', + 'go', + 'astro', + 'nuxt', + 'vue', + 'ruby', + 'python', + 'js-backend', + 'sdk-development', + 'community-sdk', +] as const + +export type SDK = (typeof VALID_SDKS)[number] + +export const sdk = z.enum(VALID_SDKS) + +export const icon = z.enum([ + 'apple', + 'application-2', + 'arrow-up-circle', + 'astro', + 'angular', + 'block', + 'bolt', + 'book', + 'box', + 'c-sharp', + 'chart', + 'checkmark-circle', + 'chrome', + 'clerk', + 'code-bracket', + 'cog-6-teeth', + 'door', + 'elysia', + 'expressjs', + 'globe', + 'go', + 'home', + 'hono', + 'javascript', + 'koa', + 'link', + 'linkedin', + 'lock', + 'nextjs', + 'nodejs', + 'plug', + 'plus-circle', + 'python', + 'react', + 'redwood', + 'remix', + 'react-router', + 'rocket', + 'route', + 'ruby', + 'rust', + 'speedometer', + 'stacked-rectangle', + 'solid', + 'svelte', + 'tanstack', + 'user-circle', + 'user-dotted-circle', + 'vue', + 'x', + 'expo', + 'nuxt', + 'fastify', +]) + +export type Icon = z.infer + +export const tag = z.enum(['(Beta)', '(Community)']) + +export type Tag = z.infer + +export const isValidSdk = + (config: BuildConfig) => + (sdk: string): sdk is SDK => { + return config.validSdks.includes(sdk as SDK) + } + +export const isValidSdks = + (config: BuildConfig) => + (sdks: string[]): sdks is SDK[] => { + return sdks.every(isValidSdk(config)) + } diff --git a/scripts/lib/store.ts b/scripts/lib/store.ts new file mode 100644 index 0000000000..6a23e8c3de --- /dev/null +++ b/scripts/lib/store.ts @@ -0,0 +1,11 @@ +import type { parseInMarkdownFile } from './markdown' +import type { readPartial } from './partials' +import type { readTypedoc } from './typedoc' + +type MarkdownFile = Awaited>> +type PartialsFile = Awaited>> +type TypedocsFile = Awaited>> + +export type DocsMap = Map +export type PartialsMap = Map +export type TypedocsMap = Map diff --git a/scripts/lib/typedoc.ts b/scripts/lib/typedoc.ts new file mode 100644 index 0000000000..5ffdc2e214 --- /dev/null +++ b/scripts/lib/typedoc.ts @@ -0,0 +1,87 @@ +// responsible for reading in and parsing the typedoc markdown +// for validation see validators/checkTypedoc.ts +// this also removes the .mdx suffix from the urls in the markdown +// some of the typedoc files with not parse when using `remarkMdx` +// so we catch those errors and parse them the same but without `remarkMdx` + +import path from 'node:path' +import readdirp from 'readdirp' +import { remark } from 'remark' +import remarkMdx from 'remark-mdx' +import type { Node } from 'unist' +import type { BuildConfig } from './config' +import { errorMessages } from './error-messages' +import { readMarkdownFile } from './io' +import { removeMdxSuffix } from './utils/removeMdxSuffix' + +export const readTypedocsFolder = (config: BuildConfig) => async () => { + return readdirp.promise(config.typedocPath, { + type: 'files', + fileFilter: '*.mdx', + }) +} + +export const readTypedoc = (config: BuildConfig) => async (filePath: string) => { + const readFile = readMarkdownFile(config) + + const typedocPath = path.join(config.typedocRelativePath, filePath) + + const [error, content] = await readFile(typedocPath) + + if (error) { + throw new Error(errorMessages['typedoc-read-error'](typedocPath), { cause: error }) + } + + try { + let node: Node | null = null + + const vfile = await remark() + .use(remarkMdx) + .use(() => (tree) => { + node = tree + }) + .process({ + path: typedocPath, + value: content, + }) + + if (node === null) { + throw new Error(errorMessages['typedoc-parse-error'](typedocPath)) + } + + return { + path: `${removeMdxSuffix(filePath)}.mdx`, + content, + vfile, + node: node as Node, + } + } catch (error) { + let node: Node | null = null + + const vfile = await remark() + .use(() => (tree) => { + node = tree + }) + .process({ + path: typedocPath, + value: content, + }) + + if (node === null) { + throw new Error(errorMessages['typedoc-parse-error'](typedocPath)) + } + + return { + path: `${removeMdxSuffix(filePath)}.mdx`, + content, + vfile, + node: node as Node, + } + } +} + +export const readTypedocsMarkdown = (config: BuildConfig) => async (paths: string[]) => { + const read = readTypedoc(config) + + return Promise.all(paths.map(async (filePath) => read(filePath))) +} diff --git a/scripts/lib/utils/documentHasIfComponents.ts b/scripts/lib/utils/documentHasIfComponents.ts new file mode 100644 index 0000000000..d89be3ff38 --- /dev/null +++ b/scripts/lib/utils/documentHasIfComponents.ts @@ -0,0 +1,19 @@ +// checks if a document has any components + +import { Node } from 'unist' +import { visit as mdastVisit } from 'unist-util-visit' +import { findComponent } from './findComponent' + +export const documentHasIfComponents = (tree: Node) => { + let found = false + + mdastVisit(tree, (node) => { + const ifSrc = findComponent(node, 'If') + + if (ifSrc !== undefined) { + found = true + } + }) + + return found +} diff --git a/scripts/lib/utils/extractComponentPropValueFromNode.ts b/scripts/lib/utils/extractComponentPropValueFromNode.ts new file mode 100644 index 0000000000..99432a0608 --- /dev/null +++ b/scripts/lib/utils/extractComponentPropValueFromNode.ts @@ -0,0 +1,102 @@ +// Given a component name (eg ) and a prop name (eg sdk) +// this function will extract and return the value of the prop +// note that this won't further parse the value + +import type { VFile } from 'vfile' +import type { BuildConfig } from '../config' +import type { Node } from 'unist' +import { safeMessage, type WarningsSection } from '../error-messages' +import { findComponent } from './findComponent' + +export const extractComponentPropValueFromNode = ( + config: BuildConfig, + node: Node, + vfile: VFile | undefined, + componentName: string, + propName: string, + required = true, + section: WarningsSection, + filePath: string, +): string | undefined => { + const component = findComponent(node, componentName) + + if (component === undefined) return undefined + + // Check for attributes + if (!('attributes' in component)) { + if (vfile) { + safeMessage(config, vfile, filePath, section, 'component-no-props', [componentName], component.position) + } + return undefined + } + + if (!Array.isArray(component.attributes)) { + if (vfile) { + safeMessage( + config, + vfile, + filePath, + section, + 'component-attributes-not-array', + [componentName], + component.position, + ) + } + return undefined + } + + // Find the requested prop + const propAttribute = component.attributes.find((attribute) => attribute.name === propName) + + if (propAttribute === undefined) { + if (required === true && vfile) { + safeMessage( + config, + vfile, + filePath, + section, + 'component-missing-attribute', + [componentName, propName], + component.position, + ) + } + return undefined + } + + const value = propAttribute.value + + if (value === undefined) { + if (required === true && vfile) { + safeMessage( + config, + vfile, + filePath, + section, + 'component-attribute-no-value', + [componentName, propName], + component.position, + ) + } + return undefined + } + + // Handle both string values and object values (like JSX expressions) + if (typeof value === 'string') { + return value + } else if (typeof value === 'object' && 'value' in value) { + return value.value + } + + if (vfile) { + safeMessage( + config, + vfile, + filePath, + section, + 'component-attribute-unsupported-type', + [componentName, propName], + component.position, + ) + } + return undefined +} diff --git a/scripts/lib/utils/extractHeadingFromHeadingNode.ts b/scripts/lib/utils/extractHeadingFromHeadingNode.ts new file mode 100644 index 0000000000..9c050f5d8a --- /dev/null +++ b/scripts/lib/utils/extractHeadingFromHeadingNode.ts @@ -0,0 +1,34 @@ +// We give authors the control to override the default heading id generated by slugify +// This function extracts the custom id from the heading node if it exists + +import type { Node } from 'unist' + +export const extractHeadingFromHeadingNode = (node: Node) => { + // eg # test {{ id: 'my-heading' }} + // This is for remapping the hash to the custom id + const id = + ('children' in node && + Array.isArray(node.children) && + (node?.children + ?.find( + (child: unknown) => + typeof child === 'object' && child !== null && 'type' in child && child?.type === 'mdxTextExpression', + ) + ?.data?.estree?.body?.find( + (child: unknown) => + typeof child === 'object' && child !== null && 'type' in child && child?.type === 'ExpressionStatement', + ) + ?.expression?.properties?.find( + (prop: unknown) => + typeof prop === 'object' && + prop !== null && + 'key' in prop && + typeof prop.key === 'object' && + prop.key !== null && + 'name' in prop.key && + prop.key.name === 'id', + )?.value?.value as string | undefined)) || + undefined + + return id +} diff --git a/scripts/lib/utils/extractSDKsFromIfProp.ts b/scripts/lib/utils/extractSDKsFromIfProp.ts new file mode 100644 index 0000000000..41139256ef --- /dev/null +++ b/scripts/lib/utils/extractSDKsFromIfProp.ts @@ -0,0 +1,36 @@ +// This function takes the value pulled out from +// `extractComponentPropValueFromNode()` and parses it in to +// an array of sdk keys + +import { VFile } from 'vfile' +import { BuildConfig } from '../config' +import type { Node } from 'unist' +import { safeMessage, type WarningsSection } from '../error-messages' +import { isValidSdk, isValidSdks } from '../schemas' + +export const extractSDKsFromIfProp = + (config: BuildConfig) => + (node: Node, vfile: VFile | undefined, sdkProp: string, section: WarningsSection, filePath: string) => { + const isValidItem = isValidSdk(config) + const isValidItems = isValidSdks(config) + + if (sdkProp.includes('", "') || sdkProp.includes("', '") || sdkProp.includes('["') || sdkProp.includes('"]')) { + const sdks = JSON.parse(sdkProp.replaceAll("'", '"')) as string[] + if (isValidItems(sdks)) { + return sdks + } else { + const invalidSDKs = sdks.filter((sdk) => !isValidItem(sdk)) + if (vfile) { + safeMessage(config, vfile, filePath, section, 'invalid-sdks-in-if', [invalidSDKs], node.position) + } + } + } else { + if (isValidItem(sdkProp)) { + return [sdkProp] + } else { + if (vfile) { + safeMessage(config, vfile, filePath, section, 'invalid-sdk-in-if', [sdkProp], node.position) + } + } + } + } diff --git a/scripts/lib/utils/findComponent.ts b/scripts/lib/utils/findComponent.ts new file mode 100644 index 0000000000..a1bd241c3b --- /dev/null +++ b/scripts/lib/utils/findComponent.ts @@ -0,0 +1,16 @@ +// hunts a markdown tree for a specific component + +import type { Node } from 'unist' + +export const findComponent = (node: Node, componentName: string) => { + // Check if it's an MDX component + if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') { + return undefined + } + + // Check if it's the correct component + if (!('name' in node)) return undefined + if (node.name !== componentName) return undefined + + return node +} diff --git a/scripts/lib/utils/removeMdxSuffix.ts b/scripts/lib/utils/removeMdxSuffix.ts new file mode 100644 index 0000000000..36696bc896 --- /dev/null +++ b/scripts/lib/utils/removeMdxSuffix.ts @@ -0,0 +1,19 @@ +// removes the .mdx suffix from a file path if it exists + +export const removeMdxSuffix = (filePath: string) => { + if (filePath.includes('#')) { + const [url, hash] = filePath.split('#') + + if (url.endsWith('.mdx')) { + return url.slice(0, -4) + `#${hash}` + } + + return url + `#${hash}` + } + + if (filePath.endsWith('.mdx')) { + return filePath.slice(0, -4) + } + + return filePath +}