├── .env.local.example ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── (llms) │ ├── _origin.ts │ ├── llms-full.txt │ │ └── route.tsx │ ├── llms-full │ │ └── page.tsx │ └── llms.txt │ │ └── route.tsx ├── [category] │ ├── [[...slug]] │ │ └── page.tsx │ └── layout.tsx ├── _components │ ├── article-link │ │ ├── fragment.ts │ │ └── mark.tsx │ ├── article │ │ ├── accordion │ │ │ ├── accordion.module.scss │ │ │ ├── accordion.tsx │ │ │ ├── fragment.ts │ │ │ └── index.tsx │ │ ├── article-index.tsx │ │ ├── article.module.scss │ │ ├── breadcrumb.tsx │ │ ├── callout-fragment.ts │ │ ├── callout.tsx │ │ ├── cards-grid-fragment.tsx │ │ ├── cards-grid.tsx │ │ ├── code-snippet │ │ │ ├── code-snippet.module.scss │ │ │ ├── controller.tsx │ │ │ ├── fragment.ts │ │ │ ├── header.tsx │ │ │ └── index.tsx │ │ ├── footer.tsx │ │ ├── heading │ │ │ ├── heading.module.scss │ │ │ └── index.tsx │ │ ├── iframe │ │ │ ├── fragment.ts │ │ │ ├── iframe-with-fade-in.tsx │ │ │ └── index.tsx │ │ ├── image │ │ │ ├── handler.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── stepper │ │ │ ├── fragment.ts │ │ │ ├── index.tsx │ │ │ ├── step-controller.tsx │ │ │ └── stepper.module.scss │ │ └── video.tsx │ ├── feedback.tsx │ ├── footer │ │ ├── footer.module.scss │ │ └── index.tsx │ ├── header │ │ ├── header-nav.tsx │ │ ├── header.module.scss │ │ ├── index.tsx │ │ ├── pages-nav │ │ │ ├── index.tsx │ │ │ ├── nav-link.tsx │ │ │ ├── nav.module.scss │ │ │ └── nav.tsx │ │ └── search │ │ │ ├── index.tsx │ │ │ └── search.module.scss │ ├── icons.tsx │ ├── icons │ │ ├── thumbs-up.tsx │ │ └── thumns-down.tsx │ ├── logo │ │ ├── index.tsx │ │ └── logo.module.scss │ ├── openapi │ │ ├── index.tsx │ │ └── openapi.module.scss │ ├── sidebar │ │ ├── index.tsx │ │ └── sidebar.module.scss │ ├── theme-provider │ │ ├── client.tsx │ │ └── server.tsx │ ├── theme-switcher │ │ ├── index.tsx │ │ └── theme-switcher.module.scss │ └── toc │ │ ├── index.tsx │ │ ├── toc.module.scss │ │ └── utils.ts ├── _constants.ts ├── globals.css ├── layout.tsx └── sitemap.ts ├── basehub-helpers ├── fragments.ts ├── sidebar.ts └── util.ts ├── hooks └── use-has-rendered.ts ├── lib └── constants │ └── index.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── public ├── basehub.svg ├── font │ └── geist │ │ ├── Geist-Medium.otf │ │ └── Geist-Regular.otf ├── robots.txt ├── sitemap-0.xml └── sitemap.xml └── tsconfig.json /.env.local.example: -------------------------------------------------------------------------------- 1 | BASEHUB_TOKEN="" 2 | BASEHUB_OUTPUT=".basehub" # important! -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "no-unused-vars": [ 5 | 2, 6 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # basehub 40 | .basehub -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.x -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "tabWidth": 2, 6 | "printWidth": 80, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 BaseHub Corp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Open Graph, Homepage (2) (1)](https://github.com/basehub-ai/nextjs-docs/assets/40034115/67d684ae-9afc-4290-a48a-72072f1e6053) 2 | 3 | [BaseHub Templates](https://basehub.com/templates) are production-ready website templates, powered by BaseHub. 4 | 5 | # Documentation Template 6 | 7 | [![Use template](https://basehub.com/template-button.svg)](https://basehub.com/basehub/docs) 8 | 9 | Fully featured documentation website. 10 | 11 | - 🔸 Perfect for developer docs, user manuals, and similar 12 | - 🔸 Fully editable from BaseHub 13 | - 🔸 Comes with Search, Dark/Light Mode, Analytics, and more 14 | - 🔸 Requires just a BaseHub account and a deployment platform—no other service 15 | 16 | ## Stack 17 | 18 | - Next.js 19 | - BaseHub 20 | - Radix Themes 21 | 22 | ## One Click Deployment 23 | 24 | [![Deploy with Vercel](https://vercel.com/button)]([https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fhello-world](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fbasehub-ai%2Fnextjs-docs&integration-ids=oac_xwgyJe0UwFLtsKIvIScYh0rY&env=&demo-url=https%3A%2F%2Fdocs.basehub.com&demo-description=Fully%20featured%20documentation%20website%2C%20featuring%3A%0A%0A-%20Next.js%0A-%20BaseHub%20Search%0A-%20BaseHub%20Analytics%0A-%20Radix%20Themes&demo-image=https%3A%2F%2Fbasehub.earth%2F7b31fb4b%2Fnp3FWfA8x4zRJYH5EPNwo%2Fimage-150.png&external-id=mly6i259eym3jkyvq6txyciu%3ARzwcwGKShcB0pBmOiUseY)) 25 | 26 | _You can deploy this anywhere. Vercel works nicely and with one click._ 27 | 28 | ## Local Development 29 | 30 | **Install dependencies** 31 | ```bash 32 | pnpm i 33 | ``` 34 | 35 | **Add your BASEHUB_TOKEN to `.env.local`** 36 | ```txt 37 | # .env.local 38 | 39 | BASEHUB_TOKEN="" 40 | ``` 41 | 42 | **Start the dev server** 43 | ```bash 44 | pnpm dev 45 | ``` 46 | -------------------------------------------------------------------------------- /app/(llms)/_origin.ts: -------------------------------------------------------------------------------- 1 | import nextConfig from '@/next.config' 2 | 3 | export const origin = 4 | process.env.NODE_ENV === 'production' 5 | ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` 6 | : 'http://localhost:3000' 7 | 8 | export const originPlusBasePath = origin + (nextConfig.basePath || '') 9 | -------------------------------------------------------------------------------- /app/(llms)/llms-full.txt/route.tsx: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio' 2 | import Turndown from 'turndown' 3 | import { originPlusBasePath } from '../_origin' 4 | import nextConfig from '@/next.config' 5 | 6 | // cache this cause it's pretty expensive to generate 7 | export const dynamic = 'force-static' 8 | export const revalidate = 3600 // 1 hour 9 | 10 | export const GET = async () => { 11 | try { 12 | const res = await fetch( 13 | process.env.NODE_ENV === 'production' 14 | ? // use VERCEL_URL so this works in preview deployments 15 | `https://${process.env.VERCEL_URL}${nextConfig.basePath || ''}/llms-full` 16 | : `http://localhost:3000${nextConfig.basePath || ''}/llms-full` 17 | ) 18 | const rawHtml = await res.text() 19 | 20 | const $ = cheerio.load(rawHtml) 21 | const llmRoot = $('#llms-root') 22 | 23 | /** Explicitly remove all elements with the data-llms-ignore attribute */ 24 | llmRoot.find('[data-llms-ignore="true"]').remove() 25 | 26 | /** Remove all but the first snippet in each group */ 27 | const groups = new Set() 28 | $('div[data-snippet-group-id]').each((i, el) => { 29 | groups.add($(el).attr('data-snippet-group-id')) 30 | }) 31 | 32 | // For each group, remove all but the first snippet 33 | groups.forEach((groupId) => { 34 | $(`div[data-snippet-group-id="${groupId}"]`).slice(1).remove() 35 | }) 36 | 37 | const html = llmRoot.html() 38 | if (!html) return new Response('No html found for llms', { status: 404 }) 39 | 40 | const turndown = new Turndown({ 41 | headingStyle: 'atx', 42 | codeBlockStyle: 'fenced', 43 | preformattedCode: true, 44 | }) 45 | 46 | /** Remove anchor links from headings */ 47 | turndown.addRule('headings', { 48 | filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], 49 | replacement: function (content, node) { 50 | const hLevel = parseInt(node.nodeName.charAt(1)) 51 | // Strip out any anchor links, just get the text content 52 | const plainText = content.replace(/\[(.*?)\]\(.*?\)/g, '$1') 53 | return '\n\n' + '#'.repeat(hLevel) + ' ' + plainText + '\n\n' 54 | }, 55 | }) 56 | 57 | /** Transform relative URLs to absolute */ 58 | turndown.addRule('links', { 59 | filter: 'a', 60 | replacement: function (content, _node) { 61 | const node = _node as HTMLAnchorElement 62 | const href = node.getAttribute('href') 63 | if (!href) return content 64 | 65 | // Transform relative URLs to absolute 66 | const absoluteUrl = href.startsWith('/') 67 | ? `${originPlusBasePath}${href}` 68 | : href 69 | 70 | return '[' + content + '](' + absoluteUrl + ')' 71 | }, 72 | }) 73 | 74 | const text = turndown.turndown(html) 75 | 76 | return new Response(text, { status: 200 }) 77 | } catch (err) { 78 | return new Response('Not built yet', { status: 200 }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/(llms)/llms-full/page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { basehub, fragmentOn, fragmentOnRecursiveCollection } from '@/.basehub' 3 | import { ArticleFragment } from '@/basehub-helpers/fragments' 4 | import { Body } from '@/app/_components/article' 5 | 6 | const ArticleFragmentRecursive = fragmentOnRecursiveCollection( 7 | 'ArticleComponent', 8 | ArticleFragment, 9 | { 10 | levels: 5, 11 | recursiveKey: 'children', 12 | } 13 | ) 14 | 15 | const LlmsFullHtmlPage = async () => { 16 | const data = await basehub().query({ 17 | pages: { 18 | items: { 19 | _id: true, 20 | articles: { 21 | items: ArticleFragmentRecursive, 22 | }, 23 | }, 24 | }, 25 | }) 26 | 27 | return ( 28 |
29 | {data.pages.items.map((page) => { 30 | return ( 31 | 32 | {page.articles.items.map((article) => { 33 | return 34 | })} 35 | 36 | ) 37 | })} 38 |
39 | ) 40 | } 41 | 42 | const ArticleRecursive = ({ 43 | article, 44 | }: { 45 | article: fragmentOn.infer 46 | }) => { 47 | const hasContent = article.body?.json.content?.length 48 | 49 | return ( 50 | <> 51 | {hasContent && ( 52 | <> 53 |

{article._title}

54 |
{article.excerpt}
55 | 59 | 60 | )} 61 | {article.children.items.map((child) => { 62 | return 63 | })} 64 | 65 | ) 66 | } 67 | 68 | export default LlmsFullHtmlPage 69 | -------------------------------------------------------------------------------- /app/(llms)/llms.txt/route.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleMetaFragmentRecursive } from '@/basehub-helpers/fragments' 2 | import { basehub } from 'basehub' 3 | import { originPlusBasePath } from '../_origin' 4 | 5 | export const GET = async () => { 6 | const data = await basehub().query({ 7 | settings: { metadata: { sitename: true } }, 8 | pages: { 9 | items: { 10 | _title: true, 11 | _slug: true, 12 | openApiSpec: { url: true }, 13 | articles: { items: ArticleMetaFragmentRecursive }, 14 | }, 15 | }, 16 | }) 17 | 18 | function processArticleRecursive( 19 | article: ArticleMetaFragmentRecursive, 20 | headingLevel = 3 21 | ): string { 22 | if (article.children.items.length) { 23 | const children = article.children.items 24 | .map((child) => processArticleRecursive(child, headingLevel + 1)) 25 | .join('\n') 26 | return ` 27 | ${Array.from({ length: headingLevel }) 28 | .map((_) => '#') 29 | .join('')} ${article._title} 30 | 31 | ${children} 32 | ` 33 | } 34 | 35 | return `- [${article._title}](${originPlusBasePath}/${article._slug})${article.excerpt ? `: ${article.excerpt}` : ''}` 36 | } 37 | 38 | return new Response( 39 | `# ${data.settings.metadata.sitename} 40 | 41 | ${data.pages.items 42 | .map((page) => { 43 | return `## ${page._title}${page.openApiSpec?.url ? `\n\nOpenAPI Spec: ${page.openApiSpec.url}` : ''} 44 | 45 | ${page.articles.items 46 | .map((article) => { 47 | return processArticleRecursive(article, 3) 48 | }) 49 | .join('\n')} 50 | ` 51 | }) 52 | .join('\n')} 53 | 54 | `, 55 | { status: 200 } 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/[category]/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Article, ArticleWrapper } from '@/app/_components/article' 2 | import { 3 | ArticleMetaFragmentRecursive, 4 | PageFragment, 5 | pageBySlug, 6 | } from '@/basehub-helpers/fragments' 7 | import { Pump } from 'basehub/react-pump' 8 | import { Metadata } from 'next' 9 | import { notFound } from 'next/navigation' 10 | import { getActiveSidebarItem, getBreadcrumb } from '@/basehub-helpers/sidebar' 11 | import { basehub } from 'basehub' 12 | import { draftMode } from 'next/headers' 13 | import { Toc } from '@/app/_components/toc' 14 | import { ArticleIndex } from '@/app/_components/article/article-index' 15 | import { OpenApi } from '@/app/_components/openapi' 16 | 17 | export const dynamic = 'force-static' 18 | 19 | export const generateStaticParams = async (): Promise< 20 | Array<{ category: string; slug: string[] }> 21 | > => { 22 | const data = await basehub({ cache: 'no-store' }).query({ 23 | pages: { items: PageFragment }, 24 | }) 25 | const result: Array<{ category: string; slug: string[] }> = [] 26 | 27 | /** 28 | * Recursive function to process every level of nesting 29 | */ 30 | function processArticle( 31 | path: string[], 32 | article: ArticleMetaFragmentRecursive, 33 | category: string 34 | ) { 35 | const updatedPath = [...path, article._slug] 36 | if (article.body?.__typename) { 37 | // has body, therefore is linkable and should be added to result 38 | result.push({ category, slug: updatedPath }) 39 | } 40 | // recursively process children 41 | if (article.children.items && article.children.items.length > 0) { 42 | article.children.items.forEach((child) => { 43 | processArticle(updatedPath, child, category) 44 | }) 45 | } 46 | } 47 | 48 | data.pages.items.map((page) => { 49 | // add the category index 50 | result.push({ category: page._slug, slug: [] }) 51 | // process articles recursively 52 | page.articles.items.forEach((article) => { 53 | processArticle([], article, page._slug) 54 | }) 55 | }) 56 | 57 | return result 58 | } 59 | 60 | export const generateMetadata = async ({ 61 | params: _params, 62 | }: { 63 | params: Promise<{ category: string; slug: string[] | undefined }> 64 | }): Promise => { 65 | const params = await _params 66 | const data = await basehub({ draft: (await draftMode()).isEnabled }).query({ 67 | pages: pageBySlug(params.category), 68 | settings: { 69 | metadata: { 70 | pageTitleTemplate: true, 71 | sitename: true, 72 | favicon: { url: true }, 73 | }, 74 | }, 75 | }) 76 | 77 | const category = data.pages.items[0] 78 | if (!category) return {} 79 | const siteName = data.settings.metadata.sitename 80 | 81 | if (!params.slug?.length) { 82 | const title = `${category._title} ${data.settings.metadata.pageTitleTemplate}` 83 | const description = siteName + ' documentation / ' + category._title 84 | const images = [ 85 | { 86 | url: category.ogImage.url, 87 | width: 1200, 88 | height: 630, 89 | }, 90 | ] 91 | 92 | return { 93 | title, 94 | description: siteName + ' documentation / ' + category._title, 95 | icons: { 96 | icon: data.settings.metadata.favicon.url, 97 | shortcut: data.settings.metadata.favicon.url, 98 | apple: data.settings.metadata.favicon.url, 99 | }, 100 | openGraph: { 101 | title, 102 | description, 103 | siteName, 104 | locale: 'en-US', 105 | type: 'website', 106 | url: `/docs/${params.slug?.join('/') ?? ''}`, 107 | images, 108 | }, 109 | } 110 | } 111 | 112 | const { 113 | current: { article }, 114 | } = getActiveSidebarItem({ 115 | sidebar: category.articles, 116 | activeSlugs: params.slug ?? [], 117 | }) 118 | if (!article) return {} 119 | const { _title, sidebarOverrides, excerpt } = article 120 | 121 | const title = { 122 | absolute: `${category._title} / ${sidebarOverrides.title ?? _title} ${data.settings.metadata.pageTitleTemplate}`, 123 | } 124 | const description = !excerpt 125 | ? undefined 126 | : excerpt.length > 150 127 | ? excerpt.slice(0, 150) + '...' 128 | : excerpt 129 | 130 | const images = [ 131 | { 132 | url: article.ogImage.url, 133 | width: 1200, 134 | height: 630, 135 | }, 136 | ] 137 | 138 | return { 139 | title, 140 | description, 141 | icons: { 142 | icon: data.settings.metadata.favicon.url, 143 | shortcut: data.settings.metadata.favicon.url, 144 | apple: data.settings.metadata.favicon.url, 145 | }, 146 | openGraph: { 147 | title, 148 | description, 149 | siteName, 150 | locale: 'en-US', 151 | type: 'website', 152 | url: `/docs/${params.slug?.join('/') ?? ''}`, 153 | images, 154 | }, 155 | } 156 | } 157 | 158 | export default async function ArticlePage({ 159 | params: _params, 160 | }: { 161 | params: Promise<{ category: string; slug: string[] | undefined }> 162 | }) { 163 | const params = await _params 164 | const activeSlugs = params.slug ?? [] 165 | 166 | return ( 167 | 174 | {async ({ activeSlugs, params }, [data, firstCategoryData]) => { 175 | 'use server' 176 | 177 | const page = data.pages.items[0] 178 | if (!page) { 179 | notFound() 180 | } 181 | 182 | if (page.openApiSpec.enabled) { 183 | if (!page.openApiSpec.url) { 184 | throw new Error('OpenAPI spec URL is required') 185 | } 186 | return 187 | } 188 | 189 | const { 190 | current: { article: activeSidebarItem }, 191 | next, 192 | } = getActiveSidebarItem({ 193 | sidebar: page.articles, 194 | activeSlugs, 195 | }) 196 | 197 | const isInFirstCategory = 198 | page._id === firstCategoryData.pages.items[0]?._id 199 | 200 | if (!params.slug?.length) { 201 | return ( 202 | <> 203 | 215 | 216 | 217 | 218 | {[]} 219 | 220 | ) 221 | } 222 | 223 | if (!activeSidebarItem) { 224 | notFound() 225 | } 226 | 227 | const { titles, slugs } = getBreadcrumb({ 228 | sidebar: page.articles, 229 | activeSidebarItem, 230 | }) 231 | 232 | const breadcrumb = [ 233 | { title: page._title, slug: isInFirstCategory ? '' : page._slug }, 234 | ...titles.map((item, index) => ({ 235 | title: item, 236 | slug: slugs[index] ?? '#', 237 | })), 238 | { title: activeSidebarItem._title, slug: activeSidebarItem._slug }, 239 | ] 240 | 241 | let nextArticle = null 242 | if (next.article) { 243 | nextArticle = { 244 | title: next.article.sidebarOverrides.title ?? next.article._title, 245 | href: isInFirstCategory 246 | ? '/' + next.path.join('/') 247 | : '/' + params.category + '/' + next.path.join('/'), 248 | } 249 | } 250 | 251 | return ( 252 |
257 | ) 258 | }} 259 | 260 | ) 261 | } 262 | -------------------------------------------------------------------------------- /app/[category]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Pump } from '@/.basehub/react-pump' 2 | import { SidebarFragment } from '@/basehub-helpers/fragments' 3 | import { notFound } from 'next/navigation' 4 | import { Sidebar } from '../_components/sidebar' 5 | import { Container, Flex } from '@radix-ui/themes' 6 | 7 | export default async function Layout({ 8 | children, 9 | params: _params, 10 | }: { 11 | children: React.ReactNode 12 | params: Promise<{ category: string }> 13 | }) { 14 | const params = await _params 15 | return ( 16 | 22 | 26 | 27 | {async ([data]) => { 28 | 'use server' 29 | 30 | if (!data.pages.items.length) { 31 | notFound() 32 | } 33 | const categoryIndex = data.pages.items.findIndex( 34 | (page) => params.category === page._slug 35 | ) 36 | const category = data.pages.items[categoryIndex] 37 | if (!category) { 38 | notFound() 39 | } 40 | 41 | return ( 42 | 47 | ) 48 | }} 49 | 50 | 59 |
{children}
60 |
61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/_components/article-link/fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | 3 | export const SidebarOverridesFragment = fragmentOn( 4 | 'SidebarOverridesComponent', 5 | { 6 | title: true, 7 | icon: true, 8 | markAsNew: true, 9 | } 10 | ) 11 | 12 | export const ArticleLinkFragment = fragmentOn('ArticleLinkComponent', { 13 | _id: true, 14 | __typename: true, 15 | target: { 16 | _idPath: true, 17 | _slugPath: true, 18 | _title: true, 19 | sidebarOverrides: { title: true }, 20 | excerpt: true, 21 | }, 22 | anchor: true, 23 | }) 24 | 25 | export type ArticleLinkFragment = fragmentOn.infer 26 | -------------------------------------------------------------------------------- /app/_components/article-link/mark.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { getArticleSlugFromSlugPath } from '@/basehub-helpers/util' 3 | import { Heading, HoverCard, Link, Text } from '@radix-ui/themes' 4 | import NextLink from 'next/link' 5 | import { Pump } from '@/.basehub/react-pump' 6 | import { ArticleLinkFragment } from './fragment' 7 | 8 | export const ArticleLinkMark = async ( 9 | props: { 10 | children: React.ReactNode 11 | } & ArticleLinkFragment 12 | ) => { 13 | return ( 14 | 15 | {async ([{ pages }]) => { 16 | 'use server' 17 | 18 | const isInFirstCategory = pages.items[0] 19 | ? props.target._idPath.includes(pages.items[0]._id) 20 | : false 21 | 22 | return ( 23 | 27 | ) 28 | }} 29 | 30 | ) 31 | } 32 | 33 | const ArticleLinkMarkImpl = ({ 34 | children, 35 | target, 36 | anchor, 37 | isInFirstCategory, 38 | }: { 39 | children: React.ReactNode 40 | isInFirstCategory: boolean 41 | } & ArticleLinkFragment) => { 42 | let href = getArticleSlugFromSlugPath(target._slugPath, isInFirstCategory) 43 | if (anchor) { 44 | href += `#${anchor.startsWith('#') ? anchor.slice(1) : anchor}` 45 | } 46 | 47 | return ( 48 | 49 | 50 | 51 | {children} 52 | 53 | 54 | 55 | 56 | {target.sidebarOverrides.title || target._title} 57 | 58 | {target.excerpt && ( 59 | 72 | {target.excerpt} 73 | 74 | )} 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/_components/article/accordion/accordion.module.scss: -------------------------------------------------------------------------------- 1 | .accordion-root { 2 | margin-block: var(--space-6); 3 | 4 | .accordion-item { 5 | padding: 0; 6 | border-radius: var(--radius-5); 7 | border: 1px solid var(--gray-5); 8 | margin: 0 0 var(--space-3) 0; 9 | background-color: var(--gray-1); 10 | 11 | &:last-child { 12 | margin-bottom: 0; 13 | } 14 | 15 | h3 { 16 | margin: 0; 17 | } 18 | 19 | button[data-type='accordion-trigger'] { 20 | width: 100%; 21 | padding: var(--space-4); 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | border-radius: calc(var(--radius-5) - 1px); 26 | background-color: var(--gray-2); 27 | font-weight: 500; 28 | font-size: var(--font-size-3); 29 | border: none; 30 | text-align: left; 31 | 32 | &[data-state='open'] { 33 | border-bottom-right-radius: 0; 34 | border-bottom-left-radius: 0; 35 | background-color: var(--gray-1); 36 | } 37 | 38 | &:hover { 39 | span:has(svg) { 40 | border-color: var(--gray-8); 41 | } 42 | } 43 | 44 | span:has(svg) { 45 | flex-shrink: 0; 46 | width: calc(20px * var(--scaling)); 47 | height: calc(20px * var(--scaling)); 48 | display: inline-flex; 49 | justify-content: center; 50 | align-items: center; 51 | border-radius: var(--space-1); 52 | border: 1px solid var(--gray-6); 53 | background-color: var(--gray-2); 54 | 55 | svg { 56 | transition: transform 0.2s ease-out; 57 | } 58 | } 59 | 60 | &[data-state='open'] { 61 | span:has(svg) { 62 | svg { 63 | transform: rotate(90deg); 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | .accordion-content { 71 | padding: 0 var(--space-3) var(--space-3); 72 | margin: 0 var(--space-2) var(--space-2); 73 | border-radius: 0px 0px var(--radius-3) var(--radius-3); 74 | border-bottom: 1px solid var(--gray-3); 75 | position: relative; 76 | background: linear-gradient(180deg, transparent 3.75%, var(--gray-2) 100%); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/_components/article/accordion/accordion.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AccordionPrimitive from '@radix-ui/react-accordion' 5 | import { ChevronRightIcon } from '@radix-ui/react-icons' 6 | import { Text } from '@radix-ui/themes' 7 | 8 | import s from './accordion.module.scss' 9 | 10 | const Accordion = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >((props, ref) => ( 14 | 19 | )) 20 | Accordion.displayName = 'Accordion' 21 | 22 | const AccordionItem = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >((props, ref) => ( 26 | 31 | )) 32 | AccordionItem.displayName = 'AccordionItem' 33 | 34 | const AccordionTrigger = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, children, ...props }, ref) => ( 38 | 39 | 45 | {children} 46 | 47 | 48 | 49 | 50 | 51 | )) 52 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 53 | 54 | const AccordionContent = React.forwardRef< 55 | React.ElementRef, 56 | React.ComponentPropsWithoutRef 57 | >(({ className, children, ...props }, ref) => ( 58 | 63 | 64 | {children} 65 | 66 | 67 | )) 68 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 69 | 70 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 71 | -------------------------------------------------------------------------------- /app/_components/article/accordion/fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | 3 | export const AccordionGroupFragment = fragmentOn('AccordionGroupComponent', { 4 | _id: true, 5 | accordions: { 6 | items: { _id: true, _title: true, content: { json: { content: true } } }, 7 | }, 8 | }) 9 | 10 | export type AccordionGroupFragment = fragmentOn.infer< 11 | typeof AccordionGroupFragment 12 | > 13 | -------------------------------------------------------------------------------- /app/_components/article/accordion/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from './accordion' 7 | import { Body } from '..' 8 | import { AccordionGroupFragment } from './fragment' 9 | 10 | export const AccordionComponent = ({ accordions }: AccordionGroupFragment) => { 11 | return ( 12 | 13 | {accordions.items.map((accordion) => ( 14 | 15 | {accordion._title} 16 | 17 | {accordion.content?.json.content} 18 | 19 | 20 | ))} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/_components/article/article-index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ArticleFragment } from '@/basehub-helpers/fragments' 4 | import { Card, Grid, Heading, Text } from '@radix-ui/themes' 5 | import { usePathname } from 'next/navigation' 6 | import Link from 'next/link' 7 | 8 | export const ArticleIndex = ({ 9 | articles, 10 | }: { 11 | articles: ArticleFragment['children']['items'] 12 | }) => { 13 | const pathname = usePathname() 14 | 15 | return ( 16 | 17 | {articles.map((article) => { 18 | return ( 19 | 20 | 21 | 22 | {article._title ?? 'Untiled article'} 23 | 24 | {article.excerpt && ( 25 | 26 | {article.excerpt} 27 | 28 | )} 29 | 30 | 31 | ) 32 | })} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/_components/article/article.module.scss: -------------------------------------------------------------------------------- 1 | .article { 2 | min-height: calc(100svh - var(--header) - var(--space-9)); 3 | 4 | @media screen and (min-width: 1024px) { 5 | max-width: calc(716px * var(--scaling)); 6 | margin: 0 auto; 7 | padding-left: var(--space-9); 8 | } 9 | 10 | @media screen and (min-width: 1280px) { 11 | padding-right: var(--space-9); 12 | } 13 | } 14 | 15 | .body { 16 | width: 100%; 17 | 18 | > p { 19 | color: var(--gray-11); 20 | font-size: var(--font-size-3); 21 | line-height: var(--line-height-5); 22 | } 23 | 24 | code[data-type='inline-code'] { 25 | font-family: var(--font-geist-mono); 26 | white-space: nowrap; 27 | } 28 | 29 | p + p { 30 | margin-top: var(--space-4); 31 | } 32 | 33 | ul, 34 | ol { 35 | margin-top: var(--space-4); 36 | margin-bottom: var(--space-4); 37 | padding-left: calc(20px * var(--scaling)); 38 | 39 | li { 40 | &::marker { 41 | color: var(--gray-11); 42 | } 43 | 44 | color: var(--gray-11); 45 | margin-bottom: var(--space-2); 46 | } 47 | } 48 | 49 | picture { 50 | background-color: var(--gray-2); 51 | padding: var(--space-1); 52 | display: flex; 53 | max-width: max-content; 54 | margin-block: var(--space-6); 55 | border-radius: var(--radius-4); 56 | border: 1px solid var(--gray-5); 57 | z-index: 1; 58 | position: relative; 59 | box-shadow: 0px 1px 2px 0px var(--gray-6); 60 | box-sizing: border-box; 61 | 62 | video, 63 | img { 64 | border-radius: var(--radius-3); 65 | position: relative; 66 | max-width: 100%; 67 | height: auto; 68 | } 69 | 70 | img { 71 | cursor: zoom-in; 72 | } 73 | 74 | iframe { 75 | border: none; 76 | width: 100%; 77 | aspect-ratio: 16 / 9; 78 | border-radius: var(--radius-3); 79 | } 80 | } 81 | 82 | figcaption { 83 | padding-top: var(--space-5); 84 | padding-bottom: var(--space-2); 85 | margin-top: calc(var(--space-8) * -1); 86 | display: block; 87 | position: relative; 88 | text-align: center; 89 | z-index: 0; 90 | border-bottom-left-radius: var(--radius-4); 91 | border-bottom-right-radius: var(--radius-4); 92 | border: 1px solid var(--gray-5); 93 | background: var(--gray-3); 94 | color: var(--gray-11); 95 | font-size: var(--font-size-2); 96 | font-weight: 500; 97 | line-height: var(--space-4); 98 | } 99 | 100 | *[data-code-snippet] { 101 | button { 102 | display: none; 103 | } 104 | 105 | &:hover { 106 | button { 107 | display: block; 108 | } 109 | } 110 | } 111 | 112 | table { 113 | width: 100%; 114 | border-collapse: separate; 115 | border-spacing: 0; 116 | text-align: left; 117 | 118 | p { 119 | font-size: var(--font-size-2); 120 | line-height: 1.2; 121 | } 122 | 123 | a { 124 | font-size: var(--font-size-2); 125 | line-height: 1.2; 126 | } 127 | } 128 | 129 | > div, 130 | > blockquote { 131 | margin-top: var(--space-6); 132 | 133 | &:not(:last-child) { 134 | margin-bottom: var(--space-6); 135 | } 136 | } 137 | 138 | > *:first-child { 139 | margin-top: 0; 140 | } 141 | 142 | > *:last-child { 143 | margin-bottom: 0; 144 | } 145 | } 146 | 147 | .article-footer { 148 | width: 100%; 149 | } 150 | -------------------------------------------------------------------------------- /app/_components/article/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import NextLink from 'next/link' 5 | import { Flex, Link, Text } from '@radix-ui/themes' 6 | import { SlashIcon } from '@radix-ui/react-icons' 7 | 8 | export type ArticleBreadcrumb = { title: string; slug: string }[] 9 | 10 | export const ArticleBreadcrumb = ({ 11 | breadcrumb, 12 | }: { 13 | breadcrumb: ArticleBreadcrumb 14 | }) => { 15 | return ( 16 | 17 | {breadcrumb.map((segment, index) => { 18 | const href = `/${breadcrumb 19 | .slice(0, index + 1) 20 | .map((segment) => segment.slug) 21 | .filter((slug) => slug !== '') 22 | .join('/')}` 23 | 24 | if (index === breadcrumb.length - 1) { 25 | return ( 26 | 27 | 28 | {segment.title} 29 | 30 | {breadcrumb.length === 1 && ( 31 | 32 | )} 33 | 34 | ) 35 | } 36 | 37 | return ( 38 | 39 | 40 | {segment.title} 41 | 42 | 43 | 44 | ) 45 | })} 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/_components/article/callout-fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | import { ArticleLinkFragment } from '../article-link/fragment' 3 | 4 | export const CalloutFragment = fragmentOn('CalloutComponent', { 5 | _id: true, 6 | _title: true, 7 | content: { 8 | json: { 9 | content: true, 10 | blocks: { 11 | on_ArticleLinkComponent: ArticleLinkFragment, 12 | }, 13 | }, 14 | }, 15 | type: true, 16 | }) 17 | 18 | export type CalloutFragment = fragmentOn.infer 19 | -------------------------------------------------------------------------------- /app/_components/article/callout.tsx: -------------------------------------------------------------------------------- 1 | import { Callout, Code, Heading, Link, VisuallyHidden } from '@radix-ui/themes' 2 | import NextLink from 'next/link' 3 | import { RichText } from 'basehub/react-rich-text' 4 | import { 5 | CheckCircledIcon, 6 | ExclamationTriangleIcon, 7 | InfoCircledIcon, 8 | Pencil1Icon, 9 | } from '@radix-ui/react-icons' 10 | import { ArticleLinkMark } from '../article-link/mark' 11 | import { CalloutFragment } from './callout-fragment' 12 | import { CodeSnippetRichText } from './code-snippet' 13 | 14 | export const CalloutComponent = (props: CalloutFragment) => { 15 | let calloutColor: React.ComponentProps['color'] = 'gray' 16 | 17 | let icon 18 | switch (props.type) { 19 | default: 20 | case 'info': 21 | calloutColor = 'gray' 22 | icon = 23 | break 24 | case 'check': 25 | calloutColor = 'green' 26 | icon = 27 | break 28 | case 'warning': 29 | calloutColor = 'yellow' 30 | icon = 31 | break 32 | case 'danger': 33 | calloutColor = 'red' 34 | icon = 35 | break 36 | case 'note': 37 | calloutColor = 'blue' 38 | icon = 39 | break 40 | } 41 | 42 | return ( 43 | 49 | {icon && ( 50 | 51 | {icon} 52 | {props.type}: 53 | 54 | )} 55 | 56 | ( 60 | {children} 61 | ), 62 | h1: ({ children }) => ( 63 | 64 | {children} 65 | 66 | ), 67 | h2: ({ children }) => ( 68 | 69 | {children} 70 | 71 | ), 72 | h3: ({ children }) => ( 73 | 74 | {children} 75 | 76 | ), 77 | a: ({ children, ...rest }) => ( 78 | 79 | {children} 80 | 81 | ), 82 | code: ({ children }) => { 83 | return {children} 84 | }, 85 | pre: CodeSnippetRichText, 86 | ArticleLinkComponent_mark: ArticleLinkMark, 87 | }} 88 | /> 89 | 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /app/_components/article/cards-grid-fragment.tsx: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | 3 | export const CardsGridFragment = fragmentOn('CardsGridComponent', { 4 | _id: true, 5 | _title: true, 6 | cards: { 7 | items: { 8 | _id: true, 9 | _title: true, 10 | href: true, 11 | description: true, 12 | icon: { 13 | light: { 14 | url: true, 15 | }, 16 | dark: { 17 | url: true, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }) 23 | 24 | export type CardsGridFragment = fragmentOn.infer 25 | -------------------------------------------------------------------------------- /app/_components/article/cards-grid.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { 3 | Box, 4 | Card, 5 | Grid, 6 | Heading, 7 | Text, 8 | VisuallyHidden, 9 | } from '@radix-ui/themes' 10 | import Link from 'next/link' 11 | import { CardsGridFragment } from './cards-grid-fragment' 12 | 13 | export const CardsGridComponent = ({ cards }: CardsGridFragment) => { 14 | return ( 15 | <> 16 | 1 ? '2' : '1' }} 18 | gap="4" 19 | data-llms-ignore 20 | > 21 | {cards.items.map((card) => { 22 | return ( 23 | 24 | 25 | {(card.icon.light || card.icon.dark) && ( 26 | 27 | {card.icon.light?.url && ( 28 | {card._title 34 | )} 35 | {card.icon.dark?.url && ( 36 | {card._title 42 | )} 43 | 44 | )} 45 | 46 | {card._title} 47 | 48 | {card.description && ( 49 | 50 | {card.description} 51 | 52 | )} 53 | 54 | 55 | ) 56 | })} 57 | 58 | 59 | {/* llms only */} 60 | 61 | {cards.items.map((card) => { 62 | return ( 63 | 64 | {card._title} 65 | {card.description && <>({card.description})} 66 | 67 | ) 68 | })} 69 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /app/_components/article/code-snippet/code-snippet.module.scss: -------------------------------------------------------------------------------- 1 | .code-snippet-header { 2 | border-radius: var(--radius-4) var(--radius-4) 0px 0px; 3 | border: 1px solid var(--gray-6); 4 | background: var(--gray-3); 5 | color: var(--normal); 6 | overflow-x: clip; 7 | font-size: var(--font-size-2); 8 | position: relative; 9 | font-style: normal; 10 | font-weight: 500; 11 | line-height: var(--space-4); 12 | height: var(--space-7); 13 | text-overflow: ellipsis; 14 | white-space: nowrap; 15 | } 16 | 17 | .code-snippet { 18 | position: relative; 19 | border-radius: var(--radius-4); 20 | 21 | div[data-snippet-id] { 22 | &:not(:first-of-type) { 23 | display: none; 24 | } 25 | } 26 | 27 | .code-snippet-header ~ div[data-snippet-id] { 28 | pre { 29 | border-top-left-radius: 0; 30 | border-top-right-radius: 0; 31 | border-top: 0; 32 | margin-top: 0; 33 | } 34 | } 35 | 36 | pre { 37 | &:focus { 38 | outline-color: var(--focus-9); 39 | } 40 | 41 | cursor: text; 42 | overflow-x: auto; 43 | line-height: calc(20px * var(--scaling)); 44 | border-radius: var(--radius-4); 45 | font-size: var(--font-size-2); 46 | margin-block: var(--space-6); 47 | border: 1px solid var(--gray-6); 48 | font-size: var(--font-size-1); 49 | font-weight: 500; 50 | position: relative; 51 | 52 | --shiki-color-text: #000; 53 | --shiki-background: var(--gray-2); 54 | --shiki-token-constant: var(--accent-12); 55 | --shiki-token-function: var(--gray-11); 56 | --shiki-token-string-expression: var(--gray-11); 57 | --shiki-token-string: var(--accent-indicator); 58 | --shiki-token-comment: var(--gray-10); 59 | --shiki-token-keyword: var(--accent-11); 60 | --shiki-token-parameter: #d6deeb; 61 | --shiki-token-punctuation: #c792e9; 62 | --shiki-token-link: #79b8ff; 63 | 64 | counter-reset: step; 65 | counter-increment: step 0; 66 | 67 | :global(.line::before) { 68 | content: counter(step); 69 | counter-increment: step; 70 | width: var(--space-4); 71 | display: inline-block; 72 | user-select: none; 73 | text-align: right; 74 | color: var(--gray-9); 75 | margin-right: var(--space-4); 76 | } 77 | 78 | :global(.line) { 79 | box-sizing: border-box; 80 | display: inline-block; 81 | width: 100%; 82 | padding-left: var(--space-3); 83 | padding-right: var(--space-3); 84 | 85 | &:first-child { 86 | margin-top: var(--space-3); 87 | } 88 | 89 | &:last-child { 90 | margin-bottom: var(--space-3); 91 | } 92 | 93 | &:global(.highlighted) { 94 | background-color: var(--gray-4); 95 | } 96 | :global(.highlighted-word) { 97 | background-color: var(--accent-5); 98 | color: var(--gray-12) !important; 99 | padding: 1px 0; 100 | margin: -1px 0; 101 | } 102 | &:global(.diff.remove) { 103 | background-color: var(--red-3); 104 | 105 | &::before { 106 | content: '-'; 107 | color: var(--red-12); 108 | } 109 | } 110 | &:global(.diff.add) { 111 | background-color: var(--green-3); 112 | 113 | &::before { 114 | content: '+'; 115 | color: var(--green-12); 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | .code-snippet--single { 123 | button { 124 | display: none; 125 | } 126 | 127 | &:hover { 128 | button { 129 | display: block; 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/_components/article/code-snippet/controller.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { CheckIcon, CopyIcon } from '@radix-ui/react-icons' 5 | 6 | import { IconButton } from '@radix-ui/themes' 7 | import { useCopyToClipboard } from 'basehub/react-code-block/client' 8 | 9 | export const CopyButton = ({ 10 | style, 11 | }: { 12 | style?: React.JSX.IntrinsicElements['button']['style'] 13 | }) => { 14 | const { onCopy, copied } = useCopyToClipboard({ copiedDurationMs: 2000 }) 15 | 16 | return ( 17 | 24 | {copied ? ( 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/_components/article/code-snippet/fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | 3 | export const CodeSnippetFragment = fragmentOn('CodeSnippetComponent', { 4 | _id: true, 5 | __typename: true, 6 | code: { code: true, language: true }, 7 | fileName: true, 8 | }) 9 | 10 | export const CodeSnippetFragmentRecursive = fragmentOn('CodeSnippetComponent', { 11 | _id: true, 12 | __typename: true, 13 | code: { 14 | __typename: true, 15 | code: true, 16 | language: true, 17 | }, 18 | fileName: true, 19 | targetOptionalToReuse: CodeSnippetFragment, 20 | }) 21 | 22 | export const CodeGroupFragment = fragmentOn('CodeGroupComponent', { 23 | _id: true, 24 | __typename: true, 25 | codeSnippets: { items: CodeSnippetFragmentRecursive }, 26 | }) 27 | 28 | export type CodeSnippetFragment = fragmentOn.infer 29 | -------------------------------------------------------------------------------- /app/_components/article/code-snippet/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { Flex, Tabs, VisuallyHidden } from '@radix-ui/themes' 5 | import { useCodeBlockContext } from 'basehub/react-code-block/client' 6 | 7 | import s from './code-snippet.module.scss' 8 | import { CopyButton } from './controller' 9 | 10 | export const CodeGroupHeader = () => { 11 | const highligterRef = React.useRef(null) 12 | const { activeSnippet, snippets, selectSnippet } = useCodeBlockContext() 13 | 14 | React.useEffect(() => { 15 | if (!highligterRef.current) return 16 | 17 | const activeButton = highligterRef.current.parentElement?.querySelector( 18 | '[data-active="true"]' 19 | ) as HTMLButtonElement | null 20 | 21 | if (activeButton) { 22 | const activeButtonComputedStyles = window.getComputedStyle(activeButton) 23 | highligterRef.current.style.left = `calc(${activeButton.offsetLeft}px + ${activeButtonComputedStyles.paddingLeft})` 24 | highligterRef.current.style.width = `calc(${activeButton.offsetWidth}px - ${activeButtonComputedStyles.paddingLeft} - ${activeButtonComputedStyles.paddingRight})` 25 | } 26 | }, [activeSnippet]) 27 | 28 | if (!activeSnippet) return null 29 | 30 | return ( 31 | <> 32 | 33 |
34 | {snippets.length > 1 ? ( 35 | { 39 | const selectedSnippet = snippets.find( 40 | (snippet) => snippet.id === _id 41 | ) 42 | if (selectedSnippet) selectSnippet(selectedSnippet) 43 | }} 44 | > 45 | 46 | {snippets.map((snippet) => ( 47 | 48 | {snippet.label || 'Untitled'} 49 | 50 | ))} 51 | 52 | 53 | ) : ( 54 | activeSnippet.label || 'Untitled' 55 | )} 56 | 57 | 58 |
59 |
60 | 61 | {/* llms only */} 62 | {snippets[0]?.label && ( 63 | {snippets[0].label} 64 | )} 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/_components/article/code-snippet/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock, createCssVariablesTheme } from 'basehub/react-code-block' 2 | 3 | import { CopyButton } from './controller' 4 | import { CodeGroupHeader } from './header' 5 | import { Box, Flex } from '@radix-ui/themes' 6 | import { CodeSnippetFragment } from './fragment' 7 | 8 | import s from './code-snippet.module.scss' 9 | import { HandlerProps } from 'basehub/react-rich-text' 10 | 11 | export const theme = createCssVariablesTheme({ 12 | name: 'css-variables', 13 | variablePrefix: '--shiki-', 14 | variableDefaults: {}, 15 | fontStyle: true, 16 | }) 17 | 18 | export const CodeSnippetGroup = ({ 19 | codeSnippets, 20 | }: { 21 | codeSnippets: { items: CodeSnippetFragment[] } 22 | }) => { 23 | return ( 24 |
25 | } 27 | snippets={codeSnippets.items.map((snippet) => { 28 | return { 29 | code: snippet.code.code, 30 | label: snippet.fileName || 'Untitled', 31 | id: snippet._id, 32 | language: snippet.code.language, 33 | } as const 34 | })} 35 | theme={theme} 36 | components={{ 37 | div: ({ children, ...rest }) => ( 38 | {children} 39 | ), 40 | }} 41 | /> 42 |
43 | ) 44 | } 45 | 46 | export const CodeSnippetItem = ({ 47 | children, 48 | ...rest 49 | }: { 50 | children: React.ReactNode 51 | }) => { 52 | return ( 53 |
54 | {children} 55 |
56 | ) 57 | } 58 | 59 | export const CodeSnippetSingle = (props: CodeSnippetFragment) => { 60 | return ( 61 | 62 | 65 |
66 | {props.fileName || 'Untitled'} 67 | 68 |
69 | 70 | } 71 | snippets={[ 72 | { 73 | code: props.code.code, 74 | id: props._id, 75 | language: props.code.language, 76 | }, 77 | ]} 78 | theme={theme} 79 | components={{ 80 | div: ({ children, ...rest }) => ( 81 | {children} 82 | ), 83 | }} 84 | /> 85 |
86 | ) 87 | } 88 | 89 | export const CodeSnippetRichText = ({ 90 | code, 91 | language, 92 | }: HandlerProps<'pre'>) => ( 93 | 94 | ( 99 | {children} 100 | ), 101 | pre: ({ style, ...rest }) => ( 102 |
103 |         ),
104 |       }}
105 |       childrenBottom={
106 |         
113 |       }
114 |     />
115 |   
116 | )
117 | 


--------------------------------------------------------------------------------
/app/_components/article/footer.tsx:
--------------------------------------------------------------------------------
 1 | import NextLink from 'next/link'
 2 | import { ChevronRightIcon } from '@radix-ui/react-icons'
 3 | import {
 4 |   Box,
 5 |   Card,
 6 |   Container,
 7 |   Flex,
 8 |   Link,
 9 |   Separator,
10 |   Text,
11 | } from '@radix-ui/themes'
12 | import { ArticleFragment } from '@/basehub-helpers/fragments'
13 | import { intlFormatDistance } from 'date-fns'
14 | import { Feedback } from '../feedback'
15 | import { basehub } from 'basehub'
16 | 
17 | import s from './article.module.scss'
18 | 
19 | export type ArticleFooter = {
20 |   lastUpdatedAt: ArticleFragment['_sys']['lastModifiedAt'] | null
21 |   nextArticle: { title: string; href: string } | null
22 | }
23 | 
24 | export const ArticleFooter = async ({
25 |   lastUpdatedAt,
26 |   nextArticle,
27 | }: ArticleFooter) => {
28 |   const lastUpdatedFormatted = intlFormatDistance(
29 |     new Date(lastUpdatedAt ?? new Date()),
30 |     new Date()
31 |   )
32 | 
33 |   const { feedback } = await basehub().query({
34 |     feedback: {
35 |       submissions: {
36 |         ingestKey: true,
37 |         schema: true,
38 |       },
39 |     },
40 |   })
41 | 
42 |   return (
43 |     
44 |       
45 | 46 | 56 | {lastUpdatedAt ? ( 57 | <>Last updated {lastUpdatedFormatted} 58 | ) : ( 59 | <>  60 | )} 61 | 62 | 66 | 67 | 68 | {nextArticle && ( 69 | 70 | 71 | 72 | 73 | 74 | 75 | {nextArticle.title || 'Untitled article'} 76 | 77 | 78 | 79 | 80 | 81 | Up next 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | )} 90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /app/_components/article/heading/heading.module.scss: -------------------------------------------------------------------------------- 1 | .heading { 2 | position: relative; 3 | scroll-margin-top: calc(var(--header) + var(--space-5)); 4 | color: var(--strong); 5 | line-height: 1.375; 6 | font-weight: 600; 7 | position: relative; 8 | text-wrap: pretty; 9 | 10 | @media screen and (min-width: 1024px) { 11 | scroll-margin-top: calc(var(--header) + var(--space-6)); 12 | } 13 | 14 | b { 15 | font-weight: 600; 16 | } 17 | 18 | a { 19 | text-decoration: none; 20 | color: var(--gray-12); 21 | 22 | & > svg { 23 | vertical-align: middle; 24 | visibility: hidden; 25 | } 26 | 27 | &:hover { 28 | & > svg { 29 | visibility: visible; 30 | } 31 | } 32 | 33 | &:focus { 34 | & > svg { 35 | visibility: visible; 36 | } 37 | } 38 | 39 | &:focus-visible { 40 | & > svg { 41 | visibility: visible; 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/_components/article/heading/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { Link2Icon } from '@radix-ui/react-icons' 5 | import { Box, Link, Heading as RadixHeading } from '@radix-ui/themes' 6 | 7 | import NextLink from 'next/link' 8 | 9 | import s from './heading.module.scss' 10 | 11 | type HeadingProps = React.JSX.IntrinsicElements['h1'] & { 12 | as: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' 13 | href?: string 14 | } 15 | 16 | export const Heading = ({ children, as, id, href }: HeadingProps) => { 17 | const size = as === 'h1' ? '8' : as === 'h2' ? '6' : as === 'h3' ? '4' : '3' 18 | const marginTop = as === 'h1' ? '8' : as === 'h2' ? '7' : '6' 19 | 20 | return ( 21 | 29 | {!id ? ( 30 | children 31 | ) : ( 32 | 33 | { 36 | if (href || !id) return 37 | 38 | navigator.clipboard.writeText( 39 | window.location.origin + window.location.pathname + `#${id}` 40 | ) 41 | }} 42 | > 43 | {children} 44 | {!href && ( 45 | 52 | 53 | 54 | )} 55 | 56 | 57 | )} 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/_components/article/iframe/fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from 'basehub' 2 | 3 | export const IFrameFragment = fragmentOn('IFrameComponent', { 4 | _id: true, 5 | src: true, 6 | }) 7 | 8 | export type IFrameFragment = fragmentOn.infer 9 | -------------------------------------------------------------------------------- /app/_components/article/iframe/iframe-with-fade-in.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import * as React from 'react' 3 | import { IFrameFragment } from './fragment' 4 | 5 | export const IFrameWithFadeIn = (props: IFrameFragment) => { 6 | const [loaded, setLoaded] = React.useState(false) 7 | 8 | return ( 9 |