diff --git a/.nvmrc b/.nvmrc index 9a2a0e2..54c6511 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v24 diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index c6320b8..713c6be 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -1,19 +1,27 @@ -# Dependencies +# deps /node_modules -# Production -/build +# generated content +.source +content/docs/api -# Generated files -.docusaurus -.cache-loader +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo -# Misc +# misc .DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - +*.pem +/.pnp +.pnp.js npm-debug.log* -pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts diff --git a/apps/docs/README.md b/apps/docs/README.md index d2ffc28..edaebc6 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -1,41 +1,46 @@ -# Website +# React Native Cloud Storage Documentation -This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. +The documentation site for [`react-native-cloud-storage`](https://github.com/kuatsu/react-native-cloud-storage), +built with [Fumadocs](https://fumadocs.dev) on Next.js. Deployed to +[react-native-cloud-storage.oss.kuatsu.de](https://react-native-cloud-storage.oss.kuatsu.de). -### Installation +## Structure -```sh -$ pnpm install -``` +- `content/docs/**` — authored MDX (installation, guides, example). +- `content/docs/api/**` — **generated** API reference (TypeDoc → Markdown). Git-ignored; do not edit by + hand. Regenerate with `pnpm docs:api`. +- `app/(home)` — landing page. `app/docs` — docs renderer. `lib/`, `config/`, `scripts/` — the + TypeDoc-to-Fumadocs pipeline and provider/platform badge machinery. + +## Development -### Local Development +From the repo root (preferred, so workspace dependencies resolve): ```sh -$ pnpm start +pnpm --filter react-native-cloud-storage-docs dev ``` -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. - -### Build +Or from this directory: ```sh -$ pnpm build +pnpm dev # generate API reference, then start the dev server +pnpm docs:api # (re)generate content/docs/api from the library's TSDoc +pnpm build # production build +pnpm typecheck # generate API + types, then tsc --noEmit ``` -This command generates static content into the `build` directory and can be served using any static contents hosting service. - -### Deployment +`dev`, `build`, and `typecheck` run `pnpm docs:api` first, so the generated API reference is always in +sync with `packages/react-native-cloud-storage/src`. -Using SSH: +## API reference & badges -```sh -$ USE_SSH=true pnpm deploy -``` - -Not using SSH: +The API reference is generated from the library's TypeScript and TSDoc by TypeDoc +(`typedoc.config.mts` + `scripts/typedoc-frontmatter.mts`). Provider/platform badges come from +additive `@platform` / `@provider` TSDoc tags in the library source; never edit the generated MDX. -```sh -$ GIT_USER= pnpm deploy -``` +## Deployment -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. +Deployed on Netlify. Configuration lives in `netlify.toml` (base directory `apps/docs`, build command +`pnpm build`, publish directory `.next`, `@netlify/plugin-nextjs`). Netlify installs workspace +dependencies from the repo-root pnpm lockfile. If the site is instead configured through the Netlify +UI (as for other Kuatsu OSS sites), mirror those same values there. diff --git a/apps/docs/app/(home)/layout.tsx b/apps/docs/app/(home)/layout.tsx new file mode 100644 index 0000000..77379fa --- /dev/null +++ b/apps/docs/app/(home)/layout.tsx @@ -0,0 +1,6 @@ +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/'>) { + return {children}; +} diff --git a/apps/docs/app/(home)/page.tsx b/apps/docs/app/(home)/page.tsx new file mode 100644 index 0000000..c35c15d --- /dev/null +++ b/apps/docs/app/(home)/page.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; +import { ArrowRight, Cloud, FolderTree, Github, Layers, Puzzle, Webhook } from 'lucide-react'; +import { gitConfig } from '@/lib/layout.shared'; + +const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}`; + +function InlineCode({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const codeSample = `import { CloudStorage } from 'react-native-cloud-storage'; + +// Write a file to the cloud... +await CloudStorage.writeFile('/data.json', data.toString()); + +// ... and read it back! +const data = await CloudStorage.readFile('/data.json');`; + +const features: { icon: typeof FolderTree; title: string; description: ReactNode }[] = [ + { + icon: FolderTree, + title: 'fs-like API', + description: ( + <> + Read, write, stat, and list files with an API that follows Node's fs conventions. + + ), + }, + { + icon: Cloud, + title: 'iCloud & Google Drive', + description: 'One API, two providers. Use the default platform backend, or pick your own.', + }, + { + icon: Webhook, + title: 'React hooks', + description: ( + <> + useCloudFile and useIsCloudAvailable keep your UI in sync with + cloud state. + + ), + }, + { + icon: Puzzle, + title: 'Expo config plugin', + description: 'Configure native capabilities automatically in Expo projects.', + }, +]; + +const providers = [ + { + name: 'iCloud (iOS only)', + scope: 'Backed by a native CloudKit module.', + }, + { + name: 'Google Drive', + scope: 'Backed by the Drive REST API using an access token.', + }, +]; + +export default function HomePage() { + return ( +
+
+
+

