diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 032d3af2023..fa7af6a8efe 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -1,11 +1,15 @@ import type * as Preset from '@docusaurus/preset-classic'; import type { Config } from '@docusaurus/types'; -import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.mjs'; -import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.mjs'; -import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.mjs'; -import remarkNpm2Yarn from './src/plugins/remark-npm2yarn.mjs'; -import remarkRawMarkdown from './src/plugins/remark-raw-markdown.mjs'; +import disableFullySpecified from './src/plugins/disable-fully-specified.ts'; +import llmsTxt from './src/plugins/llms-txt.ts'; +import ogImage from './src/plugins/og-image.ts'; +import reactNavigationVersions from './src/plugins/react-navigation-versions.ts'; +import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.ts'; +import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.ts'; +import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.ts'; +import remarkNpm2Yarn from './src/plugins/remark-npm2yarn.ts'; +import remarkRawMarkdown from './src/plugins/remark-raw-markdown.ts'; import darkTheme from './src/themes/react-navigation-dark'; import lightTheme from './src/themes/react-navigation-light'; @@ -131,10 +135,10 @@ const config: Config = { }, } satisfies Preset.ThemeConfig, plugins: [ - './src/plugins/disable-fully-specified.mjs', - './src/plugins/react-navigation-versions.mjs', - ['./src/plugins/llms-txt.mjs', { latestVersion }], - './src/plugins/og-image.ts', + disableFullySpecified, + reactNavigationVersions, + [llmsTxt, { latestVersion }], + ogImage, [ '@docusaurus/plugin-client-redirects', { diff --git a/__tests__/rehype-static-to-dynamic.test.mjs b/src/__tests__/rehype-static-to-dynamic.test.ts similarity index 96% rename from __tests__/rehype-static-to-dynamic.test.mjs rename to src/__tests__/rehype-static-to-dynamic.test.ts index 0e739ba10a9..786f2304918 100644 --- a/__tests__/rehype-static-to-dynamic.test.mjs +++ b/src/__tests__/rehype-static-to-dynamic.test.ts @@ -1,22 +1,30 @@ import dedent from 'dedent'; import assert from 'node:assert'; import { describe, test } from 'node:test'; -import rehypeStaticToDynamic from '../src/plugins/rehype-static-to-dynamic.mjs'; +import rehypeStaticToDynamic, { + type RehypeStaticToDynamicElement as Element, + type RehypeStaticToDynamicElementChild as ElementChild, + type RehypeStaticToDynamicRoot as Root, + type RehypeStaticToDynamicText as Text, + type RehypeStaticToDynamicTreeChild as TreeChild, +} from '../plugins/rehype-static-to-dynamic.ts'; /** * Helper function to create a test tree structure */ -function createTestTree(code) { - return { +function createTestTree(code: string): Root { + const tree: Root = { type: 'root', children: [ { type: 'element', tagName: 'pre', + properties: {}, children: [ { type: 'element', tagName: 'code', + properties: {}, data: { meta: 'static2dynamic' }, children: [ { @@ -29,23 +37,25 @@ function createTestTree(code) { }, ], }; + + return tree; } /** * Helper function to extract the transformed code from the tree */ -function extractTransformedCode(tree) { +function extractTransformedCode(tree: Root): string { // After transformation, the tree should have TabItem elements const tabsElement = tree.children[0]; - if (!tabsElement || tabsElement.tagName !== 'Tabs') { + if (!isElement(tabsElement) || tabsElement.tagName !== 'Tabs') { throw new Error('Expected Tabs element not found'); } // Find the "Dynamic" tab const dynamicTab = tabsElement.children.find( - (child) => - child.type === 'element' && + (child): child is Element => + isElement(child) && child.tagName === 'TabItem' && child.properties?.value === 'dynamic' ); @@ -56,7 +66,7 @@ function extractTransformedCode(tree) { // Extract the code from the dynamic tab const preElement = dynamicTab.children.find( - (child) => child.type === 'element' && child.tagName === 'pre' + (child): child is Element => isElement(child) && child.tagName === 'pre' ); if (!preElement) { @@ -64,18 +74,28 @@ function extractTransformedCode(tree) { } const codeElement = preElement.children.find( - (child) => child.type === 'element' && child.tagName === 'code' + (child): child is Element => isElement(child) && child.tagName === 'code' ); if (!codeElement) { throw new Error('Code element not found'); } - const textNode = codeElement.children.find((child) => child.type === 'text'); + const textNode = codeElement.children.find(isTextNode); return textNode?.value || ''; } +function isElement( + node: TreeChild | ElementChild | undefined +): node is Element { + return Boolean(node && node.type === 'element'); +} + +function isTextNode(node: ElementChild): node is Text { + return node.type === 'text'; +} + describe('rehype-static-to-dynamic', () => { test('basic screen transformation', async () => { const input = dedent /* javascript */ ` diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 67a2a18e612..b83885c0d0f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -12,7 +12,6 @@ export default function Home() { return ( ; + +type ParsedFrontMatter = { + data: FrontMatterData; + content: string; +}; + +type SidebarCategory = { + type: 'category'; + label: string; + items: SidebarItem[]; +}; + +type SidebarItem = string | SidebarCategory | Record; + +type FullDoc = { + title: string; + url: string; + content: string; +}; + +type LlmsTxtOptions = { + latestVersion?: string; +}; + +type SidebarProcessResult = { + content: string; + docs: FullDoc[]; +}; + +const versionsSource: unknown = versionsData; + +const versions = Array.isArray(versionsSource) + ? versionsSource.filter( + (version): version is string => typeof version === 'string' + ) + : []; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwnProperty( + value: T, + key: K +): value is T & Record { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isSidebarItem(value: unknown): value is SidebarItem { + return typeof value === 'string' || isRecord(value); +} + +function isSidebarCategory(item: SidebarItem): item is SidebarCategory { + if (!isRecord(item)) { + return false; + } + + if ( + !hasOwnProperty(item, 'type') || + !hasOwnProperty(item, 'label') || + !hasOwnProperty(item, 'items') + ) { + return false; + } + + return ( + item.type === 'category' && + typeof item.label === 'string' && + Array.isArray(item.items) + ); +} + +function normalizeRootItems(value: unknown): SidebarItem[] { + if (Array.isArray(value)) { + return value.filter(isSidebarItem); + } + + if (isRecord(value)) { + const normalized: SidebarCategory[] = []; + + for (const [label, items] of Object.entries(value)) { + normalized.push({ + type: 'category', + label, + items: Array.isArray(items) ? items.filter(isSidebarItem) : [], + }); + } + + return normalized; + } + + return []; +} /** * Parses frontmatter from markdown content. @@ -10,7 +106,7 @@ import versions from '../../versions.json'; * @param {string} fileContent - Raw markdown file content. * @returns {{data: Object, content: string}} - Parsed data and stripped content. */ -function parseFrontMatter(fileContent) { +function parseFrontMatter(fileContent: string): ParsedFrontMatter { const frontMatterRegex = /^---\n([\s\S]+?)\n---\n/; const match = fileContent.match(frontMatterRegex); @@ -21,7 +117,7 @@ function parseFrontMatter(fileContent) { const frontMatterBlock = match[1]; const content = fileContent.replace(frontMatterRegex, ''); - const data = {}; + const data: FrontMatterData = {}; frontMatterBlock.split('\n').forEach((line) => { const parts = line.split(':'); @@ -59,15 +155,15 @@ function parseFrontMatter(fileContent) { * @returns {{content: string, docs: Array}} - Generated index content and list of doc objects. */ function processSidebar( - items, - docsPath, - version, - isLatest, - baseUrl, + items: SidebarItem[], + docsPath: string, + version: string, + isLatest: boolean, + baseUrl: string, level = 0 -) { +): SidebarProcessResult { let llmsContent = ''; - let fullDocsList = []; + let fullDocsList: FullDoc[] = []; items.forEach((item) => { // Case 1: Item is a direct link (string ID) @@ -99,7 +195,7 @@ function processSidebar( } } // Case 2: Item is a category object - else if (item.type === 'category') { + else if (isSidebarCategory(item)) { const label = item.label; const headingPrefix = '#'.repeat(level + 3); // Start at level 3 (###) @@ -128,13 +224,13 @@ function processSidebar( * @returns {Array} - List of generated filenames. */ function generateForVersion( - siteDir, - outDir, - version, - outputPrefix, - isLatest, - baseUrl -) { + siteDir: string, + outDir: string, + version: string, + outputPrefix: string, + isLatest: boolean, + baseUrl: string +): string[] { const docsPath = path.join(siteDir, 'versioned_docs', `version-${version}`); const sidebarPath = path.join( siteDir, @@ -147,25 +243,18 @@ function generateForVersion( return []; } - const sidebarConfig = JSON.parse(fs.readFileSync(sidebarPath, 'utf8')); + const sidebarConfig: unknown = JSON.parse( + fs.readFileSync(sidebarPath, 'utf8') + ); // Handle different Docusaurus sidebar structures (root 'docs' key or first key) - let rootItems = sidebarConfig.docs || Object.values(sidebarConfig)[0] || []; - - // Normalize object-style sidebars (older Docusaurus versions) to array format - if (!Array.isArray(rootItems) && typeof rootItems === 'object') { - const normalized = []; - - for (const [label, items] of Object.entries(rootItems)) { - normalized.push({ - type: 'category', - label: label, - items: items, - }); - } + const rootItemsSource = isRecord(sidebarConfig) + ? hasOwnProperty(sidebarConfig, 'docs') + ? sidebarConfig.docs + : Object.values(sidebarConfig)[0] + : undefined; - rootItems = normalized; - } + const rootItems = normalizeRootItems(rootItemsSource); const { content: sidebarContent, docs } = processSidebar( rootItems, @@ -213,7 +302,10 @@ function generateForVersion( return [summaryFilename, fullFilename]; } -export default function (context, options) { +export default function llmsTxtPlugin( + context: LoadContext, + options: LlmsTxtOptions +): Plugin { return { name: 'llms.txt', async postBuild({ siteDir, outDir }) { diff --git a/src/plugins/react-navigation-versions.mjs b/src/plugins/react-navigation-versions.ts similarity index 51% rename from src/plugins/react-navigation-versions.mjs rename to src/plugins/react-navigation-versions.ts index 7b3c257038a..1639f708565 100644 --- a/src/plugins/react-navigation-versions.mjs +++ b/src/plugins/react-navigation-versions.ts @@ -1,5 +1,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import type { + LoadContext, + Plugin, + PluginContentLoadedActions, +} from '@docusaurus/types'; const CACHE_DIR = join( process.cwd(), @@ -8,32 +13,87 @@ const CACHE_DIR = join( 'react-navigation-versions' ); -const query = async (name, tag) => { +type NpmPackage = { + version: string; + peerDependencies?: Record; +}; + +type VersionQuery = { + tag: string; + packages: string[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + + return Object.values(value).every((entry) => typeof entry === 'string'); +} + +function isNpmPackage(value: unknown): value is NpmPackage { + if (!isRecord(value)) { + return false; + } + + if (typeof value.version !== 'string') { + return false; + } + + if ( + 'peerDependencies' in value && + value.peerDependencies !== undefined && + !isStringRecord(value.peerDependencies) + ) { + return false; + } + + return true; +} + +const query = async (name: string, tag: string): Promise => { const cached = join(CACHE_DIR, `${name}-${tag}.json`); - let pkg; + let pkg: NpmPackage; try { - pkg = await fetch(`https://registry.npmjs.org/${name}/${tag}`).then((res) => - res.json() - ); + const response = await fetch(`https://registry.npmjs.org/${name}/${tag}`); + const data: unknown = await response.json(); + + if (!isNpmPackage(data)) { + throw new Error(`Invalid package response for ${name}@${tag}`); + } + + pkg = data; await mkdir(dirname(cached), { recursive: true }); await writeFile(cached, JSON.stringify(pkg)); } catch (e) { const data = await readFile(cached, 'utf-8'); + const parsed: unknown = JSON.parse(data); + + if (!isNpmPackage(parsed)) { + throw new Error(`Invalid cached package response for ${name}@${tag}`); + } - pkg = JSON.parse(data); + pkg = parsed; } return pkg; }; -export default function reactNavigationVersionsPlugin(context, options) { +export default function reactNavigationVersionsPlugin( + _context: LoadContext, + _options: unknown +): Plugin { return { name: 'react-navigation-versions', - async contentLoaded({ content, actions }) { - const queries = { + async contentLoaded({ actions }: { actions: PluginContentLoadedActions }) { + const queries: Record = { '7.x': { tag: 'latest', packages: [ diff --git a/src/plugins/rehype-codeblock-meta.mjs b/src/plugins/rehype-codeblock-meta.ts similarity index 50% rename from src/plugins/rehype-codeblock-meta.mjs rename to src/plugins/rehype-codeblock-meta.ts index 1dac09d65c2..e408a5de154 100644 --- a/src/plugins/rehype-codeblock-meta.mjs +++ b/src/plugins/rehype-codeblock-meta.ts @@ -1,27 +1,60 @@ +import type { Element, Root } from 'hast'; import { visit } from 'unist-util-visit'; +type MatchMap = Record; + +type RehypeCodeblockMetaOptions = { + match: MatchMap; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwnProperty( + value: T, + key: K +): value is T & Record { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function getMeta(data: Element['data'] | undefined): string | undefined { + if (!isRecord(data)) { + return undefined; + } + + if (!hasOwnProperty(data, 'meta')) { + return undefined; + } + + return typeof data.meta === 'string' ? data.meta : undefined; +} + /** * Plugin to process codeblock meta * * @param {{ match: { [key: string]: string }, element: JSX.ElementType }} options */ -export default function rehypeCodeblockMeta(options) { +export default function rehypeCodeblockMeta( + options: RehypeCodeblockMetaOptions +) { if (!options?.match) { throw new Error('rehype-codeblock-meta: `match` option is required'); } - return (tree) => { - visit(tree, 'element', (node) => { + return (tree: Root) => { + visit(tree, 'element', (node: Element) => { if ( node.tagName === 'pre' && node.children?.length === 1 && + node.children[0].type === 'element' && node.children[0].tagName === 'code' ) { const codeblock = node.children[0]; - const meta = codeblock.data?.meta; + const meta = getMeta(codeblock.data); if (meta) { - let segments = []; + let segments: string[] = []; // Walk through meta string and split it into segments based on space unless it's inside quotes for (let i = 0; i < meta.length; i++) { @@ -41,13 +74,16 @@ export default function rehypeCodeblockMeta(options) { segments.push(segment); } - const attributes = segments.reduce((acc, attribute) => { - const [key, value = 'true'] = attribute.split('='); + const attributes = segments.reduce>( + (acc, attribute) => { + const [key, value = 'true'] = attribute.split('='); - return Object.assign(acc, { - [`data-${key}`]: value.replace(/^"(.+(?="$))"$/, '$1'), - }); - }, {}); + return Object.assign(acc, { + [`data-${key}`]: value.replace(/^"(.+(?="$))"$/, '$1'), + }); + }, + {} + ); if ( Object.entries(options.match).some(([key, value]) => { diff --git a/src/plugins/rehype-static-to-dynamic.mjs b/src/plugins/rehype-static-to-dynamic.ts similarity index 70% rename from src/plugins/rehype-static-to-dynamic.mjs rename to src/plugins/rehype-static-to-dynamic.ts index adca20977e2..d3efeb0159c 100644 --- a/src/plugins/rehype-static-to-dynamic.mjs +++ b/src/plugins/rehype-static-to-dynamic.ts @@ -8,6 +8,8 @@ import * as recast from 'recast'; import * as babelParser from 'recast/parsers/babel-ts.js'; import { visit } from 'unist-util-visit'; import { fileURLToPath } from 'url'; +import type { Element, Root, Text } from 'hast'; +import type { Parent } from 'unist'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -15,6 +17,139 @@ const prettierConfig = JSON.parse( readFileSync(join(__dirname, '..', '..', '.prettierrc.json'), 'utf-8') ); +type TrackedComment = { + value: string; + type: t.Comment['type']; +}; + +export type RehypeStaticToDynamicRoot = Root; + +export type RehypeStaticToDynamicElement = Element; + +export type RehypeStaticToDynamicText = Text; + +export type RehypeStaticToDynamicTreeChild = Root['children'][number]; + +export type RehypeStaticToDynamicElementChild = Element['children'][number]; + +type CommentWithMarkers = t.Comment & { + leading?: boolean; + trailing?: boolean; +}; + +type CommentedNode = t.Node & { + comments?: CommentWithMarkers[]; +}; + +type CommentTrackingEntry = { + screenName?: string; + navigatorProp?: string; + screenConfigProperty?: string; + leadingComments: TrackedComment[]; + trailingComments: TrackedComment[]; +}; + +type NavigatorInfo = { + componentName: string; + type: string; + config: t.ObjectExpression; + comments: CommentWithMarkers[]; + trailingComments: CommentWithMarkers[]; + index: number; +}; + +type PropInfo = { + key: string; + value: t.Expression; +}; + +type ScreenConfig = { + component: string; + screenProps: Record; +}; + +type GroupConfig = { + screens: Record; + groupProps: Record; +}; + +type ParsedNavigatorConfig = { + screens: Record; + groups: Record; + navigatorProps: Record; +}; + +type Replacement = { + parent: Parent; + index: number; + tabsElement: Element; +}; + +type CommentArrayKey = 'comments' | 'leadingComments' | 'trailingComments'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwnProperty( + value: T, + key: K +): value is T & Record { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isCommentWithMarkers(value: unknown): value is CommentWithMarkers { + return isRecord(value) && hasOwnProperty(value, 'type'); +} + +function getCommentArray( + node: t.Node, + key: CommentArrayKey +): CommentWithMarkers[] { + if (!hasOwnProperty(node, key)) { + return []; + } + + const value = node[key]; + + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isCommentWithMarkers); +} + +function toTrackedComments(comments: CommentWithMarkers[]): TrackedComment[] { + return comments.map((comment) => ({ + value: comment.value, + type: comment.type, + })); +} + +function getMeta(data: Element['data'] | undefined): string | undefined { + if (!isRecord(data) || !hasOwnProperty(data, 'meta')) { + return undefined; + } + + return typeof data.meta === 'string' ? data.meta : undefined; +} + +function getDataObject( + data: Element['data'] | undefined +): Record { + return isRecord(data) ? data : {}; +} + +function getFirstChildElement(node: Element): Element | null { + const [firstChild] = node.children; + return firstChild?.type === 'element' ? firstChild : null; +} + +function getTextNodeValue(node: Element): string | null { + const [firstChild] = node.children; + return firstChild?.type === 'text' ? firstChild.value : null; +} + /** * Plugin to automatically convert static config examples to dynamic config * @@ -22,19 +157,25 @@ const prettierConfig = JSON.parse( * corresponding dynamic configuration examples wrapped in tabs. */ export default function rehypeStaticToDynamic() { - return async (tree) => { - const promises = []; - const replacements = []; + return async (tree: Root) => { + const promises: Promise[] = []; + const replacements: Replacement[] = []; - visit(tree, 'element', (node, index, parent) => { + visit(tree, 'element', (node: Element, index, parent) => { // Look for code blocks with static2dynamic in meta if ( node.tagName === 'pre' && node.children?.length === 1 && + node.children[0].type === 'element' && node.children[0].tagName === 'code' ) { - const codeNode = node.children[0]; - const meta = codeNode.data?.meta; + const codeNode = getFirstChildElement(node); + + if (!codeNode) { + return; + } + + const meta = getMeta(codeNode.data); // Check if meta contains 'static2dynamic' if (!meta || !meta.includes('static2dynamic')) { @@ -42,7 +183,7 @@ export default function rehypeStaticToDynamic() { } // Extract code from the code block - const code = codeNode?.children?.[0]?.value; + const code = getTextNodeValue(codeNode); if (!code) { throw new Error( @@ -57,6 +198,7 @@ export default function rehypeStaticToDynamic() { dynamicCode, node ); + if (!parent || typeof index !== 'number') return; replacements.push({ parent, index, tabsElement }); }); promises.push(promise); @@ -90,7 +232,7 @@ export default function rehypeStaticToDynamic() { * 5. Post-process: Inject comments into formatted code * - Comments are injected as strings since Prettier may move/remove AST comments */ -async function convertStaticToDynamic(code) { +async function convertStaticToDynamic(code: string): Promise { // Parse the code into AST using recast with comment attachment enabled const ast = recast.parse(code, { parser: { @@ -104,17 +246,17 @@ async function convertStaticToDynamic(code) { }, }); - let navigatorInfos = []; - let staticNavigationIndices = []; + let navigatorInfos: NavigatorInfo[] = []; + let staticNavigationIndices: number[] = []; // Track comments throughout the transformation // We collect comments from the AST during transformation, then inject them // after Prettier formatting (since Prettier may reformat/move AST comments) - const commentTracking = new Set(); + const commentTracking = new Set(); // First pass: Collect navigator info and transform imports recast.visit(ast, { - visitImportDeclaration(path) { + visitImportDeclaration(path: any) { const source = path.node.source.value; // Transform @react-navigation/native imports @@ -125,29 +267,26 @@ async function convertStaticToDynamic(code) { let hasCreateStaticNavigation = false; specifiers.forEach((spec) => { - if ( - t.isImportSpecifier(spec) && - spec.imported.name === 'NavigationContainer' - ) { + if (!t.isImportSpecifier(spec)) return; + + const importedName = getPropertyKeyName(spec.imported); + + if (importedName === 'NavigationContainer') { hasNavigationContainer = true; } - if ( - t.isImportSpecifier(spec) && - spec.imported.name === 'createStaticNavigation' - ) { + if (importedName === 'createStaticNavigation') { hasCreateStaticNavigation = true; } }); // Remove createStaticNavigation and add NavigationContainer if needed if (hasCreateStaticNavigation) { - path.node.specifiers = specifiers.filter( - (spec) => - !( - t.isImportSpecifier(spec) && - spec.imported.name === 'createStaticNavigation' - ) - ); + path.node.specifiers = specifiers.filter((spec) => { + if (!t.isImportSpecifier(spec)) return true; + return ( + getPropertyKeyName(spec.imported) !== 'createStaticNavigation' + ); + }); if (!hasNavigationContainer) { path.node.specifiers.push( @@ -165,7 +304,7 @@ async function convertStaticToDynamic(code) { if (source.startsWith('@react-navigation/')) { path.node.specifiers = path.node.specifiers.filter((spec) => { if (t.isImportSpecifier(spec)) { - const importedName = spec.imported.name; + const importedName = getPropertyKeyName(spec.imported); // Remove imports that match createXScreen pattern return !( importedName.startsWith('create') && @@ -179,7 +318,7 @@ async function convertStaticToDynamic(code) { this.traverse(path); }, - visitProgram(path) { + visitProgram(path: any) { // Find declarations by index to avoid scope issues path.node.body.forEach((node, index) => { if (t.isVariableDeclaration(node)) { @@ -192,6 +331,10 @@ async function convertStaticToDynamic(code) { declarator.init.arguments.length > 0 && t.isObjectExpression(declarator.init.arguments[0]) ) { + if (!t.isIdentifier(declarator.id)) { + return; + } + const navigatorVariable = declarator.id.name; // e.g., "MyStack" const navigatorType = declarator.init.callee.name; // e.g., "createStackNavigator" const config = declarator.init.arguments[0]; @@ -201,9 +344,8 @@ async function convertStaticToDynamic(code) { type: navigatorType, config: config, // Store leading/trailing comments to preserve codeblock-focus - leadingComments: node.comments || [], - trailingComments: node.trailingComments || [], - originalNode: node, // Keep reference to original node + comments: getCommentArray(node, 'comments'), + trailingComments: getCommentArray(node, 'trailingComments'), index: index, }); } @@ -223,7 +365,7 @@ async function convertStaticToDynamic(code) { this.traverse(path); }, - visitJSXElement(path) { + visitJSXElement(path: any) { // Find (created by createStaticNavigation) if ( t.isJSXIdentifier(path.node.openingElement.name) && @@ -278,15 +420,8 @@ async function convertStaticToDynamic(code) { const navigatorConstNames = new Map(); // Track usage of navigator constant names navigatorInfos.forEach((navigatorInfo) => { - const { - componentName, - type, - config, - leadingComments, - trailingComments, - originalNode, - index, - } = navigatorInfo; + const { componentName, type, config, comments, trailingComments, index } = + navigatorInfo; const baseNavigatorConstName = deriveNavigatorConstName(type); const navigatorConstName = getUniqueNavigatorConstName( @@ -298,27 +433,30 @@ async function convertStaticToDynamic(code) { const parsedConfig = parseNavigatorConfig(config, commentTracking); // Create: const Stack = createStackNavigator(); - const navigatorConstDeclaration = t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(navigatorConstName), - t.callExpression(t.identifier(type), []) - ), - ]); + const navigatorConstDeclaration: CommentedNode = t.variableDeclaration( + 'const', + [ + t.variableDeclarator( + t.identifier(navigatorConstName), + t.callExpression(t.identifier(type), []) + ), + ] + ); // Create the navigator component function (e.g., function MyStack() {...}) - const navigatorComponent = createNavigatorComponent( + const navigatorComponent: CommentedNode = createNavigatorComponent( componentName, // function name: MyStack navigatorConstName, // Stack.Navigator, Stack.Screen parsedConfig ); // Preserve all comments from the original node - if (originalNode.comments && originalNode.comments.length > 0) { + if (comments.length > 0) { // Separate leading and trailing comments based on recast markers - const leadingComments = []; - const trailingCommentsFromNode = []; + const leadingComments: CommentWithMarkers[] = []; + const trailingCommentsFromNode: CommentWithMarkers[] = []; - originalNode.comments.forEach((comment) => { + comments.forEach((comment) => { if (comment.trailing) { trailingCommentsFromNode.push(comment); } else { @@ -523,11 +661,34 @@ async function convertStaticToDynamic(code) { return formattedCode; } +function getPropertyKeyName(key: t.Expression | t.PrivateName): string { + if (t.isIdentifier(key)) return key.name; + if (t.isStringLiteral(key)) return key.value; + if (t.isNumericLiteral(key)) return String(key.value); + return ''; +} + +function getObjectPropertyValue( + prop: t.ObjectProperty | t.ObjectMethod +): t.Expression | null { + if (t.isObjectProperty(prop)) { + return t.isExpression(prop.value) ? prop.value : null; + } + + return t.functionExpression( + null, + prop.params, + prop.body, + prop.generator, + prop.async + ); +} + /** * Extract screen config from a screen value node. * Handles both direct object expressions and createXScreen function calls. */ -function extractScreenConfig(screenValue) { +function extractScreenConfig(screenValue: t.Node): t.Node { // Handle createXScreen function calls // e.g., createNativeStackScreen({ screen: ProfileScreen, ... }) if ( @@ -549,30 +710,30 @@ function extractScreenConfig(screenValue) { /** * Track comments on screen config properties (options, screen, listeners, etc.) */ -function trackScreenConfigComments(screenValue, screenName, commentTracking) { +function trackScreenConfigComments( + screenValue: t.Node, + screenName: string, + commentTracking: Set +) { const configObject = extractScreenConfig(screenValue); if (t.isObjectExpression(configObject)) { configObject.properties.forEach((configProp) => { if ( t.isObjectProperty(configProp) && - (configProp.leadingComments || configProp.trailingComments) + (getCommentArray(configProp, 'leadingComments').length > 0 || + getCommentArray(configProp, 'trailingComments').length > 0) ) { - const propName = configProp.key.name || configProp.key.value; + const propName = getPropertyKeyName(configProp.key); commentTracking.add({ - originalNode: configProp, screenName, screenConfigProperty: propName, - leadingComments: - configProp.leadingComments?.map((c) => ({ - value: c.value, - type: c.type, - })) || [], - trailingComments: - configProp.trailingComments?.map((c) => ({ - value: c.value, - type: c.type, - })) || [], + leadingComments: toTrackedComments( + getCommentArray(configProp, 'leadingComments') + ), + trailingComments: toTrackedComments( + getCommentArray(configProp, 'trailingComments') + ), }); } }); @@ -582,22 +743,23 @@ function trackScreenConfigComments(screenValue, screenName, commentTracking) { /** * Track comments on screen elements themselves */ -function trackScreenComments(screenProp, screenName, commentTracking) { - if (screenProp.leadingComments || screenProp.trailingComments) { +function trackScreenComments( + screenProp: t.ObjectProperty, + screenName: string, + commentTracking: Set +) { + if ( + getCommentArray(screenProp, 'leadingComments').length > 0 || + getCommentArray(screenProp, 'trailingComments').length > 0 + ) { commentTracking.add({ - originalNode: screenProp, screenName, - leadingComments: - screenProp.leadingComments?.map((c) => ({ - value: c.value, - type: c.type, - })) || [], - trailingComments: - screenProp.trailingComments?.map((c) => ({ - value: c.value, - type: c.type, - })) || [], - targetNode: null, + leadingComments: toTrackedComments( + getCommentArray(screenProp, 'leadingComments') + ), + trailingComments: toTrackedComments( + getCommentArray(screenProp, 'trailingComments') + ), }); } } @@ -606,7 +768,7 @@ function trackScreenComments(screenProp, screenName, commentTracking) { * Find the line index of a Screen element's opening tag. * Handles both single-line and multi-line Screen elements. */ -function findScreenElementLine(lines, screenName) { +function findScreenElementLine(lines: string[], screenName: string) { for (let i = 0; i < lines.length; i++) { // Check if line contains Screen opening tag with the name attribute if ( @@ -634,7 +796,7 @@ function findScreenElementLine(lines, screenName) { * Find the line index of a Screen element's closing tag. * Looks for either self-closing /> or closing tag. */ -function findScreenClosingLine(lines, screenName) { +function findScreenClosingLine(lines: string[], screenName: string) { for (let i = 0; i < lines.length; i++) { if ( (lines[i].includes('.Screen') && lines[i].includes('/>')) || @@ -654,7 +816,11 @@ function findScreenClosingLine(lines, screenName) { /** * Format a comment for injection into code */ -function formatComment(comment, indent, isJSXContext = false) { +function formatComment( + comment: TrackedComment, + indent: string, + isJSXContext = false +) { const commentValue = comment.value.trim(); if (comment.type === 'CommentLine') { return `${indent}// ${commentValue}`; @@ -669,10 +835,10 @@ function formatComment(comment, indent, isJSXContext = false) { * Inject comments into lines array at specified position */ function injectComments( - lines, - comments, - lineIndex, - indent, + lines: string[], + comments: TrackedComment[], + lineIndex: number, + indent: string, isJSXContext = false ) { let currentIndex = lineIndex; @@ -687,7 +853,7 @@ function injectComments( /** * Check if a line contains a screen name attribute (with either quote style) */ -function lineMatchesScreenName(line, screenName) { +function lineMatchesScreenName(line: string, screenName: string) { return ( line.includes(`name='${screenName}'`) || line.includes(`name="${screenName}"`) @@ -697,7 +863,7 @@ function lineMatchesScreenName(line, screenName) { /** * Extract indentation from a line */ -function getIndentation(line, defaultIndent = ' ') { +function getIndentation(line: string, defaultIndent = ' ') { return line.match(/^(\s*)/)?.[1] || defaultIndent; } @@ -709,7 +875,7 @@ function getIndentation(line, defaultIndent = ' ') { * - "createBottomTabNavigator" -> "Tab" * - "createMaterialTopTabNavigator" -> "Tab" */ -function deriveNavigatorConstName(navigatorType) { +function deriveNavigatorConstName(navigatorType: string) { // Remove "create" prefix and "Navigator" suffix const withoutCreate = navigatorType.replace(/^create/, ''); const withoutNavigator = withoutCreate.replace(/Navigator$/, ''); @@ -723,8 +889,8 @@ function deriveNavigatorConstName(navigatorType) { * Second occurrence gets 'A', third gets 'B', etc. */ function getUniqueNavigatorConstName( - baseNavigatorConstName, - navigatorConstNames + baseNavigatorConstName: string, + navigatorConstNames: Map ) { const currentCount = navigatorConstNames.get(baseNavigatorConstName) || 0; navigatorConstNames.set(baseNavigatorConstName, currentCount + 1); @@ -740,7 +906,11 @@ function getUniqueNavigatorConstName( /** * Attach comments to AST nodes with proper leading/trailing markers */ -function attachCommentsToNode(node, comments, isTrailing = false) { +function attachCommentsToNode( + node: CommentedNode, + comments: CommentWithMarkers[], + isTrailing = false +) { if (comments.length === 0) return; comments.forEach((c) => { @@ -753,7 +923,12 @@ function attachCommentsToNode(node, comments, isTrailing = false) { /** * Find a property line within a context (searches lines before for context marker) */ -function findPropertyLine(lines, propMatcher, contextMatcher, searchRange) { +function findPropertyLine( + lines: string[], + propMatcher: (line: string) => boolean, + contextMatcher: (line: string) => boolean, + searchRange: number +) { for (let i = 0; i < lines.length; i++) { if (propMatcher(lines[i])) { // Check if this is within the right context by searching lines before only @@ -773,7 +948,11 @@ function findPropertyLine(lines, propMatcher, contextMatcher, searchRange) { /** * Find the closing line for a JSX property value (looks for }} or })}) */ -function findPropertyClosingLine(lines, startLine, maxSearchLines = 10) { +function findPropertyClosingLine( + lines: string[], + startLine: number, + maxSearchLines = 10 +) { for ( let i = startLine; i < Math.min(startLine + maxSearchLines, lines.length); @@ -795,7 +974,7 @@ function findPropertyClosingLine(lines, startLine, maxSearchLines = 10) { /** * Create a JSX member expression (e.g., Stack.Navigator, Stack.Screen) */ -function createJsxMemberExpression(componentName, memberName) { +function createJsxMemberExpression(componentName: string, memberName: string) { return t.jsxMemberExpression( t.jsxIdentifier(componentName), t.jsxIdentifier(memberName) @@ -805,9 +984,9 @@ function createJsxMemberExpression(componentName, memberName) { /** * Create JSX attributes from propInfo objects */ -function createJsxAttributesFromProps(propsObject) { +function createJsxAttributesFromProps(propsObject: Record) { return Object.values(propsObject).map((propInfo) => { - if (propInfo.isStringLiteral) { + if (t.isStringLiteral(propInfo.value)) { return t.jsxAttribute( t.jsxIdentifier(propInfo.key), t.stringLiteral(propInfo.value.value) @@ -823,7 +1002,11 @@ function createJsxAttributesFromProps(propsObject) { /** * Create a Screen JSX element */ -function createScreenElement(componentName, screenName, screenConfig) { +function createScreenElement( + componentName: string, + screenName: string, + screenConfig: ScreenConfig +) { const screenProps = [ t.jsxAttribute(t.jsxIdentifier('name'), t.stringLiteral(screenName)), t.jsxAttribute( @@ -855,7 +1038,7 @@ function createScreenElement(componentName, screenName, screenConfig) { * Parse a screen value and return component and screenProps. * Handles identifiers, object expressions, and createXScreen calls. */ -function parseScreenValue(screenValue) { +function parseScreenValue(screenValue: t.Node): ScreenConfig | null { if (t.isIdentifier(screenValue)) { // Simple screen: Home: HomeScreen return { @@ -869,25 +1052,43 @@ function parseScreenValue(screenValue) { if (t.isObjectExpression(configNode)) { // Screen with config: Home: { screen: HomeScreen, options: {...}, listeners: {...} } - let component = null; + let component: string | null = null; const screenProps = {}; configNode.properties.forEach((screenConfigProp) => { - if (!t.isObjectProperty(screenConfigProp)) return; + if ( + !t.isObjectProperty(screenConfigProp) && + !t.isObjectMethod(screenConfigProp) + ) { + return; + } - const configKey = screenConfigProp.key.name || screenConfigProp.key.value; + const configKey = getPropertyKeyName(screenConfigProp.key); - if (configKey === 'screen' && t.isIdentifier(screenConfigProp.value)) { + if ( + configKey === 'screen' && + t.isObjectProperty(screenConfigProp) && + t.isIdentifier(screenConfigProp.value) + ) { component = screenConfigProp.value.name; - } else { - // Store all other props (options, listeners, getId, linking, etc.) - // But skip 'linking' as it's only for static config - if (configKey !== 'linking') { - screenProps[configKey] = screenConfigProp.value; + return; + } + + // Store all other props (options, listeners, getId, linking, etc.) + // But skip 'linking' as it's only for static config + if (configKey !== 'linking') { + const propValue = getObjectPropertyValue(screenConfigProp); + + if (propValue) { + screenProps[configKey] = propValue; } } }); + if (!component) { + return null; + } + return { component, screenProps }; } @@ -899,8 +1100,11 @@ function parseScreenValue(screenValue) { * Extracts screens, groups, and navigator-level properties. * Also tracks comments for later injection into the dynamic code. */ -function parseNavigatorConfig(configNode, commentTracking) { - const result = { +function parseNavigatorConfig( + configNode: t.ObjectExpression, + commentTracking: Set +): ParsedNavigatorConfig { + const result: ParsedNavigatorConfig = { screens: {}, // Standalone screens (not in groups) groups: {}, // Screen groups with their own screens and props navigatorProps: {}, // Navigator-level props (screenOptions, initialRouteName, etc.) @@ -912,46 +1116,35 @@ function parseNavigatorConfig(configNode, commentTracking) { // Get all properties from the navigator config object const props = configNode.properties.filter( - (prop) => t.isObjectProperty(prop) || t.isObjectMethod(prop) + (prop): prop is t.ObjectProperty | t.ObjectMethod => + t.isObjectProperty(prop) || t.isObjectMethod(prop) ); props.forEach((prop, index) => { - const keyName = prop.key.name || prop.key.value; + const keyName = getPropertyKeyName(prop.key); + const propValue = getObjectPropertyValue(prop); // Track comments on navigator-level properties (but not on screens/groups) if (keyName !== 'screens' && keyName !== 'groups') { - const leadingComments = - prop.leadingComments?.map((c) => ({ - value: c.value, - type: c.type, - })) || []; + const leadingComments = toTrackedComments( + getCommentArray(prop, 'leadingComments') + ); // Collect trailing comments from both the property and its value - let trailingComments = []; - if (prop.trailingComments) { - trailingComments.push( - ...prop.trailingComments.map((c) => ({ - value: c.value, - type: c.type, - })) - ); - } - if (prop.value?.trailingComments) { - trailingComments.push( - ...prop.value.trailingComments.map((c) => ({ - value: c.value, - type: c.type, - })) - ); - } + const trailingComments = [ + ...toTrackedComments(getCommentArray(prop, 'trailingComments')), + ...(t.isObjectProperty(prop) + ? toTrackedComments(getCommentArray(prop.value, 'trailingComments')) + : []), + ]; // Heuristic: Check if the next property has leading comments that are actually // trailing comments for this property (detected by -end or end suffix) // This handles cases where Babel attaches multiline trailing comments as leading const nextProp = props[index + 1]; - if (nextProp?.leadingComments) { - nextProp.leadingComments.forEach((c) => { + if (nextProp) { + getCommentArray(nextProp, 'leadingComments').forEach((c) => { // Only treat as trailing if the comment ends with -end or similar markers if ( c.value.trim().endsWith('-end') || @@ -967,7 +1160,6 @@ function parseNavigatorConfig(configNode, commentTracking) { if (leadingComments.length > 0 || trailingComments.length > 0) { const commentObj = { - originalNode: prop, navigatorProp: keyName, leadingComments, trailingComments, @@ -977,12 +1169,16 @@ function parseNavigatorConfig(configNode, commentTracking) { } // Parse groups object (e.g., groups: { modal: { screens: {...}, screenOptions: {...} } }) - if (keyName === 'groups' && t.isObjectExpression(prop.value)) { + if ( + keyName === 'groups' && + t.isObjectProperty(prop) && + t.isObjectExpression(prop.value) + ) { // Parse groups object prop.value.properties.forEach((groupProp) => { if (!t.isObjectProperty(groupProp)) return; - const groupKey = groupProp.key.name || groupProp.key.value; + const groupKey = getPropertyKeyName(groupProp.key); const groupValue = groupProp.value; if (t.isObjectExpression(groupValue)) { @@ -992,20 +1188,26 @@ function parseNavigatorConfig(configNode, commentTracking) { }; groupValue.properties.forEach((groupConfigProp) => { - if (!t.isObjectProperty(groupConfigProp)) return; + if ( + !t.isObjectProperty(groupConfigProp) && + !t.isObjectMethod(groupConfigProp) + ) { + return; + } - const configKey = - groupConfigProp.key.name || groupConfigProp.key.value; + const configKey = getPropertyKeyName(groupConfigProp.key); + const groupPropValue = getObjectPropertyValue(groupConfigProp); if ( configKey === 'screens' && + t.isObjectProperty(groupConfigProp) && t.isObjectExpression(groupConfigProp.value) ) { // Parse screens within the group groupConfigProp.value.properties.forEach((screenProp) => { if (!t.isObjectProperty(screenProp)) return; - const screenName = screenProp.key.name || screenProp.key.value; + const screenName = getPropertyKeyName(screenProp.key); const screenValue = screenProp.value; // Track comments on any property inside the screen config @@ -1026,11 +1228,12 @@ function parseNavigatorConfig(configNode, commentTracking) { }); } else { // Store other group-level props (screenOptions, screenLayout, etc.) - groupConfig.groupProps[configKey] = { - key: configKey, - value: groupConfigProp.value, - isStringLiteral: t.isStringLiteral(groupConfigProp.value), - }; + if (groupPropValue) { + groupConfig.groupProps[configKey] = { + key: configKey, + value: groupPropValue, + }; + } } }); @@ -1038,12 +1241,16 @@ function parseNavigatorConfig(configNode, commentTracking) { } }); // Parse top-level screens object (e.g., screens: { Home: HomeScreen, Profile: {...} }) - } else if (keyName === 'screens' && t.isObjectExpression(prop.value)) { + } else if ( + keyName === 'screens' && + t.isObjectProperty(prop) && + t.isObjectExpression(prop.value) + ) { // Parse screens object prop.value.properties.forEach((screenProp) => { if (!t.isObjectProperty(screenProp)) return; - const screenName = screenProp.key.name || screenProp.key.value; + const screenName = getPropertyKeyName(screenProp.key); const screenValue = screenProp.value; // Track comments on any property inside the screen config @@ -1061,11 +1268,12 @@ function parseNavigatorConfig(configNode, commentTracking) { } else { // Store all other navigator-level props (screenOptions, initialRouteName, etc.) // Keep track of whether the value is a string literal to determine JSX attribute format - result.navigatorProps[keyName] = { - key: keyName, - value: prop.value, - isStringLiteral: t.isStringLiteral(prop.value), - }; + if (propValue) { + result.navigatorProps[keyName] = { + key: keyName, + value: propValue, + }; + } } }); @@ -1075,7 +1283,11 @@ function parseNavigatorConfig(configNode, commentTracking) { /** * Create navigator component function */ -function createNavigatorComponent(functionName, componentName, config) { +function createNavigatorComponent( + functionName: string, + componentName: string, + config: ParsedNavigatorConfig +) { // Add all navigator-level props dynamically const navigatorProps = createJsxAttributesFromProps(config.navigatorProps); @@ -1161,14 +1373,33 @@ function createNavigatorComponent(functionName, componentName, config) { * Create a TabItem element with code block */ function createTabItem( - value, - label, - code, - codeNode, - originalCodeBlock, - cleanData, + value: string, + label: string, + code: string, + codeNode: Element, + originalCodeBlock: Element, + cleanData: Record, isDefault = false -) { +): Element { + const children: Element['children'] = [ + { type: 'text', value: '\n\n' }, + { + type: 'element', + tagName: 'pre', + properties: originalCodeBlock.properties || {}, + children: [ + { + type: 'element', + tagName: 'code', + properties: codeNode.properties || {}, + data: cleanData, + children: [{ type: 'text', value: code }], + }, + ], + }, + { type: 'text', value: '\n\n' }, + ]; + return { type: 'element', tagName: 'TabItem', @@ -1177,55 +1408,52 @@ function createTabItem( label, ...(isDefault && { default: true }), }, - children: [ - { type: 'text', value: '\n\n' }, - { - type: 'element', - tagName: 'pre', - properties: originalCodeBlock.properties || {}, - children: [ - { - type: 'element', - tagName: 'code', - properties: codeNode.properties || {}, - data: cleanData, - children: [{ type: 'text', value: code }], - }, - ], - }, - { type: 'text', value: '\n\n' }, - ], + children, }; } /** * Create a Tabs element with both static and dynamic TabItems */ -function createTabsWithBothConfigs(staticCode, dynamicCode, originalCodeBlock) { - const codeNode = originalCodeBlock.children[0]; +function createTabsWithBothConfigs( + staticCode: string, + dynamicCode: string, + originalCodeBlock: Element +): Element { + const codeNode = getFirstChildElement(originalCodeBlock); + + if (!codeNode) { + throw new Error( + 'rehype-static-to-dynamic: Expected code element in code block' + ); + } // Remove 'static2dynamic' from meta for the actual code blocks - const cleanMeta = - codeNode.data?.meta?.replace(/\bstatic2dynamic\b\s*/g, '').trim() || ''; - const cleanData = { ...codeNode.data, meta: cleanMeta }; + const rawMeta = getMeta(codeNode.data) ?? ''; + const cleanMeta = rawMeta.replace(/\bstatic2dynamic\b\s*/g, '').trim(); + const cleanData = { ...getDataObject(codeNode.data), meta: cleanMeta }; const tabItems = [ { value: 'static', label: 'Static', code: staticCode, isDefault: true }, { value: 'dynamic', label: 'Dynamic', code: dynamicCode, isDefault: false }, ]; - const children = tabItems.flatMap((item) => [ - { type: 'text', value: '\n' }, - createTabItem( - item.value, - item.label, - item.code, - codeNode, - originalCodeBlock, - cleanData, - item.isDefault - ), - ]); + const children: Element['children'] = []; + + tabItems.forEach((item) => { + children.push({ type: 'text', value: '\n' }); + children.push( + createTabItem( + item.value, + item.label, + item.code, + codeNode, + originalCodeBlock, + cleanData, + item.isDefault + ) + ); + }); children.push({ type: 'text', value: '\n' }); diff --git a/src/plugins/rehype-video-aspect-ratio.mjs b/src/plugins/rehype-video-aspect-ratio.ts similarity index 65% rename from src/plugins/rehype-video-aspect-ratio.mjs rename to src/plugins/rehype-video-aspect-ratio.ts index c694e554846..3177c615987 100644 --- a/src/plugins/rehype-video-aspect-ratio.mjs +++ b/src/plugins/rehype-video-aspect-ratio.ts @@ -4,20 +4,85 @@ import fs from 'fs'; import path from 'path'; import { visit } from 'unist-util-visit'; import { promisify } from 'util'; +import type { Node } from 'unist'; const execAsync = promisify(exec); +type MdxJsxAttribute = { + type: 'mdxJsxAttribute'; + name: string; + value?: string | MdxJsxAttributeValueExpression; +}; + +type MdxJsxAttributeValueExpression = { + type: 'mdxJsxAttributeValueExpression'; + data?: { + estree?: { + body?: Array<{ + expression?: { + properties?: unknown[]; + }; + }>; + }; + }; +}; + +type MdxJsxFlowElement = { + type: 'mdxJsxFlowElement'; + name?: string; + attributes?: MdxJsxAttribute[]; + children?: MdxJsxFlowElement[]; +}; + +type VFileLike = { + cwd: string; + dirname: string; +}; + +type VideoDimensions = { + width?: number; + height?: number; +}; + +type StyleEstreeData = { + estree: { + type: 'Program'; + body: Array<{ + type: 'ExpressionStatement'; + expression: { + type: 'ObjectExpression'; + properties: unknown[]; + }; + }>; + }; +}; + +function isAttributeValueExpression( + value: MdxJsxAttribute['value'] +): value is MdxJsxAttributeValueExpression { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type === 'mdxJsxAttributeValueExpression' + ); +} + /** * Rehype plugin to add aspect ratio preservation to video tags */ -export default function rehypeVideoAspectRatio({ staticDir }) { - return async (tree, file) => { - const promises = []; - - visit(tree, 'mdxJsxFlowElement', (node) => { +export default function rehypeVideoAspectRatio({ + staticDir, +}: { + staticDir: string; +}) { + return async (tree: Node, file: VFileLike) => { + const promises: Promise[] = []; + + visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => { if (node.name === 'video') { // Find video source - check src attribute or source children - let videoSrc = null; + let videoSrc: string | null = null; // Look for src in attributes if (node.attributes) { @@ -25,7 +90,7 @@ export default function rehypeVideoAspectRatio({ staticDir }) { (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src' ); - if (srcAttr) { + if (srcAttr && typeof srcAttr.value === 'string') { videoSrc = srcAttr.value; } } @@ -42,7 +107,7 @@ export default function rehypeVideoAspectRatio({ staticDir }) { (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src' ); - if (srcAttr) { + if (srcAttr && typeof srcAttr.value === 'string') { videoSrc = srcAttr.value; } } @@ -83,8 +148,12 @@ export default function rehypeVideoAspectRatio({ staticDir }) { /** * Apply aspect ratio styles to a video node */ -function applyAspectRatio(node, width, height) { - const data = { +function applyAspectRatio( + node: MdxJsxFlowElement, + width: number, + height: number +) { + const data: StyleEstreeData = { estree: { type: 'Program', body: [ @@ -112,11 +181,13 @@ function applyAspectRatio(node, width, height) { (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'style' ); - if (styleAttr) { + if (styleAttr && isAttributeValueExpression(styleAttr.value)) { const properties = - styleAttr.value?.data?.estree?.body?.[0]?.expression?.properties ?? []; + styleAttr.value.data?.estree?.body?.[0]?.expression?.properties; - data.estree.body[0].expression.properties.push(...properties); + if (Array.isArray(properties)) { + data.estree.body[0].expression.properties.push(...properties); + } } styleAttr = { @@ -142,13 +213,13 @@ function applyAspectRatio(node, width, height) { /** * Get video dimensions using ffprobe */ -async function getVideoDimensions(filePath) { +async function getVideoDimensions(filePath: string) { const { stdout } = await execAsync( `${ffprobe.path} -v error -of flat=s=_ -select_streams v:0 -show_entries stream=height,width "${filePath}"` ); const lines = stdout.trim().split('\n'); - const dimensions = {}; + const dimensions: VideoDimensions = {}; for (const line of lines) { if (line.includes('width')) { diff --git a/src/plugins/remark-npm2yarn.mjs b/src/plugins/remark-npm2yarn.ts similarity index 50% rename from src/plugins/remark-npm2yarn.mjs rename to src/plugins/remark-npm2yarn.ts index 4e05870edb0..1d4ae6e809d 100644 --- a/src/plugins/remark-npm2yarn.mjs +++ b/src/plugins/remark-npm2yarn.ts @@ -1,14 +1,67 @@ import npmToYarn from 'npm-to-yarn'; import { Parser } from 'acorn'; import acornJsx from 'acorn-jsx'; +import type { Node } from 'unist'; const parser = Parser.extend(acornJsx()); -function parseExpression(code) { +type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +type RemarkNpm2YarnOptions = { + sync?: boolean; + converters?: PackageManager[]; +}; + +type MdxJsxAttributeValueExpression = { + type: 'mdxJsxAttributeValueExpression'; + value: string; + data: { + estree: unknown; + }; +}; + +type MdxJsxAttribute = { + type: 'mdxJsxAttribute'; + name: string; + value?: string | MdxJsxAttributeValueExpression; +}; + +type MdxNode = Node & { + value?: string; + lang?: string; + meta?: string; + name?: string; + attributes?: MdxJsxAttribute[]; + children?: MdxNode[]; + data?: { + estree?: unknown; + }; +}; + +type CodeNode = MdxNode & { + type: 'code'; + value: string; +}; + +type CreateTabItemOptions = { + code: string; + node: MdxNode; + pm: PackageManager; +}; + +const defaultConverters: PackageManager[] = ['yarn', 'pnpm', 'bun']; + +function parseExpression(code: string): unknown { return parser.parse(code, { ecmaVersion: 'latest', sourceType: 'module' }); } -function createLabelWithIcon(pm) { +function isCodeNode(node: MdxNode): node is CodeNode { + return node.type === 'code' && typeof node.value === 'string'; +} + +function createLabelWithIcon( + pm: PackageManager +): MdxJsxAttributeValueExpression { const value = `<>${pm}`; return { @@ -18,7 +71,7 @@ function createLabelWithIcon(pm) { }; } -function createTabItem({ code, node, pm }) { +function createTabItem({ code, node, pm }: CreateTabItemOptions): MdxNode { return { type: 'mdxJsxFlowElement', name: 'TabItem', @@ -34,7 +87,11 @@ function createTabItem({ code, node, pm }) { }; } -function transformNode(node, sync, converters) { +function transformNode( + node: CodeNode, + sync: boolean, + converters: PackageManager[] +): MdxNode { const npmCode = node.value; return { @@ -52,7 +109,7 @@ function transformNode(node, sync, converters) { }; } -function createImportNode() { +function createImportNode(): MdxNode { const value = "import Tabs from '@theme/Tabs'\nimport TabItem from '@theme/TabItem'"; @@ -63,17 +120,21 @@ function createImportNode() { }; } -export default function remarkNpm2Yarn(options = {}) { - const { sync = false, converters = ['yarn', 'pnpm', 'bun'] } = options; +export default function remarkNpm2Yarn(options: RemarkNpm2YarnOptions = {}) { + const { sync = false, converters = defaultConverters } = options; - return async (root) => { + return async (root: MdxNode) => { const { visit } = await import('unist-util-visit'); let transformed = false; let alreadyImported = false; - visit(root, (node) => { - if (node.type === 'mdxjsEsm' && node.value.includes('@theme/Tabs')) { + visit(root, (node: MdxNode) => { + if ( + node.type === 'mdxjsEsm' && + typeof node.value === 'string' && + node.value.includes('@theme/Tabs') + ) { alreadyImported = true; } @@ -81,7 +142,7 @@ export default function remarkNpm2Yarn(options = {}) { let i = 0; while (i < node.children.length) { const child = node.children[i]; - if (child.type === 'code' && child.meta === 'npm2yarn') { + if (isCodeNode(child) && child.meta === 'npm2yarn') { node.children[i] = transformNode(child, sync, converters); transformed = true; } @@ -90,7 +151,7 @@ export default function remarkNpm2Yarn(options = {}) { } }); - if (transformed && !alreadyImported) { + if (transformed && !alreadyImported && Array.isArray(root.children)) { root.children.unshift(createImportNode()); } }; diff --git a/src/plugins/remark-raw-markdown.mjs b/src/plugins/remark-raw-markdown.mjs deleted file mode 100644 index 3f8d2cde6e3..00000000000 --- a/src/plugins/remark-raw-markdown.mjs +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-check - -/** @type {import('unified').Plugin} */ -const plugin = () => { - return (tree, file) => { - // Add raw markdown to frontMatter so it's accessible via useDoc() - file.data.frontMatter = file.data.frontMatter || {}; - // @ts-expect-error: we are adding a custom field - file.data.frontMatter.rawMarkdown = file.value; - }; -}; - -export default plugin; diff --git a/src/plugins/remark-raw-markdown.ts b/src/plugins/remark-raw-markdown.ts new file mode 100644 index 00000000000..4ae2ce34d4e --- /dev/null +++ b/src/plugins/remark-raw-markdown.ts @@ -0,0 +1,13 @@ +type RemarkFile = { + data: Record; + value: unknown; +}; + +const plugin = () => { + return (_tree: unknown, file: RemarkFile) => { + file.data.frontMatter = file.data.frontMatter || {}; + file.data.frontMatter.rawMarkdown = file.value; + }; +}; + +export default plugin;