diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..465cfc4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: Documentation + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:llm + - run: pnpm build diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 28917a3..b96722e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,7 +2,7 @@ ## Project Overview -The Nativewind documentation site serves both v4 (stable) and v5 (preview) docs side-by-side. Built with Next.js 15 and Fumadocs. +The Nativewind documentation site serves both v4.2.7 (stable) and v5 (release candidate) documentation. Built with Next.js 15 and Fumadocs. - **V4 docs** at `/docs/` — sourced from `content/docs/` - **V5 docs** at `/v5/` — sourced from `content/v5/` @@ -21,7 +21,8 @@ The Nativewind documentation site serves both v4 (stable) and v5 (preview) docs ```bash pnpm install # Install dependencies pnpm dev # Start dev server -pnpm build # Production build +pnpm test:llm # Verify all public Markdown exports and shared helpers +pnpm build # Production build, including text endpoints pnpm start # Start production server ``` @@ -72,8 +73,8 @@ app/ ├── docs/[[...slug]]/ # V4 doc pages ├── v5/[[...slug]]/ # V5 doc pages ├── llms.mdx/ # LLM-friendly markdown endpoints -├── llms.txt/ # Concatenated LLM text (v4) -├── v5/llms.txt/ # Concatenated LLM text (v5) +├── llms.txt/ # Documentation index (v4) +├── v5/llms.txt/ # Documentation index (v5) └── api/ # Search, OG image generation components/ @@ -89,7 +90,7 @@ content/ lib/ ├── source.ts # Fumadocs loaders (source for v4, source5 for v5) -└── get-llm-text.ts # Strips frontmatter, formats docs for LLM endpoints +└── get-llm-text.ts # Formats source Markdown for LLM endpoints ``` ## Key Config Files @@ -104,12 +105,20 @@ The site has built-in endpoints for serving docs as plain text for LLM consumpti | Endpoint | Description | |----------|-------------| -| `/llms.txt` | Concatenated v4 docs as plain text | -| `/v5/llms.txt` | Concatenated v5 docs as plain text | +| `/llms.txt` | V4 documentation index with Markdown page links | +| `/v5/llms.txt` | V5 documentation index with Markdown page links | | `/llms-full.txt` | Full v4 doc dump | | `/v5/llms-full.txt` | Full v5 doc dump | -| `/llms.mdx/docs/[path]` | Individual v4 doc as markdown | -| `/llms.mdx/v5/[path]` | Individual v5 doc as markdown | +| `/llms.mdx/docs/[path]` | Individual v4 page as Markdown; also `/docs/[path].mdx` | +| `/llms.mdx/v5/[path]` | Individual v5 page as Markdown; also `/v5/[path].mdx` | + +The indices, full exports and copy buttons all use the same documentation source. `lib/llm-markdown.ts` expands imported MDX partials and includes, renders tabs, callouts and tables as Markdown, preserves code fences and resolves page links. Files beginning with `_` are helpers and are not exported as standalone pages. + +`lib/doc-tables.ts` supplies install commands and compatibility rows to both the React components and the text exporter. Update those shared helpers when changing command generation or support labels. Installation and migration copy buttons fetch the page Markdown endpoint; do not reintroduce separate guide strings. + +The exporter supports the data expressions and components used by this repository. It deliberately fails on unknown components or JavaScript expressions instead of silently dropping guidance. When introducing a component, add its Markdown representation and a regression case to `tests/llm-markdown.test.ts`. Run `pnpm test:llm` and `pnpm build`; check rendered pages and copy buttons if UI components changed. Legacy `.md` files are included in those checks. + +Keep v4 and v5 installation advice separate. Use the pinned pair from the RC installation page for v5; do not copy current Tailwind CSS v4 links into the stable Tailwind CSS v3 docs. The application migration skills are maintained in [nativewind/nativewind](https://github.com/nativewind/nativewind/tree/main/skills), alongside release setup and measured compatibility records. ## Common Pitfalls diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index ffdea45..f61911e 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -1,11 +1,14 @@ import { source } from '@/lib/source'; -import { getLLMText } from '@/lib/get-llm-text'; +import { getLLMText, isPublicDoc } from '@/lib/get-llm-text'; export const revalidate = false; export async function GET() { - const scan = source.getPages().map(getLLMText); - const scanned = await Promise.all(scan); - - return new Response(scanned.join('\n\n')); + const pages = source.getPages().filter((page) => isPublicDoc(page.file.path)); + const texts = await Promise.all( + pages.map((page) => getLLMText(page, 'docs')), + ); + return new Response(texts.join('\n\n'), { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); } diff --git a/app/llms.mdx/docs/[[...slug]]/route.ts b/app/llms.mdx/docs/[[...slug]]/route.ts index ee9be80..d4d75cc 100644 --- a/app/llms.mdx/docs/[[...slug]]/route.ts +++ b/app/llms.mdx/docs/[[...slug]]/route.ts @@ -1,4 +1,4 @@ -import { getLLMText } from '@/lib/get-llm-text'; +import { getLLMText, isPublicDoc } from '@/lib/get-llm-text'; import { source } from '@/lib/source'; import { notFound } from 'next/navigation'; @@ -10,15 +10,15 @@ export async function GET( ) { const { slug } = await params; const page = source.getPage(slug); - if (!page) notFound(); - - return new Response(await getLLMText(page), { - headers: { - 'Content-Type': 'text/markdown', - }, + if (!page || !isPublicDoc(page.file.path)) notFound(); + return new Response(await getLLMText(page, 'docs'), { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, }); } export function generateStaticParams() { - return source.generateParams(); + return source + .getPages() + .filter((page) => isPublicDoc(page.file.path)) + .map((page) => ({ slug: page.slugs })); } diff --git a/app/llms.mdx/v5/[[...slug]]/route.ts b/app/llms.mdx/v5/[[...slug]]/route.ts index 2d940f6..baf9c44 100644 --- a/app/llms.mdx/v5/[[...slug]]/route.ts +++ b/app/llms.mdx/v5/[[...slug]]/route.ts @@ -1,36 +1,24 @@ +import { getLLMText, isPublicDoc } from '@/lib/get-llm-text'; import { source5 } from '@/lib/source'; import { notFound } from 'next/navigation'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; export const revalidate = false; -function stripFrontmatter(content: string): string { - const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); - return match ? content.slice(match[0].length).trim() : content.trim(); -} - export async function GET( _req: Request, { params }: { params: Promise<{ slug?: string[] }> }, ) { const { slug } = await params; const page = source5.getPage(slug); - if (!page) notFound(); - - const filePath = join(process.cwd(), 'content/v5', page.file.path); - const raw = await readFile(filePath, 'utf-8'); - const body = stripFrontmatter(raw); - - const text = `# ${page.data.title} (${page.url})\n\n${page.data.description ? `${page.data.description}\n\n` : ''}${body}`; - - return new Response(text, { - headers: { - 'Content-Type': 'text/markdown', - }, + if (!page || !isPublicDoc(page.file.path)) notFound(); + return new Response(await getLLMText(page, 'v5'), { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, }); } export function generateStaticParams() { - return source5.generateParams(); + return source5 + .getPages() + .filter((page) => isPublicDoc(page.file.path)) + .map((page) => ({ slug: page.slugs })); } diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 160114b..20620f9 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -1,79 +1,10 @@ import { source } from '@/lib/source'; +import { getLLMIndex } from '@/lib/llm-index'; export const revalidate = false; -const BASE_URL = 'https://nativewind.dev'; - -interface Section { - title: string; - pages: { title: string; url: string; description?: string }[]; -} - export async function GET() { - const pages = source.getPages(); - - const sectionMap: Record = {}; - const sectionOrder = [ - { prefix: '', title: 'Overview' }, - { prefix: 'getting-started', title: 'Getting Started' }, - { prefix: 'guides', title: 'Guides' }, - { prefix: 'core-concepts', title: 'Core Concepts' }, - { prefix: 'customization', title: 'Customization' }, - { prefix: 'api', title: 'API' }, - { prefix: 'tailwind', title: 'Tailwind CSS Utilities' }, - ]; - - for (const section of sectionOrder) { - sectionMap[section.prefix] = { title: section.title, pages: [] }; - } - - for (const page of pages) { - const slugs = page.slugs; - const firstSegment = slugs[0] ?? ''; - - let sectionKey: string; - if (slugs.length === 0) { - sectionKey = ''; - } else if (firstSegment in sectionMap) { - sectionKey = firstSegment; - } else { - sectionKey = 'tailwind'; - } - - sectionMap[sectionKey]?.pages.push({ - title: page.data.title, - url: `${BASE_URL}${page.url}`, - description: page.data.description, - }); - } - - const lines: string[] = [ - '# NativeWind v4', - '', - '> NativeWind uses Tailwind CSS as a scripting language to create a universal style system for React Native. It compiles Tailwind CSS styles into native StyleSheet objects at build time while providing an efficient runtime for conditional styles like hover, focus, media queries, and container queries.', - '', - ]; - - for (const section of sectionOrder) { - const data = sectionMap[section.prefix]; - if (!data || data.pages.length === 0) continue; - - lines.push(`## ${data.title}`, ''); - for (const page of data.pages) { - const desc = page.description ? `: ${page.description}` : ''; - lines.push(`- [${page.title}](${page.url})${desc}`); - } - lines.push(''); - } - - lines.push( - '## Optional', - '', - `- [Full documentation](${BASE_URL}/llms-full.txt): Complete NativeWind v4 documentation in a single file for LLM consumption`, - '', - ); - - return new Response(lines.join('\n'), { + return new Response(getLLMIndex(source.getPages(), 'docs'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } diff --git a/app/v5/llms-full.txt/route.ts b/app/v5/llms-full.txt/route.ts index 183527d..4b8d47b 100644 --- a/app/v5/llms-full.txt/route.ts +++ b/app/v5/llms-full.txt/route.ts @@ -1,27 +1,13 @@ import { source5 } from '@/lib/source'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { getLLMText, isPublicDoc } from '@/lib/get-llm-text'; export const revalidate = false; -function stripFrontmatter(content: string): string { - const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); - return match ? content.slice(match[0].length).trim() : content.trim(); -} - export async function GET() { - const pages = source5.getPages(); - - const texts = await Promise.all( - pages.map(async (page) => { - const filePath = join(process.cwd(), 'content/v5', page.file.path); - const raw = await readFile(filePath, 'utf-8'); - const body = stripFrontmatter(raw); - - return `# ${page.data.title} (${page.url})\n\n${page.data.description ? `${page.data.description}\n\n` : ''}${body}`; - }), - ); - + const pages = source5 + .getPages() + .filter((page) => isPublicDoc(page.file.path)); + const texts = await Promise.all(pages.map((page) => getLLMText(page, 'v5'))); return new Response(texts.join('\n\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); diff --git a/app/v5/llms.txt/route.ts b/app/v5/llms.txt/route.ts index 5823c66..d77f650 100644 --- a/app/v5/llms.txt/route.ts +++ b/app/v5/llms.txt/route.ts @@ -1,81 +1,10 @@ import { source5 } from '@/lib/source'; +import { getLLMIndex } from '@/lib/llm-index'; export const revalidate = false; -const BASE_URL = 'https://nativewind.dev'; - -interface Section { - title: string; - pages: { title: string; url: string; description?: string }[]; -} - export async function GET() { - const pages = source5.getPages(); - - const sectionMap: Record = {}; - const sectionOrder = [ - { prefix: '', title: 'Overview' }, - { prefix: 'getting-started', title: 'Getting Started' }, - { prefix: 'guides', title: 'Guides' }, - { prefix: 'core-concepts', title: 'Core Concepts' }, - { prefix: 'customization', title: 'Customization' }, - { prefix: 'api', title: 'API' }, - { prefix: 'tailwind', title: 'Tailwind CSS Utilities' }, - ]; - - for (const section of sectionOrder) { - sectionMap[section.prefix] = { title: section.title, pages: [] }; - } - - for (const page of pages) { - const slugs = page.slugs; - const firstSegment = slugs[0] ?? ''; - - let sectionKey: string; - if (slugs.length === 0) { - sectionKey = ''; - } else if (firstSegment in sectionMap) { - sectionKey = firstSegment; - } else { - sectionKey = 'tailwind'; - } - - sectionMap[sectionKey]?.pages.push({ - title: page.data.title, - url: `${BASE_URL}${page.url}`, - description: page.data.description, - }); - } - - const lines: string[] = [ - '# Nativewind v5 RC', - '', - 'Target: nativewind@5.0.0-rc.0 and react-native-css@3.1.0-rc.0 on the tested Expo 57 toolchain. V4.2.7 remains the stable release.', - '', - '> Nativewind v5 uses Tailwind CSS v4 as a scripting language to create a universal style system for React Native. Built on top of react-native-css, it compiles Tailwind CSS styles into native StyleSheet objects at build time while providing an efficient runtime for conditional styles like hover, focus, media queries, and container queries.', - '', - ]; - - for (const section of sectionOrder) { - const data = sectionMap[section.prefix]; - if (!data || data.pages.length === 0) continue; - - lines.push(`## ${data.title}`, ''); - for (const page of data.pages) { - const desc = page.description ? `: ${page.description}` : ''; - lines.push(`- [${page.title}](${page.url})${desc}`); - } - lines.push(''); - } - - lines.push( - '## Optional', - '', - `- [Full documentation](${BASE_URL}/v5/llms-full.txt): Complete Nativewind v5 documentation in a single file for LLM consumption`, - '', - ); - - return new Response(lines.join('\n'), { + return new Response(getLLMIndex(source5.getPages(), 'v5'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } diff --git a/components/compatibility-table.tsx b/components/compatibility-table.tsx new file mode 100644 index 0000000..5429e48 --- /dev/null +++ b/components/compatibility-table.tsx @@ -0,0 +1,28 @@ +import { compatibilityRows, type CompatibilityOptions } from '@/lib/doc-tables'; + +export function CompatibilityTable(props: CompatibilityOptions) { + const rows = compatibilityRows(props); + const comments = rows.some((row) => row.comment); + return ( + + + + + + {comments && } + + {rows.map((row, index) => ( + + + + {comments && } + + ))} + +
ClassSupportComments
+
+                {row.value}
+              
+
{row.label}{row.comment}
+ ); +} diff --git a/components/copy-installation-button.tsx b/components/copy-installation-button.tsx index 9bf5d1b..61bcf5e 100644 --- a/components/copy-installation-button.tsx +++ b/components/copy-installation-button.tsx @@ -1,78 +1,11 @@ -'use client'; -import { Copy } from "lucide-react"; -import { useState } from "react"; -import { cn } from "@/lib/cn"; - -// Full markdown content for direct copying -const INSTALLATION_GUIDE_CONTENT = `# Nativewind v5 RC0 installation - -Use an Expo 57 app. Tested versions: Expo 57.0.22, React Native 0.86.3, React 19.2.3, Reanimated 4.5.1 and Worklets 0.10.1. - -\`\`\`sh -npm install --save-exact nativewind@5.0.0-rc.0 react-native-css@3.1.0-rc.0 -npm install --save-dev --save-exact tailwindcss@4.1.12 @tailwindcss/postcss@4.1.12 postcss lightningcss@1.30.1 -npx expo install react-native-reanimated react-native-worklets react-native-safe-area-context expo-system-ui -\`\`\` - -Use the project's existing package manager. Keep both RC packages pinned together. - -Create postcss.config.mjs (Expo 57 does not discover postcss.config.cjs): -\`\`\`js -export default { plugins: { "@tailwindcss/postcss": {} } }; -\`\`\` - -Create global.css: -\`\`\`css -@import "tailwindcss/theme.css" layer(theme); -@import "tailwindcss/preflight.css" layer(base); -@import "tailwindcss/utilities.css"; -@import "nativewind/theme"; -\`\`\` - -Wrap the existing Metro configuration, preserving custom settings: -\`\`\`js -const { getDefaultConfig } = require("expo/metro-config"); -const { withNativewind } = require("nativewind/metro"); -module.exports = withNativewind(getDefaultConfig(__dirname)); -\`\`\` - -Import global.css once from App.tsx or app/_layout.tsx. Keep babel-preset-expo. Remove the v4 Nativewind Babel preset and JSX import source settings if present. Set expo.userInterfaceStyle to automatic in app.json for system theme changes. - -Pin lightningcss to 1.30.1 in package manager overrides or resolutions, following the installation guide. Ensure the generated nativewind-env.d.ts belongs to the TypeScript project. Restart Metro and rebuild when native dependencies change. Verify colors, layout, input, themes and navigation on each supported platform; a successful bundle alone does not prove rendering. - -Full installation guide and package manager examples: https://www.nativewind.dev/v5/getting-started/installation -Existing v4 apps: https://www.nativewind.dev/v5/guides/migrate-from-v4 -Previous v5 preview: https://www.nativewind.dev/v5/guides/migrate-from-preview -`; - -interface CopyInstallationButtonProps { - className?: string; -} - -export function CopyInstallationButton({ className = "" }: CopyInstallationButtonProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(INSTALLATION_GUIDE_CONTENT); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; +import { CopyMarkdownButton } from './copy-markdown-button'; +export function CopyInstallationButton({ className }: { className?: string }) { return ( - + ); } - diff --git a/components/copy-markdown-button.tsx b/components/copy-markdown-button.tsx index 53e8d7f..f5f4be3 100644 --- a/components/copy-markdown-button.tsx +++ b/components/copy-markdown-button.tsx @@ -6,9 +6,14 @@ import { useState, useCallback } from 'react'; interface CopyMarkdownButtonProps { markdownUrl: string; className?: string; + label?: string; } -export function CopyMarkdownButton({ markdownUrl, className = '' }: CopyMarkdownButtonProps) { +export function CopyMarkdownButton({ + markdownUrl, + className = '', + label = 'Copy', +}: CopyMarkdownButtonProps) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -18,7 +23,8 @@ export function CopyMarkdownButton({ markdownUrl, className = '' }: CopyMarkdown // the user gesture handler. Using ClipboardItem with a Promise allows us // to call clipboard.write() immediately while fetching the data async. const textPromise = fetch(markdownUrl).then(async (res) => { - if (!res.ok) throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`); + if (!res.ok) + throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`); const text = await res.text(); return new Blob([text], { type: 'text/plain' }); }); @@ -28,7 +34,8 @@ export function CopyMarkdownButton({ markdownUrl, className = '' }: CopyMarkdown } else { // Fallback for browsers that don't support ClipboardItem const res = await fetch(markdownUrl); - if (!res.ok) throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`); + if (!res.ok) + throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`); const text = await res.text(); await navigator.clipboard.writeText(text); } @@ -52,7 +59,7 @@ export function CopyMarkdownButton({ markdownUrl, className = '' }: CopyMarkdown ) : ( <> - Copy + {label} )} diff --git a/components/copy-migration-button.tsx b/components/copy-migration-button.tsx index 35ad32a..1464463 100644 --- a/components/copy-migration-button.tsx +++ b/components/copy-migration-button.tsx @@ -1,141 +1,11 @@ -'use client'; -import { Copy } from "lucide-react"; -import { useState } from "react"; -import { cn } from "@/lib/cn"; - -// Full markdown content for direct copying -const MIGRATION_GUIDE_CONTENT = `# Migrate Nativewind v4 to v5 RC0 - -## Choose the migration path - -This guide targets Nativewind 5.0.0-rc.0 and react-native-css 3.1.0-rc.0 from Nativewind v4, including v4.2.7. V4 remains stable and uses Tailwind CSS 3. If you are already on v5 preview.4, follow [Upgrade a v5 preview](https://www.nativewind.dev/v5/guides/migrate-from-preview). - -The RC was tested with Expo 57.0.22, React Native 0.86.3, React 19.2.3, Reanimated 4.5.1 and Worklets 0.10.1. Upgrade and verify an older Expo SDK separately. Do not force native dependency versions just to satisfy a styling migration. - -NativewindUI v4 components should remain on Nativewind v4. Use their supported v4 setup instead of partially converting them to v5. - -## Use the migration skill - -\`\`\`bash -npx skills add nativewind/nativewind --skill nativewind-v4-to-v5 -\`\`\` - -Ask your agent to apply the skill to your app. It includes a read only preflight, checks for custom configuration and component mappings, and recovery instructions. Review the [skill and measured verification scope](https://github.com/nativewind/nativewind/tree/main/skills/nativewind-v4-to-v5). Fixture results do not establish that every application is verified. - -## 1. Preserve a working baseline - -Work on a branch. Preserve your source, package manifests, lockfile and configuration, including any uncommitted work. Record the package manager and exact starting versions. Capture representative screens and interactions before editing, including themes, animations, navigation and third party components. - -## 2. Update the dependency group together - -Update Nativewind, its engine and Tailwind/PostCSS together in your manifest before installing with your existing package manager: - -\`\`\`json -{ - "dependencies": { - "nativewind": "5.0.0-rc.0", - "react-native-css": "3.1.0-rc.0" - }, - "devDependencies": { - "tailwindcss": "4.1.12", - "@tailwindcss/postcss": "4.1.12", - "lightningcss": "1.30.1" - } -} -\`\`\` - -Merge these entries into your existing manifest; do not replace it. Install PostCSS as shown in the [installation guide](https://www.nativewind.dev/v5/getting-started/installation), and let \`npx expo install\` align Reanimated, Worklets, safe area context and expo-system-ui with your SDK. - -Nativewind RC0 requires the exact engine candidate above. The packages cannot be upgraded independently for this release. Remove direct \`react-native-css-interop\` dependencies only after checking that no application or workspace consumer still needs them. - -If npm reports \`ERESOLVE\` from the stale v4/Tailwind 3 graph, do not use \`--force\` or \`--legacy-peer-deps\`. Follow the [tested recovery procedure](https://github.com/nativewind/nativewind/blob/main/skills/nativewind-v4-to-v5/references/installation.md), which saves the intended manifest and lets npm update the styling dependency group without deleting the lockfile. Workspaces require a consumer inventory first. - -## 3. Convert Tailwind configuration - -Translate custom theme values, plugins and utilities to Tailwind 4 CSS configuration. Preserve any customizations that do not have a verified replacement. Review the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide); utility renames alone do not prove the same native appearance. - -Replace the Tailwind 3 directives in your root CSS: - -\`\`\`css title="global.css" -@import "tailwindcss/theme.css" layer(theme); -@import "tailwindcss/preflight.css" layer(base); -@import "tailwindcss/utilities.css"; -@import "nativewind/theme"; -\`\`\` - -Keep utilities unlayered so React Native Web defaults do not override them. Add \`@source\` paths for shared workspace components when needed. - -Create or update \`postcss.config.mjs\`, preserving unrelated plugins: - -\`\`\`js title="postcss.config.mjs" -export default { plugins: { "@tailwindcss/postcss": {} } }; -\`\`\` - -Expo 57 does not discover \`postcss.config.cjs\`. Follow the installation guide to pin lightningcss 1.30.1 through your package manager's overrides or resolutions. - -## 4. Update Babel, Metro and TypeScript - -Remove \`nativewind/babel\` and the Nativewind \`jsxImportSource\` setting from the v4 setup. Keep \`babel-preset-expo\` and unrelated plugins. - -\`\`\`js title="metro.config.js" -const { getDefaultConfig } = require("expo/metro-config"); -const { withNativewind } = require("nativewind/metro"); - -module.exports = withNativewind(getDefaultConfig(__dirname)); -\`\`\` - -Wrap your existing Metro config rather than discarding custom resolvers. The v4 \`input\` option is no longer needed. Import the root CSS once in \`App.tsx\` or \`app/_layout.tsx\`. Ensure the generated \`nativewind-env.d.ts\` belongs to your TypeScript project. - -## 5. Review application contracts - -Use [styled](https://www.nativewind.dev/v5/api/styled) for components that need explicit mapping. The RC does not export \`cssInterop\` or \`remapProps\` from Nativewind. Do not mechanically rename those functions: \`styled\` returns a wrapper that must be used, and prop destinations and precedence need to be checked. There is no \`global\` option. \`nativeStyleMapping\` is the current mapping option; \`nativeStyleToProp\` remains a deprecated alias. - -For native theme overrides, use \`Appearance.setColorScheme("dark")\` or \`"light"\`. On the Expo 57 target, \`"unspecified"\` restores system appearance. Set \`expo.userInterfaceStyle\` to \`"automatic"\`. Preserve any custom browser theme selection separately. See [Dark Mode](https://www.nativewind.dev/v5/core-concepts/dark-mode). - -Prefer [VariableContextProvider](https://www.nativewind.dev/v5/guides/themes) for runtime variables and exclude variables that must remain dynamic from compiler inlining. Cross platform lengths need units, for example \`"80px"\`. \`vars()\`, the Nativewind color scheme hook and \`useUnstableNativeVariable\` remain available; deprecated does not mean removed. - -Remove legacy \`@cssInterop\` or \`@react-native\` configuration after translating its intent. Qualified native root selectors such as \`:root.dark\` are rejected; use media queries and Appearance on native. - -Props such as \`placeholderClassName\`, \`indicatorClassName\`, \`presentationClassName\`, \`cssInterop\` and StatusBar \`className\` no longer have declarations for unsupported v5 mappings. Use native props or explicit supported wrappers. TextInput supports \`className="placeholder:text-gray-500"\`. - -Shadows now use React Native \`boxShadow\`, and animations use Reanimated CSS animations. Compare the affected screens with your baseline. See the [RC compatibility guide](https://github.com/nativewind/nativewind/blob/main/docs/rc-compatibility.md) for value and platform limits, including the known Android animation cancellation issue. - -## 6. Verify and recover - -Restart Metro with \`npx expo start --clear\`. Rebuild native apps when native dependencies or configuration change. Run typechecks and production bundles, then compare actual rendering and interactions on every supported platform. Include theme override and system restoration, mappings, text entry, navigation and animations. - -A successful export does not prove visual parity. Report unavailable or failing runtime checks as verification pending. A second migration pass should make no new source or dependency changes. - -To revert, restore only this migration's changes from your source, manifest, lockfile and configuration snapshots. Reinstall with the same package manager and rebuild if native dependencies changed. Preserve unrelated user work.`; - -interface CopyMigrationButtonProps { - className?: string; -} - -export function CopyMigrationButton({ className = "" }: CopyMigrationButtonProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(MIGRATION_GUIDE_CONTENT); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; +import { CopyMarkdownButton } from './copy-markdown-button'; +export function CopyMigrationButton({ className }: { className?: string }) { return ( - + ); } - diff --git a/components/package-install.tsx b/components/package-install.tsx new file mode 100644 index 0000000..eae9b2f --- /dev/null +++ b/components/package-install.tsx @@ -0,0 +1,21 @@ +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; +import { installCommands, type InstallOptions } from '@/lib/doc-tables'; + +export function PackageInstall(props: InstallOptions) { + const commands = installCommands(props); + return ( + item.manager)} + > + {commands.map(({ manager, command }) => ( + + +
{command}
+
+
+ ))} +
+ ); +} diff --git a/content/docs/api/with-nativewind.mdx b/content/docs/api/with-nativewind.mdx index 538cfe8..33c3c52 100644 --- a/content/docs/api/with-nativewind.mdx +++ b/content/docs/api/with-nativewind.mdx @@ -4,12 +4,12 @@ title: withNativeWind {/* # withNativeWind */} -`withNativeWind` is a higher order component that updates your Metro configuration to support NativeWind. +`withNativeWind` is a Metro configuration wrapper that updates your Metro configuration to support Nativewind. The only required option is `input`, which is the relative path to your `.css` file. ```tsx title=metro.config.js -import { withNativeWind } from "native-wind/metro"; +import { withNativeWind } from "nativewind/metro"; module.exports = withNativeWind(config, { input: "", diff --git a/content/docs/getting-started/installation/_npm.mdx b/content/docs/getting-started/installation/_npm.mdx index 5f76cb1..0f4a081 100644 --- a/content/docs/getting-started/installation/_npm.mdx +++ b/content/docs/getting-started/installation/_npm.mdx @@ -2,80 +2,6 @@ title: _npm helper --- -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; +import { PackageInstall } from "@/components/package-install"; - - - -
-        {[
-          props.deps?.length ? `npm install ${props.deps.join(" ")}` : undefined,
-          props.devDeps?.length
-            ? `npm install --save-dev ${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length ? `yarn add ${props.deps.join(" ")}` : undefined,
-          props.devDeps?.length
-            ? `yarn add --dev ${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length ? `pnpm add ${props.deps.join(" ")}` : undefined,
-          props.devDeps?.length
-            ? `pnpm add --save-dev ${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length ? `bun install ${props.deps.join(" ")}` : undefined,
-          props.devDeps?.length
-            ? `bun install --dev ${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- {props.framework === 'expo' && ( - - -
-          {[
-            props.deps?.length ? `npx expo install ${props.deps.join(" ")}` : undefined,
-            props.devDeps?.length
-              ? `npx expo install --dev ${props.devDeps.join(" ")}`
-              : undefined,
-          ]
-            .filter(Boolean)
-            .join("\n")}
-        
-
-
- )} -
+ diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 4e987c8..927a9eb 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -1,5 +1,6 @@ --- title: Overview +description: Nativewind 4.2.7 is the stable release and uses Tailwind CSS v3. --- {/* # Overview */} @@ -18,13 +19,13 @@ On web, Nativewind is a small polyfill for adding `className` support to React N 🖥️ **DevUX** Plugins for simple setup and improving intellisense support -✨ **Media & Container queries** Use modern mobile styling features like media and container queries [(docs)](../docs/core-concepts/responsive-design) +✨ **Media & Container queries** Use modern mobile styling features like media and container queries [(docs)](/docs/core-concepts/responsive-design) 👪 **Custom values (CSS Variables)** Create themes, sub-themes and dynamic styles using custom values -✨ **Pseudo classes** hover / focus / active on compatible components [(docs)](../core-concepts/states#hover-focus-and-active) +✨ **Pseudo classes** hover / focus / active on compatible components [(docs)](/docs/core-concepts/states#hover-focus-and-active) -👪 **Parent state styles** automatically style children based upon parent pseudo classes [(docs)](../docs/core-concepts/states#styling-based-on-parent-state) +👪 **Parent state styles** automatically style children based upon parent pseudo classes [(docs)](/docs/core-concepts/states#styling-based-on-parent-state) 🔥 **Lots of other features** diff --git a/content/docs/tailwind/_compatibility-with-comments.mdx b/content/docs/tailwind/_compatibility-with-comments.mdx index a6bb5c8..94c9ce6 100644 --- a/content/docs/tailwind/_compatibility-with-comments.mdx +++ b/content/docs/tailwind/_compatibility-with-comments.mdx @@ -2,71 +2,10 @@ title: Compatibility with Comments --- +import { CompatibilityTable } from "@/components/compatibility-table"; + import Legend from "./_legend.mdx"; - - - - - - - - {(props.supported || []).map((value, index) => ( - - - - - - ))} - {(props.experimental || []).map((value, index) => ( - - - - - - ))} - {(props.native || []).map((value, index) => ( - - - - - - ))} - {(props.partial || []).map((value, index) => ( - - - - - - ))} - {(props.none || []).map((value, index) => ( - - - - - - ))} - -
ClassSupportComments
-
-            {value[0]}
-          
-
✅ Full Support{value[1]}
-
-            {value[0]}
-          
-
🧪 Experimental Support{value[1]}
-
-            {value[0]}
-          
-
📱 Native only{value[1]}
-
-            {value[0]}
-          
-
✔️ Partial Support{value[1]}
-
-            {value[0]}
-          
-
🌐 Web only{value[1]}
+ <>{props.legend || props.legend === undefined ? : <>} \ No newline at end of file diff --git a/content/docs/tailwind/_compatibility.mdx b/content/docs/tailwind/_compatibility.mdx index 4dd267a..0168beb 100644 --- a/content/docs/tailwind/_compatibility.mdx +++ b/content/docs/tailwind/_compatibility.mdx @@ -2,65 +2,10 @@ title: _compatibility.mdx --- +import { CompatibilityTable } from "@/components/compatibility-table"; + import Legend from "./_legend.mdx"; - - - - - - - {(props.supported || []).map((value, index) => ( - - - - - ))} - {(props.experimental || []).map((value, index) => ( - - - - - ))} - {(props.native || []).map((value, index) => ( - - - - - ))} - {(props.partial || []).map((value, index) => ( - - - - - ))} - {(props.none || []).map((value, index) => ( - - - - - ))} - -
ClassSupport
-
-            {value}
-          
-
✅ Full Support
-
-            {value}
-          
-
🧪 Experimental Support
-
-            {value}
-          
-
📱 Native only
-
-            {value}
-          
-
✔️ Partial Support
-
-            {value}
-          
-
🌐 Web only
+ <>{props.legend || props.legend === undefined ? : <>} \ No newline at end of file diff --git a/content/docs/tailwind/_legend.mdx b/content/docs/tailwind/_legend.mdx index 149001c..a7550be 100644 --- a/content/docs/tailwind/_legend.mdx +++ b/content/docs/tailwind/_legend.mdx @@ -13,7 +13,7 @@ title: _legend.mdx ### Icon -✅ Full support +✅ Supported ✔️ Partial support on native diff --git a/content/docs/tailwind/_usage.tsx b/content/docs/tailwind/_usage.tsx index c3f4cf1..31a3678 100644 --- a/content/docs/tailwind/_usage.tsx +++ b/content/docs/tailwind/_usage.tsx @@ -1,10 +1,19 @@ -"use client" -import { usePathname } from "next/navigation" +'use client'; +import { usePathname } from 'next/navigation'; +import { tailwindDocsUrl } from '@/lib/doc-tables'; export default function Usage(props: { href: string }) { - const slugs = usePathname().split('/').filter((slug) => slug !== '') - const slug = slugs[slugs.length - 1] + const slugs = usePathname() + .split('/') + .filter((slug) => slug !== ''); + const slug = slugs[slugs.length - 1]; return ( -

Please refer to the documentation on the Tailwind CSS website

- ) -} \ No newline at end of file +

+ Please refer to the{' '} + + {' '} + documentation on the Tailwind CSS website{' '} + +

+ ); +} diff --git a/content/v5/getting-started/installation/_npm.mdx b/content/v5/getting-started/installation/_npm.mdx index 4a47339..a1e518c 100644 --- a/content/v5/getting-started/installation/_npm.mdx +++ b/content/v5/getting-started/installation/_npm.mdx @@ -2,97 +2,6 @@ title: _npm helper --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs"; -import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock"; +import { PackageInstall } from "@/components/package-install"; - - {props.expo !== false ? ( - - -
-          {[
-            props.deps?.length
-              ? `npx expo install ${props.deps.join(" ")}`
-              : undefined,
-            props.devDeps?.length
-              ? `npx expo install --dev ${props.devDeps.join(" ")}`
-              : undefined,
-          ]
-            .filter(Boolean)
-            .join("\n")}
-        
-
-
- ) : ( - <> - )} - - -
-        {[
-          props.deps?.length
-            ? `npm install ${props.exact ? "--save-exact " : ""}${props.deps.join(" ")}`
-            : undefined,
-          props.devDeps?.length
-            ? `npm install --save-dev ${props.exact ? "--save-exact " : ""}${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length ? `yarn add ${props.exact ? "--exact " : ""}${props.deps.join(" ")}` : undefined,
-          props.devDeps?.length
-            ? `yarn add --dev ${props.exact ? "--exact " : ""}${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length
-            ? `pnpm add ${props.exact ? "--save-exact " : ""}${props.deps.join(" ")}`
-            : undefined,
-          props.devDeps?.length
-            ? `pnpm add --save-dev ${props.exact ? "--save-exact " : ""}${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
- - -
-        {[
-          props.deps?.length
-            ? `bun add ${props.exact ? "--exact " : ""}${props.deps.join(" ")}`
-            : undefined,
-          props.devDeps?.length
-            ? `bun add --dev ${props.exact ? "--exact " : ""}${props.devDeps.join(" ")}`
-            : undefined,
-        ]
-          .filter(Boolean)
-          .join("\n")}
-      
-
-
-
+ diff --git a/content/v5/index.mdx b/content/v5/index.mdx index 307ab80..8634a02 100644 --- a/content/v5/index.mdx +++ b/content/v5/index.mdx @@ -1,5 +1,6 @@ --- title: Overview +description: Nativewind v5 is a release candidate using Tailwind CSS v4 and react-native-css. --- {/* # Overview */} diff --git a/content/v5/tailwind/_compatibility.mdx b/content/v5/tailwind/_compatibility.mdx index b6d365d..a1130f2 100644 --- a/content/v5/tailwind/_compatibility.mdx +++ b/content/v5/tailwind/_compatibility.mdx @@ -2,73 +2,12 @@ title: _compatibility.mdx --- +import { CompatibilityTable } from "@/components/compatibility-table"; + import Legend from "./_legend.mdx";

Support applies to the documented values and platforms. See the RC compatibility notes for value limits and native migration alternatives. Browser behavior is evaluated separately.

- - - - - - - {(props.supported || []).map((value, index) => ( - - - - - ))} - {(props.experimental || []).map((value, index) => ( - - - - - ))} - {(props.native || []).map((value, index) => ( - - - - - ))} - {(props.partial || []).map((value, index) => ( - - - - - ))} - {(props.rejected || []).map((value, index) => ( - - - - - ))} - {(props.none || []).map((value, index) => ( - - - - - ))} - -
ClassSupport
-
-            {value}
-          
-
✅ Supported
-
-            {value}
-          
-
🧪 Experimental Support
-
-            {value}
-          
-
📱 Native only
-
-            {value}
-          
-
✔️ Partial Support
{value}
Not supported on native
-
-            {value}
-          
-
🌐 Web only
+ <>{props.legend || props.legend === undefined ? : <>} diff --git a/content/v5/tailwind/_legend.mdx b/content/v5/tailwind/_legend.mdx index 8a56f17..fafe576 100644 --- a/content/v5/tailwind/_legend.mdx +++ b/content/v5/tailwind/_legend.mdx @@ -13,7 +13,7 @@ title: _legend.mdx ### Icon -✅ Full support +✅ Supported ✔️ Partial support on native @@ -21,6 +21,8 @@ title: _legend.mdx 📱 Native only +Not supported on native: rejected by the native CSS engine + 🌐 Web only diff --git a/content/v5/tailwind/_usage.tsx b/content/v5/tailwind/_usage.tsx index ecc7fb9..0c58689 100644 --- a/content/v5/tailwind/_usage.tsx +++ b/content/v5/tailwind/_usage.tsx @@ -1,10 +1,19 @@ -"use client" -import { usePathname } from "next/navigation" +'use client'; +import { usePathname } from 'next/navigation'; +import { tailwindDocsUrl } from '@/lib/doc-tables'; export default function Usage(props: { href: string }) { - const slugs = usePathname().split('/').filter((slug) => slug !== '') - const slug = slugs[slugs.length - 1] + const slugs = usePathname() + .split('/') + .filter((slug) => slug !== ''); + const slug = slugs[slugs.length - 1]; return ( -

Please refer to the documentation on the Tailwind CSS website

- ) +

+ Please refer to the{' '} + + {' '} + documentation on the Tailwind CSS website{' '} + +

+ ); } diff --git a/lib/doc-tables.ts b/lib/doc-tables.ts new file mode 100644 index 0000000..566c46f --- /dev/null +++ b/lib/doc-tables.ts @@ -0,0 +1,80 @@ +export type InstallOptions = { + deps?: string[]; + devDeps?: string[]; + exact?: boolean; + expo?: boolean; + framework?: string; + version?: 'docs' | 'v5'; +}; + +// Both the page component and the text exporter use these commands. +export function installCommands(props: InstallOptions) { + const managers = + props.version === 'docs' + ? [ + 'npm', + 'yarn', + 'pnpm', + 'bun', + ...(props.framework === 'expo' ? ['expo'] : []), + ] + : [ + ...(props.expo !== false ? ['expo'] : []), + 'npm', + 'yarn', + 'pnpm', + 'bun', + ]; + return managers.map((manager) => { + const base = { + npm: 'npm install', + yarn: 'yarn add', + pnpm: 'pnpm add', + bun: 'bun add', + expo: 'npx expo install', + }[manager]; + const exact = props.exact + ? manager === 'npm' || manager === 'pnpm' + ? ' --save-exact' + : ' --exact' + : ''; + const dev = + manager === 'npm' || manager === 'pnpm' ? ' --save-dev' : ' --dev'; + return { + manager, + command: [ + props.deps?.length ? `${base}${exact} ${props.deps.join(' ')}` : '', + props.devDeps?.length + ? `${base}${dev}${exact} ${props.devDeps.join(' ')}` + : '', + ] + .filter(Boolean) + .join('\n'), + }; + }); +} + +export const supportLabels = { + supported: '✅ Supported', + experimental: '🧪 Experimental Support', + native: '📱 Native only', + partial: '✔️ Partial Support', + rejected: 'Not supported on native', + none: '🌐 Web only', +}; +export type CompatibilityOptions = Partial< + Record +>; +export function compatibilityRows(props: CompatibilityOptions) { + return Object.entries(supportLabels).flatMap(([key, label]) => + (props[key as keyof CompatibilityOptions] ?? []).map((value) => ({ + value: Array.isArray(value) ? value[0] : value, + label, + comment: Array.isArray(value) ? value[1] : '', + })), + ); +} + +export function tailwindDocsUrl(version: 'docs' | 'v5', slug: string) { + return `https://${version === 'docs' ? 'v3.' : ''}tailwindcss.com/docs/${slug}`; +} diff --git a/lib/get-llm-text.ts b/lib/get-llm-text.ts index 80cf032..04b00ff 100644 --- a/lib/get-llm-text.ts +++ b/lib/get-llm-text.ts @@ -1,19 +1,25 @@ -import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { source } from '@/lib/source'; +import { source, source5 } from '@/lib/source'; import type { InferPageType } from 'fumadocs-core/source'; +import { exportMarkdown, isPublicDoc } from './llm-markdown'; -function stripFrontmatter(content: string): string { - const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); - return match ? content.slice(match[0].length).trim() : content.trim(); -} - -export async function getLLMText(page: InferPageType) { - const filePath = join(process.cwd(), 'content/docs', page.file.path); - const raw = await readFile(filePath, 'utf-8'); - const body = stripFrontmatter(raw); - - return `# ${page.data.title} (${page.url}) +export { isPublicDoc } from './llm-markdown'; -${page.data.description ? `${page.data.description}\n\n` : ''}${body}`; +export async function getLLMText( + page: InferPageType, + version: 'docs' | 'v5' = 'docs', +) { + const pages = (version === 'docs' ? source : source5).getPages(); + const body = await exportMarkdown({ + root: join(process.cwd(), 'content', version), + file: page.file.path, + url: page.url, + version, + pageUrls: new Map( + pages + .filter((p) => isPublicDoc(p.file.path)) + .map((p) => [p.file.path, p.url]), + ), + }); + return `# ${page.data.title} (https://www.nativewind.dev${page.url})\n\n${page.data.description ? `${page.data.description}\n\n` : ''}${body || 'This documentation page has no guidance yet. Refer to the installation guide and documented APIs rather than inferring support.'}`; } diff --git a/lib/llm-index.ts b/lib/llm-index.ts new file mode 100644 index 0000000..e671bef --- /dev/null +++ b/lib/llm-index.ts @@ -0,0 +1,54 @@ +import { isPublicDoc } from './llm-markdown'; + +type Page = { + file: { path: string }; + slugs: string[]; + url: string; + data: { title: string; description?: string }; +}; + +export function getLLMIndex(pages: Page[], version: 'docs' | 'v5') { + const publicPages = pages.filter((page) => isPublicDoc(page.file.path)); + const overview = publicPages.find((page) => page.slugs.length === 0); + const sections = [ + ['', 'Overview'], + ['getting-started', 'Getting Started'], + ['guides', 'Guides'], + ['core-concepts', 'Core Concepts'], + ['customization', 'Customization'], + ['api', 'API'], + ['tailwind', 'Tailwind CSS Utilities'], + ]; + const knownSections = new Set(sections.map(([prefix]) => prefix)); + const otherSections = [ + ...new Set(publicPages.map((page) => page.slugs[0] ?? '')), + ].filter((prefix) => !knownSections.has(prefix)); + for (const prefix of otherSections) sections.push([prefix, prefix]); + const lines = [ + `# Nativewind ${version === 'docs' ? 'v4' : 'v5 RC'}`, + '', + overview?.data.description ?? '', + '', + 'Use the installation and migration pages for current package versions, setup and verification requirements. Page links below return Markdown generated from the same source as the website.', + '', + ]; + for (const [prefix, title] of sections) { + const entries = publicPages.filter( + (page) => (page.slugs[0] ?? '') === prefix, + ); + if (!entries.length) continue; + lines.push(`## ${title}`, ''); + for (const page of entries) + lines.push( + `- [${page.data.title}](https://www.nativewind.dev${page.url}.mdx)${page.data.description ? `: ${page.data.description}` : ''}`, + ); + lines.push(''); + } + lines.push( + '## Optional', + '', + `- [Full documentation](https://www.nativewind.dev${version === 'v5' ? '/v5' : ''}/llms-full.txt): All public pages in one file`, + '', + ); + return lines.join('\n'); +} diff --git a/lib/llm-markdown.ts b/lib/llm-markdown.ts new file mode 100644 index 0000000..2f01eb5 --- /dev/null +++ b/lib/llm-markdown.ts @@ -0,0 +1,363 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve, relative, sep } from 'node:path'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkMdx from 'remark-mdx'; +import remarkGfm from 'remark-gfm'; +import remarkStringify from 'remark-stringify'; +import { + compatibilityRows, + installCommands, + tailwindDocsUrl, +} from './doc-tables'; + +// MDX mixes mdast, ESTree and JSX nodes. Keep the conversion at this boundary. +type Node = { type: string; [key: string]: any }; +const markdownParser = unified().use(remarkParse).use(remarkGfm); +const parser = unified().use(remarkParse).use(remarkMdx).use(remarkGfm); +const writer = unified().use(remarkGfm).use(remarkStringify, { fences: true }); +const text = (value: string): Node => ({ type: 'text', value }); +const paragraph = (value: string): Node => ({ + type: 'paragraph', + children: [text(value)], +}); +const plain = (node: Node): string => + node.value ?? (node.children ?? []).map(plain).join(''); +const origin = 'https://www.nativewind.dev'; + +export function isPublicDoc(path: string) { + return !path.split('/').some((part) => part.startsWith('_')); +} + +// Evaluate only data expressions used by our MDX partials. Never execute imports, +// function calls or arbitrary JavaScript while exporting documentation. +function evaluate( + node: Node | undefined, + props: Record, + raw: string, +): any { + if (!node) return undefined; + switch (node.type) { + case 'Program': + return evaluate(node.body[0], props, raw); + case 'ExpressionStatement': + return evaluate(node.expression, props, raw); + case 'Literal': + return node.value; + case 'Identifier': + if (node.name === 'props') return props; + if (node.name === 'undefined') return undefined; + break; + case 'MemberExpression': + if ( + node.object.type === 'Identifier' && + node.object.name === 'props' && + !node.computed + ) + return props[node.property.name]; + break; + case 'ArrayExpression': + return node.elements.flatMap((item: Node) => + item.type === 'SpreadElement' + ? evaluate(item.argument, props, raw) + : [evaluate(item, props, raw)], + ); + case 'ConditionalExpression': + return evaluate( + evaluate(node.test, props, raw) ? node.consequent : node.alternate, + props, + raw, + ); + case 'LogicalExpression': + if (node.operator === '||') + return ( + evaluate(node.left, props, raw) || evaluate(node.right, props, raw) + ); + if (node.operator === '&&') + return ( + evaluate(node.left, props, raw) && evaluate(node.right, props, raw) + ); + break; + case 'BinaryExpression': + if (node.operator === '===') + return ( + evaluate(node.left, props, raw) === evaluate(node.right, props, raw) + ); + if (node.operator === '!==') + return ( + evaluate(node.left, props, raw) !== evaluate(node.right, props, raw) + ); + break; + case 'JSXElement': + case 'JSXFragment': + return { jsx: raw.slice(node.range[0], node.range[1]) }; + } + throw new Error(`Unsupported MDX expression: ${node.type}`); +} + +export async function exportMarkdown(options: { + root: string; + file: string; + url: string; + version: 'docs' | 'v5'; + pageUrls?: Map; +}): Promise { + const root = resolve(options.root); + const pageUrls = options.pageUrls ?? new Map(); + function link(url: string, file: string): string { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return url; + if (url.startsWith('#')) return `${origin}${options.url}${url}`; + if (url.startsWith('/')) return `${origin}${url}`; + const [path, suffix = ''] = url.split(/(?=[#?])/s, 2); + const target = relative(root, resolve(dirname(file), path)) + .split(sep) + .join('/'); + const mapped = + pageUrls.get(target) ?? + pageUrls.get(`${target}.mdx`) ?? + pageUrls.get(`${target}.md`) ?? + pageUrls.get(`${target}/index.mdx`); + if (mapped) return `${origin}${mapped}${suffix}`; + // Extensionless links in the docs are URL relative unless they match a source file. + return new URL(url, `${origin}${options.url}`).href; + } + async function expand( + file: string, + props: Record = {}, + ancestors: string[] = [], + ): Promise { + file = resolve(file); + if (!file.startsWith(`${root}${sep}`)) + throw new Error(`MDX include outside content root: ${file}`); + if (ancestors.includes(file)) + throw new Error(`Circular MDX include: ${file}`); + let raw = (await readFile(file, 'utf8')).replace( + /^---\r?\n[\s\S]*?\r?\n---\r?\n?/, + '', + ); + // Legacy .md pages contain HTML comments. Remove comment nodes before MDX + // parsing, without touching comments in fenced examples. + const commentRanges: [number, number][] = []; + const findComments = (node: Node) => { + if (node.type === 'html' && /^\s*$/.test(node.value)) + commentRanges.push([ + node.position.start.offset, + node.position.end.offset, + ]); + node.children?.forEach(findComments); + }; + findComments(markdownParser.parse(raw) as Node); + for (const [start, end] of commentRanges.reverse()) + raw = raw.slice(0, start) + raw.slice(end); + const tree = parser.parse(raw) as Node; + const imports = new Map(); + for (const node of tree.children) + if (node.type === 'mdxjsEsm') { + for (const statement of node.data.estree.body) + if (statement.type === 'ImportDeclaration') { + for (const specifier of statement.specifiers) + imports.set(specifier.local.name, statement.source.value); + } + } + const expression = (node: Node) => evaluate(node.data?.estree, props, raw); + const attributes = (node: Node) => { + const result: Record = {}; + for (const attr of node.attributes ?? []) { + if (attr.type === 'mdxJsxExpressionAttribute') { + // The only supported spread is the partial's incoming props. + if (attr.value !== '...props') + throw new Error(`Unsupported MDX spread in ${file}`); + Object.assign(result, props); + } else + result[attr.name] = + attr.value === null + ? true + : typeof attr.value === 'object' + ? expression(attr.value) + : attr.value; + } + return result; + }; + const children = async (node: Node): Promise => + (await Promise.all((node.children ?? []).map(visit))).flat(); + async function visit(node: Node): Promise { + if (node.type === 'mdxjsEsm') return []; + if ( + node.type === 'mdxFlowExpression' || + node.type === 'mdxTextExpression' + ) { + const value = expression(node); + if (value?.jsx) return children(parser.parse(value.jsx) as Node); + return value === undefined || value === false + ? [] + : [text(String(value))]; + } + if ( + node.type === 'mdxJsxFlowElement' || + node.type === 'mdxJsxTextElement' + ) { + const name = node.name; + if (name === 'CopyInstallationButton' || name === 'CopyMigrationButton') + return []; + const attrs = attributes(node); + if (name === 'include') + return expand(resolve(dirname(file), plain(node).trim()), props, [ + ...ancestors, + file, + ]); + const imported = imports.get(name); + if (imported?.startsWith('.') && imported.endsWith('.mdx')) + return expand(resolve(dirname(file), imported), attrs, [ + ...ancestors, + file, + ]); + if (name === 'PackageInstall') + return installCommands(attrs).flatMap(({ manager, command }) => [ + paragraph(manager), + { type: 'code', lang: 'bash', value: command }, + ]); + if (name === 'CompatibilityTable') { + const rows = compatibilityRows(attrs); + const comments = rows.some((row) => row.comment); + const cell = (children: Node[]) => ({ type: 'tableCell', children }); + return [ + { + type: 'table', + children: [ + { + type: 'tableRow', + children: [ + 'Class', + 'Support', + ...(comments ? ['Comments'] : []), + ].map((value) => cell([text(value)])), + }, + ...rows.map((row) => ({ + type: 'tableRow', + children: [ + cell([{ type: 'inlineCode', value: row.value }]), + cell([text(row.label)]), + ...(comments ? [cell([text(row.comment)])] : []), + ], + })), + ], + }, + ]; + } + if (name === 'Usage' && imported?.endsWith('/_usage.tsx')) { + return [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: tailwindDocsUrl( + options.version, + attrs.href || options.url.split('/').at(-1) || '', + ), + children: [text('Tailwind CSS documentation')], + }, + ], + }, + ]; + } + if (name === 'Pre') + return [{ type: 'code', lang: 'bash', value: plain(node).trim() }]; + if (name === 'table') { + const rows: Node[] = []; + const collect = (n: Node) => { + if (n.name === 'tr') rows.push(n); + else n.children?.forEach(collect); + }; + collect(node); + return [ + { + type: 'table', + children: await Promise.all( + rows.map(async (row) => ({ + type: 'tableRow', + children: await Promise.all( + row.children + .filter((n: Node) => n.name === 'td' || n.name === 'th') + .map(async (n: Node) => ({ + type: 'tableCell', + children: (await children(n)).flatMap((c) => + c.type === 'paragraph' ? c.children : [c], + ), + })), + ), + })), + ), + }, + ]; + } + const body = await children(node); + if (name === 'a') + return [ + { type: 'link', url: link(attrs.href, file), children: body }, + ]; + if (name === 'img') + return [ + { type: 'image', url: link(attrs.src, file), alt: attrs.alt ?? '' }, + ]; + if (name === 'Callout') + return [ + { + type: 'blockquote', + children: [ + ...(attrs.title ? [paragraph(attrs.title)] : []), + ...body, + ], + }, + ]; + if (name === 'Tab') + return [paragraph(attrs.label || attrs.value), ...body]; + if (name === 'p' || name === 'summary') + return [{ type: 'paragraph', children: body }]; + if (name === 'code') + return [{ type: 'inlineCode', value: plain(node) }]; + if ([null, 'Tabs', 'CodeBlock', 'details'].includes(name)) return body; + throw new Error(`Unhandled MDX component ${name} in ${file}`); + } + // Inline MDX includes can expand into multiple block nodes. Lift them out + // of the containing paragraph so headings, lists and fences stay valid. + if (node.type === 'paragraph') { + const blocks: Node[] = []; + let inline: Node[] = []; + const flush = () => { + if (inline.length) + blocks.push({ type: 'paragraph', children: inline }); + inline = []; + }; + for (const child of await children(node)) { + if ( + [ + 'paragraph', + 'heading', + 'list', + 'code', + 'blockquote', + 'table', + 'thematicBreak', + ].includes(child.type) + ) { + flush(); + blocks.push(child); + } else inline.push(child); + } + flush(); + return blocks; + } + if (node.url) node.url = link(node.url, file); + if (node.children) return [{ ...node, children: await children(node) }]; + return [node]; + } + return children(tree); + } + return writer + .stringify({ + type: 'root', + children: await expand(resolve(root, options.file)), + } as any) + .trim(); +} diff --git a/package.json b/package.json index 0ed8829..03360cb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev", "start": "next start", "postinstall": "fumadocs-mdx", - "validate": "tsx scripts/validate-urls.ts ." + "validate": "tsx scripts/validate-urls.ts .", + "test:llm": "node --import tsx --test tests/llm-markdown.test.ts" }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.4", @@ -26,8 +27,13 @@ "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.0", + "remark-parse": "11.0.0", + "remark-stringify": "11.0.0", "tailwind-merge": "^3.2.0", - "tsx": "^4.7.0" + "tsx": "^4.7.0", + "unified": "11.0.5" }, "devDependencies": { "@svgr/webpack": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d73e0a..cd2765b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,12 +56,27 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + remark-gfm: + specifier: 4.0.1 + version: 4.0.1 + remark-mdx: + specifier: 3.1.0 + version: 3.1.0 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 + remark-stringify: + specifier: 11.0.0 + version: 11.0.0 tailwind-merge: specifier: ^3.2.0 version: 3.2.0 tsx: specifier: ^4.7.0 version: 4.19.4 + unified: + specifier: 11.0.5 + version: 11.0.5 devDependencies: '@svgr/webpack': specifier: ^8.1.0 @@ -1630,6 +1645,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vercel/analytics@1.5.0': resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} diff --git a/tests/llm-markdown.test.ts b/tests/llm-markdown.test.ts new file mode 100644 index 0000000..332a1c1 --- /dev/null +++ b/tests/llm-markdown.test.ts @@ -0,0 +1,156 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { readdir, mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { exportMarkdown, isPublicDoc } from '../lib/llm-markdown'; +import { installCommands } from '../lib/doc-tables'; +import { getLLMIndex } from '../lib/llm-index'; + +const render = (version: 'docs' | 'v5', file: string) => + exportMarkdown({ + root: join(process.cwd(), 'content', version), + file, + version, + url: `/${version}/${file.replace(/(?:\/index)?\.mdx$/, '')}`, + }); + +test('all public pages export without unhandled MDX', async () => { + for (const version of ['docs', 'v5'] as const) { + const files = ( + await readdir(`content/${version}`, { recursive: true }) + ).filter((file) => /\.mdx?$/.test(file) && isPublicDoc(file)); + for (const file of files) { + try { + assert.equal(typeof (await render(version, file)), 'string'); + } catch (error) { + throw new Error(`${version}/${file}: ${String(error)}`, { + cause: error, + }); + } + } + } +}); + +test('installation exports expand partials and every package manager', async () => { + for (const version of ['docs', 'v5'] as const) { + const markdown = await render( + version, + 'getting-started/installation/index.mdx', + ); + for (const { command } of installCommands({ + version, + expo: false, + exact: version === 'v5', + deps: + version === 'v5' + ? ['nativewind@5.0.0-rc.0', 'react-native-css@3.1.0-rc.0'] + : [ + 'nativewind@4.2.7', + 'react-native-reanimated', + 'react-native-safe-area-context', + ], + })) + assert.ok(markdown.includes(command), command); + assert.doesNotMatch( + markdown, + /| { + assert.match( + await render('v5', 'tailwind/layout/overflow.mdx'), + /\| `overflow-clip`\s+\| Not supported on native/, + ); + assert.match( + await render('docs', 'tailwind/layout/isolation.mdx'), + /https:\/\/v3.tailwindcss.com\/docs\/isolation/, + ); + assert.match( + await render('v5', 'guides/migrate-from-v4.mdx'), + /npx skills add/, + ); +}); + +test('source changes propagate, links resolve and fenced imports remain intact', async () => { + const root = await mkdtemp(join(tmpdir(), 'nw-llm-')); + try { + await writeFile( + join(root, 'index.mdx'), + '\n\nimport Partial from "./_partial.mdx";\n\n\n\n```tsx\nimport { styled } from "nativewind";\n```', + ); + await writeFile( + join(root, '_partial.mdx'), + '[Guide](./guide.mdx#setup)\n\nInitial instructions', + ); + const options = { + root, + file: 'index.mdx', + url: '/v5', + version: 'v5' as const, + pageUrls: new Map([['guide.mdx', '/v5/guide']]), + }; + const first = await exportMarkdown(options); + assert.match(first, /https:\/\/www.nativewind.dev\/v5\/guide#setup/); + assert.match(first, /import \{ styled \} from "nativewind";/); + assert.doesNotMatch(first, /import Partial/); + await writeFile(join(root, '_partial.mdx'), 'Updated instructions'); + assert.match(await exportMarkdown(options), /Updated instructions/); + await writeFile( + join(root, '_partial.mdx'), + './index.mdx', + ); + await assert.rejects(exportMarkdown(options), /Circular/); + await writeFile( + join(root, '_partial.mdx'), + '../escape.mdx', + ); + await assert.rejects(exportMarkdown(options), /outside content root/); + await writeFile(join(root, '_partial.mdx'), '{process.exit()}'); + await assert.rejects(exportMarkdown(options), /Unsupported MDX expression/); + await writeFile(join(root, '_partial.mdx'), ''); + await assert.rejects(exportMarkdown(options), /Unhandled MDX component/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('the index includes new sections, uses source descriptions and hides helpers', () => { + const pages = [ + { + file: { path: 'index.mdx' }, + slugs: [], + url: '/v5', + data: { title: 'Overview', description: 'Current release context' }, + }, + { + file: { path: 'new-section/page.mdx' }, + slugs: ['new-section', 'page'], + url: '/v5/new-section/page', + data: { title: 'New guide' }, + }, + { + file: { path: 'guides/_helper.mdx' }, + slugs: ['guides', '_helper'], + url: '/v5/guides/_helper', + data: { title: 'Private partial' }, + }, + ]; + const index = getLLMIndex(pages, 'v5'); + assert.match(index, /Current release context/); + assert.match(index, /https:\/\/www.nativewind.dev\/v5.mdx/); + assert.match( + index, + /https:\/\/www.nativewind.dev\/v5\/new-section\/page.mdx/, + ); + assert.doesNotMatch(index, /Private partial|_helper/); +});