+ + React Native Cloud Storage +

+

+ iCloud and Google Drive for + React Native. +

+

+ Use iCloud and Google Drive as file storage in your React Native app, with a single fs-like API, React + hooks, and an Expo config plugin. +

+
+ + Get started + + + + + GitHub + +
+
+
+ +
+
+ +
+ {features.map((feature) => ( +
+

+ + {feature.title} +

+

{feature.description}

+
+ ))} +
+ +
+ {providers.map((provider) => ( +
+ + + +
+

{provider.name}

+

{provider.scope}

+
+
+ ))} +
+ +
+

Ready to back up your app's files?

+

+ Install the library, follow the platform setup, and start reading and writing cloud files in minutes. +

+ + Read the docs + + +
+
+ ); +} diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts new file mode 100644 index 0000000..7ba7e82 --- /dev/null +++ b/apps/docs/app/api/search/route.ts @@ -0,0 +1,7 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; + +export const { GET } = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', +}); diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000..e45f16d --- /dev/null +++ b/apps/docs/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,67 @@ +import { getPageImage, source } from '@/lib/source'; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/mdx-components'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions'; +import { BadgePills } from '@/components/badges/pills'; +import { readBadgesFromPageData, readTocBadgesFromPageData } from '@/lib/badges'; +import { gitConfig } from '@/lib/layout.shared'; + +export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + const badges = readBadgesFromPageData(page.data); + const tocBadges = readTocBadgesFromPageData(page.data); + const isApiPage = page.path.startsWith('api/'); + + // API pages are generated from the library source, so link "Open in GitHub" to the package source + // rather than the (gitignored) generated MDX file. + const githubUrl = isApiPage + ? `https://github.com/${gitConfig.user}/${gitConfig.repo}/tree/${gitConfig.branch}/packages/react-native-cloud-storage/src` + : `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/apps/docs/content/docs/${page.path}`; + + return ( + + {page.data.title} + + {page.data.description} +
+ + +
+ + + +
+ ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx new file mode 100644 index 0000000..a373143 --- /dev/null +++ b/apps/docs/app/docs/layout.tsx @@ -0,0 +1,11 @@ +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return ( + + {children} + + ); +} diff --git a/apps/docs/static/img/favicon.ico b/apps/docs/app/favicon.ico similarity index 100% rename from apps/docs/static/img/favicon.ico rename to apps/docs/app/favicon.ico diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css new file mode 100644 index 0000000..ab16df1 --- /dev/null +++ b/apps/docs/app/global.css @@ -0,0 +1,56 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +:root { + --color-fd-primary: #0ea5e9; + --color-fd-primary-foreground: #04293a; + --color-fd-ring: #0ea5e9; + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +.dark { + --color-fd-primary: #38bdf8; + --color-fd-primary-foreground: #04293a; + --color-fd-ring: #38bdf8; +} + +body { + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} + +/* Provider/platform badge pills rendered beneath API member headings. */ +.api-member-badge-row { + @apply mt-1.5 mb-3 flex flex-wrap gap-1.5; +} + +.api-member-badge { + @apply inline-flex items-center rounded-full border border-fd-primary/40 bg-fd-primary/10 px-2 py-0.5 text-[10px] leading-none font-medium text-fd-primary; +} + +/* Provider/platform pill shown next to a single-badge page in the sidebar. */ +.api-sidebar-item-content { + @apply flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden; +} + +.api-sidebar-item-label { + @apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap; +} + +.api-sidebar-badge { + @apply inline-flex shrink-0 items-center rounded-full border border-fd-primary/40 bg-fd-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-fd-primary; +} + +/* Overloaded members nest "Parameters"/"Returns"/"Deprecated" as deep (h5/h6) headings, which the + prose theme renders too faintly. Keep them looking like section headings. */ +.api-reference-body :is(h5, h6) { + @apply mt-6 mb-3 text-base font-semibold text-fd-foreground; +} + +/* TypeDoc renders inline types as a run of separate code spans joined by punctuation, which reads as + "pill soup" in the parameter/returns tables. Drop the inline-code chrome inside the API reference + so a type expression reads as one continuous monospace string. Fenced code blocks are untouched. */ +.api-reference-body :not(pre) > code { + @apply border-0 bg-transparent p-0 font-normal text-fd-foreground; +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx new file mode 100644 index 0000000..47dca71 --- /dev/null +++ b/apps/docs/app/layout.tsx @@ -0,0 +1,34 @@ +import { RootProvider } from 'fumadocs-ui/provider/next'; +import './global.css'; +import { Geist, Geist_Mono } from 'next/font/google'; +import type { Metadata } from 'next'; +import { siteDescription, siteName, siteUrl } from '@/lib/site'; + +const geist = Geist({ + subsets: ['latin'], + variable: '--font-geist-sans', +}); + +const geistMono = Geist_Mono({ + subsets: ['latin'], + variable: '--font-geist-mono', +}); + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: { + default: siteName, + template: `%s | ${siteName}`, + }, + description: siteDescription, +}; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + + + {children} + + + ); +} diff --git a/apps/docs/app/llms-full.txt/route.ts b/apps/docs/app/llms-full.txt/route.ts new file mode 100644 index 0000000..0603b36 --- /dev/null +++ b/apps/docs/app/llms-full.txt/route.ts @@ -0,0 +1,13 @@ +import { getLLMText, source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const scan = []; + for (const page of source.getPages()) { + scan.push(getLLMText(page)); + } + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts b/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 0000000..fde26d9 --- /dev/null +++ b/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,20 @@ +import { getLLMText, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { + const { slug } = await params; + const page = source.getPage(slug); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.generateParams(); +} diff --git a/apps/docs/app/llms.txt/route.ts b/apps/docs/app/llms.txt/route.ts new file mode 100644 index 0000000..6639c25 --- /dev/null +++ b/apps/docs/app/llms.txt/route.ts @@ -0,0 +1,13 @@ +import { source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const lines: string[] = []; + lines.push('# Documentation'); + lines.push(''); + for (const page of source.getPages()) { + lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`); + } + return new Response(lines.join('\n')); +} diff --git a/apps/docs/app/og/docs/[...slug]/route.tsx b/apps/docs/app/og/docs/[...slug]/route.tsx new file mode 100644 index 0000000..5b1ba43 --- /dev/null +++ b/apps/docs/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,28 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; +import { siteName } from '@/lib/site'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + , + { + width: 1200, + height: 630, + } + ); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })); +} diff --git a/apps/docs/app/robots.ts b/apps/docs/app/robots.ts new file mode 100644 index 0000000..50185e0 --- /dev/null +++ b/apps/docs/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from 'next'; +import { siteUrl } from '@/lib/site'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: '*', + allow: '/', + }, + ], + sitemap: `${siteUrl}/sitemap.xml`, + }; +} diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts new file mode 100644 index 0000000..64a6f9c --- /dev/null +++ b/apps/docs/app/sitemap.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from 'next'; +import { source } from '@/lib/source'; +import { siteUrl } from '@/lib/site'; + +export const dynamic = 'force-static'; + +export default function sitemap(): MetadataRoute.Sitemap { + const pages = source.getPages().map((page) => ({ + url: new URL(page.url, siteUrl).toString(), + changeFrequency: 'weekly' as const, + priority: page.url === '/docs' ? 0.9 : 0.7, + })); + + return [ + { + url: siteUrl, + changeFrequency: 'monthly', + priority: 1, + }, + ...pages, + ]; +} diff --git a/apps/docs/components/ai/page-actions.tsx b/apps/docs/components/ai/page-actions.tsx new file mode 100644 index 0000000..bfa507f --- /dev/null +++ b/apps/docs/components/ai/page-actions.tsx @@ -0,0 +1,162 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from 'lucide-react'; +import { cn } from '@/lib/cn'; +import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover'; + +const cache = new Map(); + +export function LLMCopyButton({ + /** + * A URL to fetch the raw Markdown/MDX content of page + */ + markdownUrl, +}: { + markdownUrl: string; +}) { + const [isLoading, setLoading] = useState(false); + const [checked, onClick] = useCopyButton(async () => { + const cached = cache.get(markdownUrl); + if (cached) return navigator.clipboard.writeText(cached); + + setLoading(true); + + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': fetch(markdownUrl).then(async (res) => { + const content = await res.text(); + cache.set(markdownUrl, content); + + return content; + }), + }), + ]); + } finally { + setLoading(false); + } + }); + + return ( + + ); +} + +export function ViewOptions({ + markdownUrl, + githubUrl, +}: { + /** + * A URL to the raw Markdown/MDX content of page + */ + markdownUrl: string; + + /** + * Source file URL on GitHub + */ + githubUrl: string; +}) { + const items = useMemo(() => { + const pageUrl = (globalThis as { location?: { href: string } }).location?.href ?? 'loading'; + const q = `Read ${pageUrl}, I want to ask questions about it.`; + + return [ + { + title: 'Open in GitHub', + href: githubUrl, + icon: ( + + GitHub + + + ), + }, + { + title: 'View as Markdown', + href: markdownUrl, + icon: , + }, + { + title: 'Open in ChatGPT', + href: `https://chatgpt.com/?${new URLSearchParams({ + hints: 'search', + q, + })}`, + icon: ( + + OpenAI + + + ), + }, + { + title: 'Open in Claude', + href: `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Anthropic + + + ), + }, + { + title: 'Open in Cursor', + icon: ( + + Cursor + + + ), + href: `https://cursor.com/link/prompt?${new URLSearchParams({ + text: q, + })}`, + }, + ]; + }, [githubUrl, markdownUrl]); + + return ( + + + Open + + + + {items.map((item) => ( + + {item.icon} + {item.title} + + + ))} + + + ); +} diff --git a/apps/docs/components/badges/pills.tsx b/apps/docs/components/badges/pills.tsx new file mode 100644 index 0000000..8e09859 --- /dev/null +++ b/apps/docs/components/badges/pills.tsx @@ -0,0 +1,21 @@ +import { cn } from '@/lib/cn'; + +const CONTAINER_CLASS = 'mt-1 mb-1 flex flex-wrap gap-1.5'; +const PILL_CLASS = + 'inline-flex items-center rounded-full border border-fd-primary/40 bg-fd-primary/10 px-2.5 py-0.5 text-xs font-medium text-fd-primary'; + +export function BadgePills({ badges, className }: { badges: string[]; className?: string }) { + if (badges.length === 0) { + return null; + } + + return ( +
+ {badges.map((badge) => ( + + {badge} + + ))} +
+ ); +} diff --git a/apps/docs/config/api-reference.ts b/apps/docs/config/api-reference.ts new file mode 100644 index 0000000..f4d7a1f --- /dev/null +++ b/apps/docs/config/api-reference.ts @@ -0,0 +1,37 @@ +/** + * Maps TypeDoc's kind-based output folders (`classes`, `functions`, …) onto the API Reference + * sidebar: which folders get flattened into the section root, their canonical labels, and the + * order in which pages and folders appear. + */ + +export function normalizeApiSectionKey(name: string): string { + return name.trim().toLowerCase().replaceAll(/\s+/g, '-'); +} + +/** TypeDoc kind folders whose pages are lifted directly into the API Reference root. */ +export const FLATTENED_FOLDERS = new Set(['classes']); + +/** TypeDoc kind folder key -> canonical sidebar label. */ +export const FOLDER_LABELS: Record = { + 'functions': 'Hooks', + 'interfaces': 'Interfaces', + 'enumerations': 'Enums', + 'type-aliases': 'Type Aliases', +}; + +/** All TypeDoc kind folders the tree plugin recognizes (used to detect the API Reference folder). */ +export const API_SECTION_KEYS = new Set([...FLATTENED_FOLDERS, ...Object.keys(FOLDER_LABELS)]); + +/** Sidebar order for flattened pages (by title). Lower sorts first. */ +export const PAGE_ORDER: Record = { + CloudStorage: 0, + CloudStorageError: 1, +}; + +/** Sidebar order for section folders (by canonical label). Lower sorts first. */ +export const FOLDER_ORDER: Record = { + 'Hooks': 2, + 'Interfaces': 3, + 'Enums': 4, + 'Type Aliases': 5, +}; diff --git a/apps/docs/config/badges.ts b/apps/docs/config/badges.ts new file mode 100644 index 0000000..b2d028d --- /dev/null +++ b/apps/docs/config/badges.ts @@ -0,0 +1,58 @@ +/** + * Canonical provider/platform badge labels and ordering. Shared between the TypeDoc frontmatter + * script (which reads `@platform`/`@provider` tags off the library source) and the runtime renderers + * (sidebar pills, heading pills). Keep this dependency-free so both contexts can import it. + */ + +export const CANONICAL_BADGE_LABELS: Record = { + ios: 'iOS only', + android: 'Android', + icloud: 'iCloud', + googledrive: 'Google Drive', +}; + +export const BADGE_ORDER: readonly string[] = ['iCloud', 'Google Drive', 'iOS only', 'Android']; + +const BADGE_ALIASES: Record = { + ios: 'ios', + iphone: 'ios', + ipados: 'ios', + android: 'android', + icloud: 'icloud', + googledrive: 'googledrive', + gdrive: 'googledrive', + drive: 'googledrive', +}; + +/** Maps a raw tag value (e.g. "icloud", "Google Drive") to its canonical label, or null. */ +export function normalizeBadgeLabel(value: string): string | null { + const key = value + .trim() + .toLowerCase() + .replaceAll(/[\s._-]+/g, ''); + if (key.length === 0) { + return null; + } + + const canonical = BADGE_ALIASES[key]; + if (canonical == null) { + return null; + } + + return CANONICAL_BADGE_LABELS[canonical] ?? null; +} + +/** De-duplicates and orders badge labels per BADGE_ORDER, with unknown labels appended alphabetically. */ +export function orderBadges(labels: Iterable): string[] { + const unique = new Set(); + for (const label of labels) { + if (label.length > 0) { + unique.add(label); + } + } + + const prioritized = BADGE_ORDER.filter((label) => unique.has(label)); + const extras = [...unique].filter((label) => !BADGE_ORDER.includes(label)).sort((a, b) => a.localeCompare(b)); + + return [...prioritized, ...extras]; +} diff --git a/apps/docs/docs/example.md b/apps/docs/content/docs/example.mdx similarity index 63% rename from apps/docs/docs/example.md rename to apps/docs/content/docs/example.mdx index fa5a7e3..fffdded 100644 --- a/apps/docs/docs/example.md +++ b/apps/docs/content/docs/example.mdx @@ -1,14 +1,13 @@ --- -sidebar_position: 5 +title: Example Project +description: Run the example app to see every feature of react-native-cloud-storage in action. --- -# Example Project - -An example project is available within the [`example` directory](https://github.com/Kuatsu/react-native-cloud-storage/tree/master/example) of the GitHub repository. It demonstrates all functionality the library provides. +An example project is available within the [`apps/example` directory](https://github.com/kuatsu/react-native-cloud-storage/tree/master/apps/example) of the GitHub repository. It demonstrates all functionality the library provides. ## iOS Setup -When setting up the example project for iOS, you will need to follow the [iOS installation steps](./installation/react-native) in order to provide your own iCloud container. +When setting up the example project for iOS, you will need to follow the [iOS installation steps](/docs/installation/react-native) in order to provide your own iCloud container. ## Android Setup diff --git a/apps/docs/docs/guides/google-drive-files-same-name.md b/apps/docs/content/docs/guides/google-drive-files-same-name.mdx similarity index 68% rename from apps/docs/docs/guides/google-drive-files-same-name.md rename to apps/docs/content/docs/guides/google-drive-files-same-name.mdx index a0381ec..a28957a 100644 --- a/apps/docs/docs/guides/google-drive-files-same-name.md +++ b/apps/docs/content/docs/guides/google-drive-files-same-name.mdx @@ -1,21 +1,18 @@ --- -sidebar_position: 2 +title: Handling multiple files with the same name in Google Drive +description: Understand and optionally opt into strict filename handling when Google Drive contains multiple files with the same name. --- -# Handling multiple files with the same name in Google Drive - Google Drive has a little peculiarity as in that there can be multiple files (or even directories) with the same name in the same parent directory. Under the hood, files are uniquely identified by a file ID instead of their name and directories are actually simply files with a custom MIME type. As iCloud does not behave in such a way, this library will default to the first file found to ensure best compatibility between the two cloud storages. -However, this might not always be desired by the user, especially when working in the [`CloudStorageScope.Documents` scope](../api/enums/CloudStorageScope). - -:::tip +However, this might not always be desired by the user, especially when working in the [`CloudStorageScope.Documents` scope](/docs/api/enumerations/CloudStorageScope). + When only working in the `CloudStorageScope.AppData` scope, which should be the case for most apps (and which is also the default behavior of this library), you don't really need to pay attention to this issue as `react-native-cloud-storage` will never create multiple files with the same filename in the same directory. Therefore, this only becomes an issue within this scope if you're accessing the same app data container from outside `react-native-cloud-storage` and create multiple files with the same filename yourself. - -::: + ## Throwing an error By default, the library will not throw when there are multiple files with the same name detected but instead default to the first one returned by the Google Drive API. You can however opt into throwing. Please note however that this will render the library completely useless for such cases until the user manually "fixed" this by renaming the files in his Google Drive, as the library will throw before performing any file operations. Behind the scenes, the library will always list all files first to get the file id of the given pathname in order to perform actual actions on this file. Throwing an error will already occur when there are multiple files with the same name detected on this step. -If you do wish to enable throwing, simply call [`CloudStorage.setProviderOptions({ strictFilenames: true })`](../api/CloudStorage#setprovideroptionsoptions) on a `CloudStorage` instance set to Google Drive. The library will then throw a [`CloudStorageError`](../api/CloudStorageError) with the code [`CloudStorageErrorCode.MULTIPLE_FILES_SAME_NAME`](../api/enums/CloudStorageErrorCode). Again, this will only affect Google Drive and not have any effect on other providers such as iCloud, which do not allow same filenames on different files within the same directory. +If you do wish to enable throwing, simply call [`CloudStorage.setProviderOptions({ strictFilenames: true })`](/docs/api/classes/CloudStorage#setprovideroptions) on a `CloudStorage` instance set to Google Drive. The library will then throw a [`CloudStorageError`](/docs/api/classes/CloudStorageError) with the code [`CloudStorageErrorCode.MULTIPLE_FILES_SAME_NAME`](/docs/api/enumerations/CloudStorageErrorCode). Again, this will only affect Google Drive and not have any effect on other providers such as iCloud, which do not allow same filenames on different files within the same directory. diff --git a/apps/docs/docs/guides/migrating-icloud-documents.md b/apps/docs/content/docs/guides/migrating-icloud-documents.mdx similarity index 91% rename from apps/docs/docs/guides/migrating-icloud-documents.md rename to apps/docs/content/docs/guides/migrating-icloud-documents.mdx index 6bb70b7..e176717 100644 --- a/apps/docs/docs/guides/migrating-icloud-documents.md +++ b/apps/docs/content/docs/guides/migrating-icloud-documents.mdx @@ -1,9 +1,8 @@ --- -sidebar_position: 3 +title: Migrating iCloud Documents from legacy sandbox storage +description: Move existing files from the legacy app-sandbox Documents directory to the user-facing iCloud Drive Documents folder. --- -# Migrating iCloud `Documents` from legacy sandbox storage - Before version 3, the `CloudStorageScope.Documents` incorrectly pointed to a local app sandbox `Documents` directory on iCloud. In version 3, this was fixed and the scope now points to the user-facing Documents folder on iCloud Drive instead. If you shipped that behavior and now want to move users to real iCloud Documents, use the iCloud provider option `documentsMode` to read legacy files and copy them over once. diff --git a/apps/docs/content/docs/guides/quick-start.mdx b/apps/docs/content/docs/guides/quick-start.mdx new file mode 100644 index 0000000..d0d524d --- /dev/null +++ b/apps/docs/content/docs/guides/quick-start.mdx @@ -0,0 +1,96 @@ +--- +title: Quick Start +description: Install react-native-cloud-storage and read and write your first cloud file in a few minutes. +--- + +This guide takes you from install to reading and writing your first cloud file. The default `CloudStorage` instance automatically uses iCloud on iOS and Google Drive everywhere else, so most of your code stays the same across platforms. + +## 1. Install + +```package-install +react-native-cloud-storage +``` + +Then complete the native setup for your platform: + +- [Expo managed project](/docs/installation/expo) +- [Bare React Native project](/docs/installation/react-native) +- [Configure the Google Drive API](/docs/installation/configure-google-drive) — required whenever Google Drive is used (e.g. on Android) + +## 2. Provide a Google Drive access token + +iCloud works out of the box on iOS. Google Drive requires an access token that you obtain from the user with a library such as [`@react-native-google-signin/google-signin`](https://github.com/react-native-google-signin/google-signin), then hand to the library: + +```ts +import { CloudStorage, CloudStorageProvider } from 'react-native-cloud-storage'; + +if (CloudStorage.getProvider() === CloudStorageProvider.GoogleDrive) { + CloudStorage.setProviderOptions({ accessToken: 'your_access_token' }); +} +``` + + + On iOS the default provider is iCloud, which needs no token. You can skip this step if you only target iOS and don't need to use Google Drive. See + [Configure Google Drive API](/docs/installation/configure-google-drive) for details on how to acquire a token. + + +## 3. Write and read a file + +The core API mirrors Node's `fs`, so reading and writing is just `writeFile` / `readFile`: + +```ts +import { CloudStorage, CloudStorageScope } from 'react-native-cloud-storage'; + +// Write a file to the app-private scope. +await CloudStorage.writeFile('/user.json', JSON.stringify({ name: 'Ada' }), CloudStorageScope.AppData); + +// Read it back. +if (await CloudStorage.exists('/user.json', CloudStorageScope.AppData)) { + const json = await CloudStorage.readFile('/user.json', CloudStorageScope.AppData); + const user = JSON.parse(json); +} +``` + + + `CloudStorageScope.AppData` is a hidden, app-private container — ideal for app state and backups. Use + `CloudStorageScope.Documents` for files the user should see in iCloud Drive or Google Drive. + + +## 4. Use the React hook + +Inside components, [`useCloudFile`](/docs/api/functions/useCloudFile) keeps a single file's content in sync and gives you helpers to write and remove it: + +```tsx +import { Button, Text, View } from 'react-native'; +import { CloudStorageScope, useCloudFile } from 'react-native-cloud-storage'; + +function Profile() { + const { content, write, remove } = useCloudFile('/user.json', CloudStorageScope.AppData); + + return ( + + {content ?? 'No profile saved yet'} +