Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dist/
apps/*/.expo/
apps/*/.next/
apps/*/dist/
apps/*/.source/
web-build/
apps/*/web-build/
expo-env.d.ts
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The create flow asks for:
- project name
- Setup Type
- App Variant names and Accents
- Backend, Auth, Database, and ORM Choices
- Styling Choice

It then creates a child folder, installs dependencies with the package manager you used to launch the create command, initializes git when safe, and prints next steps.
Expand Down Expand Up @@ -99,6 +100,24 @@ generic-standalone

Select a non-default Styling Option Value with `--styling`. Bare remains the default when Styling is omitted or `--yes` accepts defaults.

## Generated App Options

Tenkit supports exactly 32 Backend, Auth, Database, and ORM combinations across all three Setup Types. The Public CLI resolves dependency-aware Choices in this order:

```text
Backend -> Auth -> Database -> ORM
```

- `none` creates the zero-service project.
- Express and NestJS support Auth `none` or `clerk` without SQL, plus `none`, `better-auth`, or `clerk` with PostgreSQL or MySQL and Prisma or Drizzle.
- Convex supports Auth `none`, `better-auth`, or `clerk` with Convex-managed persistence.

Example:

```bash
pnpm create tenkit@latest --name express-clerk-app --setup white-label --backend express --auth clerk --database postgresql --orm prisma --yes
```

## Core Concepts

| Concept | Meaning |
Expand Down Expand Up @@ -234,6 +253,7 @@ Use `eas init` only to create or discover an App Variant's EAS Project ID. EAS P
│ ├── playground/ # Runnable Expo Playground app
│ └── web/ # Tenkit website
├── packages/
│ ├── types/ # Shared browser-safe public definitions
│ ├── cli/ # Public CLI implementation package
│ ├── create-tenkit/ # Thin package-manager create entrypoint
│ ├── release-tools/ # Release Set planning, packing, and verification
Expand Down
9 changes: 6 additions & 3 deletions apps/web/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# @tenkit/web

The Tenkit marketing website.
The Tenkit Public Web App.

This app is a Next.js package inside the Tenkit monorepo. It presents the public
Tenkit product story, links to the GitHub repository and npm package, and keeps
the web surface separate from the Expo Playground.
Tenkit product story, Configurator, Fumadocs, public projections, and links to
the GitHub repository and npm package. It stays separate from the Expo
Playground.

## Highlights

Expand All @@ -15,6 +16,8 @@ the web surface separate from the Expo Playground.
- Lives under `apps/web` and is addressed as the `@tenkit/web` workspace
package.
- Root workspace scripts are available for the common web checks and build.
- The Configurator and documentation consume the released `@tenkit/types`
Generated App Options contract, which contains 32 supported combinations.

## Local Development

Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFromSource } from "fumadocs-core/search/server"

import { source } from "@/lib/source"

const search = createFromSource(source)

export const GET = search.GET
71 changes: 71 additions & 0 deletions apps/web/app/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Metadata } from "next"
import { notFound } from "next/navigation"

import { DocsTableOfContents } from "@/components/docs/docs-toc"
import { getMDXComponents } from "@/components/docs/mdx"
import { SITE_CONFIG, createPageMetadata } from "@/lib/seo"
import { source } from "@/lib/source"

type DocsPageProps = {
params: Promise<{ slug?: string[] }>
}

function getDocsPage(slug: string[] | undefined) {
const page = source.getPage(slug)

if (!page) {
notFound()
}

return page
}

export default async function DocsPageRoute({ params }: DocsPageProps) {
const page = getDocsPage((await params).slug)
const MDXContent = page.data.body

return (
<article className="animate-in duration-500 ease-out fill-mode-backwards fade-in slide-in-from-bottom-3 motion-reduce:animate-none">
<header className="mb-10 max-w-3xl border-b pb-8">
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-muted-foreground uppercase">
Tenkit documentation
</p>
<h1 className="font-heading text-4xl leading-tight font-semibold tracking-tight text-balance sm:text-5xl">
{page.data.title}
</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-pretty text-muted-foreground">
{page.data.description}
</p>
</header>

<div className="grid gap-12 xl:grid-cols-[minmax(0,1fr)_12rem] xl:gap-14">
<div className="docs-prose max-w-3xl min-w-0">
<MDXContent components={getMDXComponents()} />
</div>
<DocsTableOfContents toc={page.data.toc} />
</div>
</article>
)
}

export function generateStaticParams() {
return source.generateParams()
}

export async function generateMetadata({
params,
}: DocsPageProps): Promise<Metadata> {
const page = getDocsPage((await params).slug)

if (!page.data.description) {
throw new Error(`Documentation page ${page.url} is missing a description.`)
}

return createPageMetadata({
path: page.url,
title: page.data.title,
description: page.data.description,
ogImage: SITE_CONFIG.ogImage,
ogImageAlt: `Tenkit documentation: ${page.data.title}.`,
})
}
7 changes: 7 additions & 0 deletions apps/web/app/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ReactNode } from "react"

import { DocsShell } from "@/components/docs/docs-shell"

export default function DocsRootLayout({ children }: { children: ReactNode }) {
return <DocsShell>{children}</DocsShell>
}
9 changes: 9 additions & 0 deletions apps/web/app/generated-app-options.md/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getGeneratedAppOptionsMarkdown } from "@/lib/seo"

export function GET() {
return new Response(getGeneratedAppOptionsMarkdown(), {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
},
})
}
2 changes: 2 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";

@custom-variant dark (&:is(.dark *));

Expand Down
33 changes: 20 additions & 13 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Dosis, Geist_Mono, Inter, Space_Grotesk } from "next/font/google"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { RootProvider } from "fumadocs-ui/provider/next"

import { DatabuddyAnalytics } from "@/components/databuddy-analytics"
import { TenkitSearchDialog } from "@/components/docs/search-dialog"
import { Header } from "@/components/header"
import { JotaiProvider } from "@/components/jotai-provider"
import { ThemeProvider } from "@/components/theme-provider"
Expand Down Expand Up @@ -62,20 +64,25 @@ export default function RootLayout({
<NuqsAdapter>
<JotaiProvider>
<ThemeProvider>
<TooltipProvider>
<div className="contents" data-slot="app-content">
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-4 inset-y-0 z-40 mx-auto max-w-6xl"
>
<span className="absolute inset-y-0 left-0 w-px bg-border" />
<span className="absolute inset-y-0 right-0 w-px bg-border" />
<RootProvider
search={{ SearchDialog: TenkitSearchDialog }}
theme={{ enabled: false }}
>
<TooltipProvider>
<div className="contents" data-slot="app-content">
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-4 inset-y-0 z-40 mx-auto max-w-6xl"
>
<span className="absolute inset-y-0 left-0 w-px bg-border" />
<span className="absolute inset-y-0 right-0 w-px bg-border" />
</div>
<Header />
{children}
</div>
<Header />
{children}
</div>
<Toaster />
</TooltipProvider>
<Toaster />
</TooltipProvider>
</RootProvider>
</ThemeProvider>
</JotaiProvider>
</NuqsAdapter>
Expand Down
16 changes: 13 additions & 3 deletions apps/web/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import type { MetadataRoute } from "next"

import { absoluteUrl, CONFIGURE_PAGE_SEO } from "@/lib/seo"
import {
absoluteUrl,
CONFIGURE_PAGE_SEO,
INDEXABLE_DOCS_ROUTES,
} from "@/lib/seo"

const SITEMAP_LAST_MODIFIED = new Date("2026-08-26")

export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: absoluteUrl("/"),
lastModified: new Date("2026-07-14"),
lastModified: SITEMAP_LAST_MODIFIED,
},
{
url: absoluteUrl(CONFIGURE_PAGE_SEO.path),
lastModified: new Date("2026-07-14"),
lastModified: SITEMAP_LAST_MODIFIED,
},
...INDEXABLE_DOCS_ROUTES.map((path) => ({
url: absoluteUrl(path),
lastModified: SITEMAP_LAST_MODIFIED,
})),
]
}
115 changes: 52 additions & 63 deletions apps/web/components/code-block-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ function isPackageManager(value: string): value is PackageManager {
)
}

function getPackageManagerInitScript(rootId: string) {
return `(()=>{try{const root=document.getElementById(${JSON.stringify(rootId)});if(!root)return;const preferred=JSON.parse(localStorage.getItem(${JSON.stringify(PACKAGE_MANAGER_STORAGE_KEY)})??"null");if(typeof preferred!=="string")return;const elements=root.querySelectorAll("[data-package-manager-value]");let available=false;elements.forEach((element)=>{if(element.getAttribute("data-package-manager-value")===preferred)available=true});if(!available)return;elements.forEach((element)=>{const active=element.getAttribute("data-package-manager-value")===preferred;element.setAttribute("data-state",active?"active":"inactive");if(element.getAttribute("role")==="tab")element.setAttribute("aria-selected",String(active))})}catch{}})()`
}

export type CodeBlockCommandProps = {
prompt?: string
pnpm?: string
Expand Down Expand Up @@ -76,70 +72,63 @@ export function CodeBlockCommand({
: (availablePackageManagers[0] ?? "prompt")

return (
<>
<div
id={rootId}
data-slot="code-block-command"
className="relative overflow-hidden rounded-xl bg-accent dark:bg-background"
<div
id={rootId}
data-slot="code-block-command"
className="relative overflow-hidden rounded-xl bg-accent dark:bg-background"
>
<Tabs
className="gap-0"
value={selectedPackageManager}
onValueChange={(value) => {
if (isPackageManager(value)) {
setPackageManager(value)
}
}}
>
<Tabs
className="gap-0"
value={selectedPackageManager}
onValueChange={(value) => {
if (isPackageManager(value)) {
setPackageManager(value)
}
}}
>
<CommandTabsHeader tabKeys={availablePackageManagers}>
<HydrationSafePackageManagerIcon
packageManager={selectedPackageManager}
packageManagers={availablePackageManagers}
/>
</CommandTabsHeader>
<CommandTabsHeader tabKeys={availablePackageManagers}>
<HydrationSafePackageManagerIcon
packageManager={selectedPackageManager}
packageManagers={availablePackageManagers}
/>
</CommandTabsHeader>

{availablePackageManagers.map((availablePackageManager) => {
return (
<TabsContent
key={availablePackageManager}
value={availablePackageManager}
forceMount
suppressHydrationWarning
data-package-manager-value={availablePackageManager}
className="data-[state=inactive]:hidden"
{availablePackageManagers.map((availablePackageManager) => {
return (
<TabsContent
key={availablePackageManager}
value={availablePackageManager}
forceMount
suppressHydrationWarning
data-package-manager-value={availablePackageManager}
className="data-[state=inactive]:hidden"
>
<pre
data-pm={availablePackageManager}
className="group/tabs-content-pre overscroll-x-contain p-4 leading-6 not-data-[pm=prompt]:overflow-x-auto"
>
<pre
data-pm={availablePackageManager}
className="group/tabs-content-pre overscroll-x-contain p-4 leading-6 not-data-[pm=prompt]:overflow-x-auto"
<code
data-slot="code-block"
data-language="bash"
className="font-mono text-sm/none text-muted-foreground group-data-[pm=prompt]/tabs-content-pre:whitespace-normal"
>
<code
data-slot="code-block"
data-language="bash"
className="font-mono text-sm/none text-muted-foreground group-data-[pm=prompt]/tabs-content-pre:whitespace-normal"
>
<span className="select-none group-data-[pm=prompt]/tabs-content-pre:hidden">
${" "}
</span>
{tabs[availablePackageManager]}
</code>
</pre>
</TabsContent>
)
})}
</Tabs>
<span className="select-none group-data-[pm=prompt]/tabs-content-pre:hidden">
${" "}
</span>
{tabs[availablePackageManager]}
</code>
</pre>
</TabsContent>
)
})}
</Tabs>

<CommandActions
packageManager={selectedPackageManager}
command={tabs[selectedPackageManager] ?? ""}
onCopySuccess={onCopySuccess}
onCopyError={onCopyError}
/>
</div>
<script
dangerouslySetInnerHTML={{
__html: getPackageManagerInitScript(rootId),
}}
<CommandActions
packageManager={selectedPackageManager}
command={tabs[selectedPackageManager] ?? ""}
onCopySuccess={onCopySuccess}
onCopyError={onCopyError}
/>
</>
</div>
)
}
Loading