diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c93ae8d..128d3b02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ The repository is structured as follows: - `api`: The API server which is served via `https://api.docs.page`. This is an express application which handles tasks such as fetching content from GitHub and markdown parsing. - `og`: A Next.js application which serves the Open Graph images for documentation pages. -- `website`: A Remix application which serves the main `https://docs.page` website, and the documentation rendering for each repository. +- `website`: A Next.js application which serves the main `https://docs.page` website, and the documentation rendering for each repository. - `packages/cli`: A CLI for running various commands and scripts for initialization, checking etc. Used locally and on CI environments. ## Running docs.page @@ -21,6 +21,6 @@ Generally, you'll want to interface with the website and api. To run these concu bun dev ``` -This will start the website on `http://localhost:5173` and the api on `http://localhost:8080`. +This will start the website on `http://localhost:3000` and the api on `http://localhost:8080`. > The API requires a `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` to be set in your environment. These are used to authenticate with the GitHub API. You can create a GitHub App in your GitHub account settings. \ No newline at end of file diff --git a/api/src/config/v1.schema.ts b/api/src/config/v1.schema.ts index f2cd52a9..cca68f91 100644 --- a/api/src/config/v1.schema.ts +++ b/api/src/config/v1.schema.ts @@ -78,10 +78,12 @@ export const V1ConfigSchema = z const config: Config = { name: v1.name, description: v1.description, - favicon: v1.favicon, + favicon: { + light: v1.favicon, + dark: v1.favicon, + }, socialPreview: v1.socialPreview, logo: { - href: "/", light: v1.logo, dark: v1.logoDark, }, diff --git a/bun.lockb b/bun.lockb index 59fb2a70..85f3007e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/og/package.json b/og/package.json index 626b3cc1..3dd12601 100644 --- a/og/package.json +++ b/og/package.json @@ -15,8 +15,8 @@ "@types/react-dom": "18.0.10", "@vercel/og": "^0.0.21", "next": "13.1.0", - "react": "18.3.0-canary-bb0944fe5-20240313", - "react-dom": "18.2.0", + "react": "18.3.1", + "react-dom": "18.3.1", "typescript": "4.9.4" } } diff --git a/package.json b/package.json index 147f79d5..e9e73d81 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "dev": "concurrently \"npm run dev:api\" \"npm run dev:website\"", "dev:api": "cd api && bun dev", "dev:website": "cd website && npm run dev", - "check": "npx @biomejs/biome check --write ." + "check": "bunx @biomejs/biome check --write ." }, "dependencies": { "typescript": "^5.5.3" @@ -13,12 +13,7 @@ "@biomejs/biome": "1.8.3", "concurrently": "^7.0.0" }, - "workspaces": [ - "api", - "website", - "og", - "packages/*" - ], + "workspaces": ["api", "website", "og", "packages/*"], "patchedDependencies": { "@remix-run/react@2.9.2": "patches/@remix-run%2Freact@2.9.2.patch" } diff --git a/website/.gitignore b/website/.gitignore index 7c028014..fd3dbb57 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -1,6 +1,36 @@ -node_modules +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -/.cache +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production /build -.env + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel .vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/website/.npmrc b/website/.npmrc deleted file mode 100644 index 165d6cf0..00000000 --- a/website/.npmrc +++ /dev/null @@ -1 +0,0 @@ -force=true \ No newline at end of file diff --git a/website/README.md b/website/README.md index 6d3efb51..b1d47025 100644 --- a/website/README.md +++ b/website/README.md @@ -1,36 +1,5 @@ -# Welcome to Remix! +# docs.page -- 📖 [Remix docs](https://remix.run/docs) +This is the website for the docs.page project, which hosts both the docs.page website and the documentation for projects. -## Development - -Run the dev server: - -```shellscript -npm run dev -``` - -## Deployment - -First, build your app for production: - -```sh -npm run build -``` - -Then run the app in production mode: - -```sh -npm start -``` - -Now you'll need to pick a host to deploy it to. - -### DIY - -If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. - -Make sure to deploy the output of `npm run build` - -- `build/server` -- `build/client` +This project is a managed workspace using Bun - please see the contributing guide for more information and how to get started. \ No newline at end of file diff --git a/website/app/components/DocSearch.tsx b/website/app/components/DocSearch.tsx deleted file mode 100644 index e6daa512..00000000 --- a/website/app/components/DocSearch.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import docsearch from "@docsearch/js"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -type Props = { - appId: string; - indexName: string; - apiKey: string; -}; - -export type DocSearchHandle = { - trigger(): void; -}; - -// This is a wrapper around the DocSearch library that allows us to trigger the search programmatically, -// without rendering the styled input the library provides. -const DocSearch = forwardRef<DocSearchHandle, Props>( - ({ appId, indexName, apiKey }, ref) => { - // A ref for the parent container where DocSearch will be mounted. - const container = useRef<HTMLDivElement>(null); - const button = useRef<HTMLButtonElement | null>(null); - - // Enable the parent component to trigger the search programmatically. - useImperativeHandle( - ref, - () => { - return { - trigger() { - if (button.current) { - button.current.click(); - } - }, - }; - }, - [], - ); - - useEffect(() => { - if (!container.current) return; - - // Apply the DocSearch logic to the hidden element. - docsearch({ - container: container.current, - appId, - indexName, - apiKey, - }); - - // Get the direct child (button) of the container. - const mounted = container.current.firstElementChild; - - // Store the button element in the ref. - if (mounted) { - button.current = mounted as HTMLButtonElement; - } - }, [apiKey, appId, indexName]); - - // Hide the element so we can trigger the search programmatically. - return <div ref={container} className="hidden" />; - }, -); - -export default DocSearch; diff --git a/website/app/entry.client.tsx b/website/app/entry.client.tsx deleted file mode 100644 index 9cc7ae4e..00000000 --- a/website/app/entry.client.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { RemixBrowser } from "@remix-run/react"; -import { StrictMode, startTransition } from "react"; -import { hydrateRoot } from "react-dom/client"; -import type { DocsPageContext } from "~/context"; - -declare global { - interface Window { - __docsPage?: DocsPageContext; - } -} - -// A list of base domains which can run this app in production. -const DOMAINS = ["docs.page", "staging.docs.page"]; - -if (window.__docsPage) { - const hostname = window.location.hostname; - - // Check if the current hostname is a vanity domain (e.g. `:org.docs.page`). - const isVanityDomain = - hostname.includes(".docs.page") && !DOMAINS.includes(hostname); - - // It's a custom domain if it's not a vanity domain and it's not one of the base domains. - const isCustomDomain = !isVanityDomain && !DOMAINS.includes(hostname); - - if (isVanityDomain || isCustomDomain) { - window.__remixContext.url = window.location.pathname; - } - - // // const { owner, repository } = window.__docsPage; - - // // A vanity domain is a rewrite request. - // if (isVanityDomain) { - // // const basename = `/${owner}`; - - // // window.__remixContext.basename = basename; - - // // Remove the owner from the URL (since it's now part of the hostname). - // window.__remixContext.url = window.location.pathname; - - // console.log("Rewriting context for vanity domain: ", { - // basename: window.__remixContext.basename, - // url: window.__remixContext.url, - // }); - // } - // // A custom domain is a proxy request. - // else if (isCustomDomain) { - // // console.log("Custom domain detected: ", { hostname, owner, repository }); - // // const basename = `/${owner}/${repository}`; - - // // // Set the base name to the owner and repository (e.g. `/invertase/docs.page`). - // // // window.__remixContext.basename = ''; - - // // // // Replace the URL which includes the repository with the correct URL. - // // // // For example: `/invertase/docs.page/configuration` -> `/configuration`. - // // // window.__remixContext.url = '/invertase/docs.page/configuration'; - - // // console.log("Rewriting context for custom domain: ", { - // // basename: window.__remixContext.basename, - // // url: window.__remixContext.url, - // // }); - // } -} - -startTransition(() => { - hydrateRoot( - document, - <StrictMode> - <RemixBrowser /> - </StrictMode>, - ); -}); diff --git a/website/app/layouts/DocsLayout.tsx b/website/app/layouts/DocsLayout.tsx deleted file mode 100644 index 3b93242d..00000000 --- a/website/app/layouts/DocsLayout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useState } from "react"; -import { Content } from "~/components/Content"; -import { Edit } from "~/components/Edit"; -import { Footer } from "~/components/Footer"; -import { Header } from "~/components/Header"; -import { PreviousNext } from "~/components/PreviousNext"; -import { Scripts } from "~/components/Scripts"; -import { Sidebar } from "~/components/Sidebar"; -import { TableOfContents } from "~/components/TableOfContents"; -import { Tabs } from "~/components/Tabs"; -import { ThemeScripts } from "~/components/Theme"; -import { useTabs } from "~/context"; -import { cn } from "~/utils"; - -export function DocsLayout() { - const hasTabs = useTabs().length > 0; - const [sidebar, setSidebar] = useState(false); - - function toggleSidebar() { - setSidebar((prev) => { - const open = !prev; - - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - - return open; - }); - } - - return ( - <> - <ThemeScripts /> - <Scripts /> - <section className="fixed z-10 inset-x-0 top-0 bg-background-dark/90 backdrop-blur"> - <Header onMenuToggle={toggleSidebar} /> - <Tabs onMenuToggle={toggleSidebar} /> - </section> - <div className="max-w-8xl mx-auto px-5"> - <section - className={cn( - "fixed z-10 w-[17rem] bottom-0 overflow-y-auto translate-x-[-19rem] lg:translate-x-0 transition-transform", - { - "top-16": !hasTabs && !sidebar, - "top-28": hasTabs && !sidebar, - "translate-x-0 top-0 z-20 bg-background border-r border-black/10 dark:border-white/10": - sidebar, - }, - )} - > - <Sidebar onMenuToggle={toggleSidebar} /> - </section> - <div - className={cn("relative lg:pl-[17rem]", { - "pt-16": !hasTabs, - "pt-28": hasTabs, - })} - > - <div - role="button" - className={cn( - "bg-background/50 z-10 absolute inset-0 lg:opacity-0 transition-opacity", - { - "pointer-events-none opacity-0": !sidebar, - "pointer-events-auto opacity-100": sidebar, - }, - )} - onClick={() => toggleSidebar()} - onKeyDown={() => toggleSidebar()} - /> - <section className="pt-8 ps-4 lg:ps-16 pe-4 flex"> - <div className="min-w-0 flex-1 pr-0 xl:pr-12"> - <Content /> - <Edit /> - <PreviousNext /> - <div className="h-px bg-black/5 dark:bg-white/5 my-12" /> - <Footer /> - </div> - <div className="hidden xl:block relative lg:w-[17rem]"> - <TableOfContents /> - </div> - </section> - </div> - </div> - </> - ); -} diff --git a/website/app/meta.ts b/website/app/meta.ts deleted file mode 100644 index 4f3a92ed..00000000 --- a/website/app/meta.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { LinkDescriptor } from "@remix-run/node"; -import type { MetaDescriptor } from "@remix-run/react"; - -export function getMetadata(): MetaDescriptor[] { - const title = "docs.page | Ship documentation, like you ship code"; - const description = - "Publish beautiful online documentation instantly, from your code editor using markdown and a public GitHub repository."; - const image = "https://docs.page/social-preview.png"; - - return [ - { - title, - }, - { - name: "description", - content: description, - }, - { - property: "og:title", - content: title, - }, - { - property: "og:description", - content: description, - }, - { - property: "og:image", - content: image, - }, - { - name: "twitter:title", - content: title, - }, - { - name: "twitter:description", - content: description, - }, - { - name: "twitter:site", - content: "@invertaseio", - }, - { - name: "twitter:image", - content: image, - }, - ]; -} - -export function getLinkDescriptors(): LinkDescriptor[] { - return [ - { - rel: "apple-touch-icon", - sizes: "180x180", - href: "/_docs.page/favicon/apple-touch-icon.png", - }, - { - rel: "icon", - type: "image/png", - sizes: "32x32", - href: "/_docs.page/favicon/favicon-32x32.png", - }, - { - rel: "icon", - type: "image/png", - sizes: "16x16", - href: "/_docs.page/favicon/favicon-16x16.png", - }, - { - rel: "manifest", - href: "/_docs.page/favicon/site.webmanifest", - }, - { - rel: "mask-icon", - href: "/_docs.page/favicon/safari-pinned-tab.svg", - color: "#5bbad5", - }, - ]; -} diff --git a/website/app/root.tsx b/website/app/root.tsx deleted file mode 100644 index e67dec24..00000000 --- a/website/app/root.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import type { LinksFunction } from "@remix-run/node"; -import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, - isRouteErrorResponse, - useFetchers, - useLoaderData, - useNavigation, - useOutletContext, - useRouteError, - useRouteLoaderData, -} from "@remix-run/react"; - -import NProgress from "nprogress"; -import nProgressStyles from "nprogress/nprogress.css?url"; -import { type ReactElement, useEffect, useMemo } from "react"; -import zoomStyles from "react-medium-image-zoom/dist/styles.css?url"; -import { ErrorLayout } from "~/layouts/ErrorLayout"; -import type { BundleErrorResponse } from "./api"; -import styles from "./styles.css?url"; -import type { SharedEnvironmentVariables } from "./utils"; - -NProgress.configure({ showSpinner: false }); - -export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - { - rel: "stylesheet", - href: zoomStyles, - }, - { - rel: "stylesheet", - href: nProgressStyles, - }, -]; - -export const loader = () => { - return { - ENV: { - VERCEL: process.env.VERCEL, - VERCEL_ENV: process.env.VERCEL_ENV, - VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA, - } satisfies SharedEnvironmentVariables, - }; -}; - -export function Layout({ children }: { children: React.ReactNode }) { - const data = useRouteLoaderData<typeof loader>("root"); - - return ( - <html lang="en"> - <head> - <meta charSet="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <meta name="theme-color" content="#0B0D0E" /> - <link rel="preconnect" href="https://fonts.googleapis.com" /> - <link - rel="preconnect" - href="https://fonts.gstatic.com" - crossOrigin="anonymous" - /> - <link - href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=auto" - rel="stylesheet" - /> - <link - href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" - rel="stylesheet" - /> - <link href="/_docs.page/fa/fontawesome.min.css" rel="stylesheet" /> - <link href="/_docs.page/fa/brands.min.css" rel="stylesheet" /> - <link href="/_docs.page/fa/solid.min.css" rel="stylesheet" /> - <Meta /> - <Links /> - </head> - <body - className="antialiased bg-background text-gray-900 dark:text-gray-200" - style={{ - textRendering: "optimizeLegibility", - }} - > - <script - dangerouslySetInnerHTML={{ - __html: `window.ENV = ${ - data?.ENV ? JSON.stringify(data.ENV) : "{}" - }`, - }} - /> - {children} - <ScrollRestoration /> - <Scripts /> - </body> - </html> - ); -} - -export default function App() { - const navigation = useNavigation(); - const fetchers = useFetchers(); - - /** - * This gets the state of every fetcher active on the app and combine it with - * the state of the global transition (Link and Form), then use them to - * determine if the app is idle or if it's loading. - * Here we consider both loading and submitting as loading. - * https://sergiodxa.com/tutorials/use-nprogress-in-a-remix-app - */ - const state = useMemo<"idle" | "loading">( - function getGlobalState() { - const states = [ - navigation.state, - ...fetchers.map((fetcher) => fetcher.state), - ]; - if (states.every((state) => state === "idle")) return "idle"; - return "loading"; - }, - [navigation.state, fetchers], - ); - - useEffect(() => { - // and when it's something else it means it's either submitting a form or - // waiting for the loaders of the next location so we start it - if (state === "loading") NProgress.start(); - // when the state is idle then we can to complete the progress bar - if (state === "idle") NProgress.done(); - }, [state]); - - return <Outlet />; -} - -export function ErrorBoundary() { - const error = useRouteError(); - - let title = "Something went wrong"; - let description = "An unexpected error occurred."; - - const body = (children: ReactElement) => ( - <> - <script - dangerouslySetInnerHTML={{ - __html: ` - if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - document.documentElement.setAttribute('data-theme', 'dark'); - }`, - }} - /> - {children} - </> - ); - - if (isRouteErrorResponse(error)) { - const isBundleError = error.data !== null && typeof error.data === "object"; - - if (isBundleError) { - return body(<ErrorLayout error={error.data as BundleErrorResponse} />); - } - - if (error.status === 404) { - title = "Page not found"; - description = "The page you were looking for could not be found."; - } - } - - return body(<ErrorLayout title={title} description={description} />); -} diff --git a/website/app/routes/$/route.tsx b/website/app/routes/$/route.tsx deleted file mode 100644 index 82fc4bd1..00000000 --- a/website/app/routes/$/route.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { - type MetaDescriptor, - type MetaFunction, - useLoaderData, -} from "@remix-run/react"; -import type { HeadersFunction, LoaderFunctionArgs } from "@vercel/remix"; -import { redirect } from "@vercel/remix"; -import { getBundle } from "~/api"; -import { Scripts } from "~/components/Scripts"; -import { type Context, PageContext } from "~/context"; -import { DocsLayout } from "~/layouts/DocsLayout"; - -import docsearch from "@docsearch/css/dist/style.css?url"; -import { trackPageRequest } from "~/plausible"; -import { - ensureLeadingSlash, - getAssetSrc, - getEnvironment, - getRequestParams, -} from "~/utils"; -import domains from "../../../../domains.json"; - -export const loader = async (args: LoaderFunctionArgs) => { - const requestUrl = new URL(args.request.url); - const { owner, repository, ref, path, vanity } = getRequestParams(args); - const environment = getEnvironment(); - - const bundle = await getBundle({ - owner, - repository, - path, - ref, - }).catch((response) => { - throw response; - }); - - // Check whether the repository has a domain assigned. - const domain = domains - .find(([, repo]) => repo === `${owner}/${repository}`) - ?.at(0); - - // Check if the user has set a redirect in the frontmatter of this page. - const redirectTo = - typeof bundle.frontmatter.redirect === "string" - ? bundle.frontmatter.redirect - : undefined; - - // Redirect to the specified URL. - if (redirectTo && redirectTo.length > 0) { - if (redirectTo.startsWith("http://") || redirectTo.startsWith("https://")) { - throw redirect(redirectTo); - } - - let url = ""; - if (vanity) { - url = `https://${owner}.docs.page/${repository}`; - if (ref) url += `~${ref}`; - url += redirectTo; - } else if (domain && environment === "production") { - // If there is a domain setup, always redirect to it. - url = `https://${domain}`; - if (ref) url += `/~${ref}`; - url += redirectTo; - } else { - // If no domain, redirect to docs.page. - url = `${requestUrl.origin}/${owner}/${repository}`; - if (ref) url += `~${ref}`; - url += redirectTo; - } - - console.log( - "Handling redirect", - redirectTo, - { owner, repository, ref, vanity, domain, environment }, - url - ); - - throw redirect(url); - } - - if (import.meta.env.PROD) { - // Track the page request. - await trackPageRequest(args.request, owner, repository); - } - - return { - path: ensureLeadingSlash(path), - owner, - repository, - ref, - domain: domain && environment === "production" ? domain : undefined, - vanity, - bundle, - preview: false, - } satisfies Context; -}; - -export default function DocsPage() { - const ctx = useLoaderData<typeof loader>(); - - return ( - <PageContext.Provider value={ctx}> - <script - dangerouslySetInnerHTML={{ - __html: `window.__docsPage = ${JSON.stringify(ctx)}`, - }} - /> - <Scripts /> - <DocsLayout /> - </PageContext.Provider> - ); -} - -export const headers: HeadersFunction = () => ({ - "Cache-Control": "s-maxage=1, stale-while-revalidate=59", -}); - -export const meta: MetaFunction<typeof loader> = ({ data: ctx }) => { - const descriptors: MetaDescriptor[] = []; - - if (!ctx) { - return descriptors; - } - - if (ctx.bundle.config.favicon?.light) { - descriptors.push({ - tagName: "link", - rel: "icon", - media: ctx.bundle.config.favicon?.dark - ? // If there is a dark favicon, add a media query to prefer light mode only. - "(prefers-color-scheme: light)" - : undefined, - href: getAssetSrc(ctx, ctx.bundle.config.favicon.light), - }); - } - - if (ctx.bundle.config.favicon?.dark) { - descriptors.push({ - tagName: "link", - rel: "icon", - media: ctx.bundle.config.favicon?.light - ? // If there is a light favicon, add a media query to prefer dark mode only. - "(prefers-color-scheme: dark)" - : undefined, - href: getAssetSrc(ctx, ctx.bundle.config.favicon.dark), - }); - } - - // Add noindex meta tag if the frontmatter or config has noindex set to true. - if ( - ctx.bundle.frontmatter.noindex === true || - ctx.bundle.config.seo?.noindex === true - ) { - descriptors.push({ - name: "robots", - content: "noindex", - }); - } - - const title = - ctx.bundle.frontmatter.title || ctx.bundle.config.name || "docs.page"; - const description = - ctx.bundle.frontmatter.description || ctx.bundle.config.description; - - let image = ctx.bundle.frontmatter.image - ? String(ctx.bundle.frontmatter.image) - : ctx.bundle.config.socialPreview; - - // If there is no image, generate one. - if (image === undefined) { - const params = JSON.stringify({ - owner: ctx.owner, - repository: ctx.repository, - title, - description, - // Use the light logo, and fallback to the dark logo - logo: - ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark - ? getAssetSrc( - ctx, - ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark || "" - ) - : undefined, - }); - - const base64String = - typeof window !== "undefined" - ? window.btoa(params) - : Buffer.from(params).toString("base64"); - - image = `https://og.docs.page?params=${base64String}`; - } - // If it has been set to false, disable the image. - else if (image === false) { - image = undefined; - } - // Otherwise the image is a path, so we need to resolve it. - else { - image = getAssetSrc(ctx, image); - } - - descriptors.push({ - title, - }); - - descriptors.push({ - property: "og:title", - content: title, - }); - - descriptors.push({ - name: "twitter:title", - content: title, - }); - - if (image) { - descriptors.push({ - name: "twitter:card", - content: "summary_large_image", - }); - - descriptors.push({ - name: "twitter:image", - content: image, - }); - - descriptors.push({ - property: "og:image", - content: image, - }); - } - - if (description) { - descriptors.push({ - name: "description", - content: description, - }); - descriptors.push({ - property: "og:description", - content: description, - }); - descriptors.push({ - name: "twitter:description", - content: description, - }); - } - - if ("domain" in ctx && ctx.domain) { - descriptors.push({ - property: "og:url", - content: `https://${ctx.domain}`, - }); - } - - if (ctx.bundle.config.social?.x) { - descriptors.push({ - name: "twitter:site", - content: `@${ctx.bundle.config.social.x}`, - }); - } - - if (ctx.bundle.config.search?.docsearch) { - // https://docsearch.algolia.com/docs/DocSearch-v3#preconnect - descriptors.push({ - tagName: "link", - rel: "preconnect", - crossOrigin: "true", - href: `https://${ctx.bundle.config.search?.docsearch.appId}-dsn.algolia.net`, - }); - - descriptors.push({ - tagName: "link", - rel: "stylesheet", - href: docsearch, - }); - } - - return descriptors; -}; diff --git a/website/app/routes/_layout.tsx b/website/app/routes/_layout.tsx deleted file mode 100644 index 7cd380cb..00000000 --- a/website/app/routes/_layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Outlet } from "@remix-run/react"; -import { useInlineScript } from "~/hooks"; - -export default function Layout() { - const scripts = useInlineScript(`<script>(() => { - document.documentElement.setAttribute('data-theme', 'dark'); - const root = document.documentElement; - root.style.setProperty('--background-dark', '224, 71%, 4%'); - })()</script>`); - - return ( - <> - {scripts} - <script - defer - data-domain="docs.page" - src="https://plausible.io/js/script.js" - /> - <Outlet /> - </> - ); -} diff --git a/website/app/routes/schema[.]json.ts b/website/app/routes/schema[.]json.ts deleted file mode 100644 index f16d8194..00000000 --- a/website/app/routes/schema[.]json.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const loader = async () => { - const response = await fetch("https://staging-api.docs.page/schema.json"); - return response.json(); -}; diff --git a/website/app/routes/_layout.preview.$/fsa.d.ts b/website/fsa.d.ts similarity index 100% rename from website/app/routes/_layout.preview.$/fsa.d.ts rename to website/fsa.d.ts diff --git a/website/next.config.mjs b/website/next.config.mjs new file mode 100644 index 00000000..7fd5de2a --- /dev/null +++ b/website/next.config.mjs @@ -0,0 +1,38 @@ +import path from "node:path"; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + typescript: { + // !! WARN !! + // Dangerously allow production builds to successfully complete even if + // your project has type errors. + // !! WARN !! + ignoreBuildErrors: true, + }, + webpack: (config) => { + // Allow transpiling TypeScript from an external package + config.module.rules.push({ + test: /\.ts$/, + include: [path.resolve(process.cwd(), "../packages/cli")], + use: { + loader: "ts-loader", + options: { + transpileOnly: true, // For faster compilation, but optional + }, + }, + }); + + return config; + }, + rewrites() { + return [ + { + source: "/schema.json", + destination: "https://staging-api.docs.page/schema.json", + }, + ]; + }, +}; + +export default nextConfig; diff --git a/website/package.json b/website/package.json index b56bd046..eaaefdcc 100644 --- a/website/package.json +++ b/website/package.json @@ -1,57 +1,45 @@ { - "name": "website-remix", + "name": "website-next", + "version": "0.1.0", "private": true, - "sideEffects": false, - "type": "module", "scripts": { - "build": "remix vite:build", - "dev": "remix vite:dev --host", - "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", - "start": "remix-serve ./build/server/index.js", - "typecheck": "tsc" + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" }, "dependencies": { "@docs.page/cli": "workspace:*", - "@docsearch/js": "^3.6.0", - "@headlessui/react": "^2.0.4", + "@docsearch/js": "^3.6.1", + "@headlessui/react": "^2.1.8", "@mdx-js/mdx": "^3.0.1", - "@remix-run/node": "2.9.2", - "@remix-run/react": "2.9.2", - "@remix-run/serve": "2.9.2", - "@tanstack/react-query": "^5.45.0", - "@types/wicg-file-system-access": "^2023.10.5", - "@vercel/remix": "2.9.2", + "@tanstack/react-query": "^5.56.2", "classnames": "^2.5.1", "color": "^4.2.3", "idb": "^8.0.0", - "isbot": "^4.1.0", - "lucide-react": "^0.394.0", - "mime": "^4.0.3", + "lucide-react": "^0.445.0", + "mime-db": "^1.53.0", + "mime-types": "^2.1.35", + "next": "14.2.13", "nprogress": "^0.2.0", - "react": "18.3.0-canary-bb0944fe5-20240313", - "react-dom": "18.3.0-canary-bb0944fe5-20240313", - "react-medium-image-zoom": "^5.2.4", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-medium-image-zoom": "^5.2.10", "request-ip": "^3.3.0", - "tailwind-merge": "^2.3.0" + "tailwind-merge": "^2.5.2" }, "devDependencies": { - "@remix-run/dev": "^2.9.2", - "@tailwindcss/typography": "^0.5.13", + "@tailwindcss/typography": "^0.5.15", "@types/color": "^3.0.6", + "@types/mime-types": "^2.1.4", + "@types/node": "^20", "@types/nprogress": "^0.2.3", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "18.3.8", + "@types/react-dom": "18.3.0", "@types/request-ip": "^0.0.41", - "@typescript-eslint/eslint-plugin": "^6.7.4", - "@typescript-eslint/parser": "^6.7.4", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.4", - "typescript": "5.5.2", - "vite": "^5.1.0", - "vite-tsconfig-paths": "^4.2.1" - }, - "engines": { - "node": ">=20.0.0" + "postcss": "^8", + "tailwindcss": "^3.4.1", + "ts-loader": "^9.5.1", + "typescript": "^5" } } diff --git a/website/postcss.config.js b/website/postcss.config.js deleted file mode 100644 index 2aa7205d..00000000 --- a/website/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/website/postcss.config.mjs b/website/postcss.config.mjs new file mode 100644 index 00000000..1a69fd2a --- /dev/null +++ b/website/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/website/public/assets/beautiful-by-design.png b/website/public/_docs.page/assets/beautiful-by-design.png similarity index 100% rename from website/public/assets/beautiful-by-design.png rename to website/public/_docs.page/assets/beautiful-by-design.png diff --git a/website/public/assets/collaborate-with-live-preview.png b/website/public/_docs.page/assets/collaborate-with-live-preview.png similarity index 100% rename from website/public/assets/collaborate-with-live-preview.png rename to website/public/_docs.page/assets/collaborate-with-live-preview.png diff --git a/website/public/assets/customise-and-theme.png b/website/public/_docs.page/assets/customise-and-theme.png similarity index 100% rename from website/public/assets/customise-and-theme.png rename to website/public/_docs.page/assets/customise-and-theme.png diff --git a/website/public/assets/get-started/add-content-editor.png b/website/public/_docs.page/assets/get-started/add-content-editor.png similarity index 100% rename from website/public/assets/get-started/add-content-editor.png rename to website/public/_docs.page/assets/get-started/add-content-editor.png diff --git a/website/public/assets/get-started/preview.png b/website/public/_docs.page/assets/get-started/preview.png similarity index 100% rename from website/public/assets/get-started/preview.png rename to website/public/_docs.page/assets/get-started/preview.png diff --git a/website/public/assets/get-started/publish.png b/website/public/_docs.page/assets/get-started/publish.png similarity index 100% rename from website/public/assets/get-started/publish.png rename to website/public/_docs.page/assets/get-started/publish.png diff --git a/website/public/assets/get-started/terminal.png b/website/public/_docs.page/assets/get-started/terminal.png similarity index 100% rename from website/public/assets/get-started/terminal.png rename to website/public/_docs.page/assets/get-started/terminal.png diff --git a/website/public/assets/manage-docs-as-code.png b/website/public/_docs.page/assets/manage-docs-as-code.png similarity index 100% rename from website/public/assets/manage-docs-as-code.png rename to website/public/_docs.page/assets/manage-docs-as-code.png diff --git a/website/public/assets/publish-instantly.png b/website/public/_docs.page/assets/publish-instantly.png similarity index 100% rename from website/public/assets/publish-instantly.png rename to website/public/_docs.page/assets/publish-instantly.png diff --git a/website/public/docs-page-hero-video.webm b/website/public/_docs.page/docs-page-hero-video.webm similarity index 100% rename from website/public/docs-page-hero-video.webm rename to website/public/_docs.page/docs-page-hero-video.webm diff --git a/website/public/logo.png b/website/public/_docs.page/logo.png similarity index 100% rename from website/public/logo.png rename to website/public/_docs.page/logo.png diff --git a/website/public/_docs.page/logo.webp b/website/public/_docs.page/logo.webp deleted file mode 100644 index a74c7a86..00000000 Binary files a/website/public/_docs.page/logo.webp and /dev/null differ diff --git a/website/public/social-preview.png b/website/public/_docs.page/social-preview.png similarity index 100% rename from website/public/social-preview.png rename to website/public/_docs.page/social-preview.png diff --git a/website/public/logo.svg b/website/public/logo.svg deleted file mode 100644 index 469073a4..00000000 --- a/website/public/logo.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="459" height="533" fill="none"><g filter="url(#filter0_d_262_362)"><path fill="url(#paint0_linear_262_362)" d="M200.693 529H4.813V19.91h193.892c52.367 0 97.608 10.19 135.724 30.574 38.281 20.218 67.779 49.385 88.494 87.5 20.881 37.95 31.321 83.44 31.321 136.471 0 53.03-10.357 98.603-31.072 136.718-20.715 37.95-50.047 67.117-87.997 87.5C297.225 518.891 252.398 529 200.693 529Zm-57.67-117.33h52.699c25.189 0 46.65-4.06 64.382-12.18 17.897-8.12 31.486-22.124 40.767-42.01 9.446-19.886 14.169-47.561 14.169-83.025 0-35.464-4.806-63.14-14.418-83.026-9.446-19.886-23.366-33.89-41.761-42.01-18.229-8.12-40.602-12.18-67.117-12.18h-48.721V411.67Z"/><path fill="#F3F3F3" d="M123 4H25v163l49-49 49 49V4Z"/></g><defs><linearGradient id="paint0_linear_262_362" x1="148.37" x2="148.378" y1="-5989.75" y2="9880.56" gradientUnits="userSpaceOnUse"><stop stop-color="#ECC918"/><stop offset="0.5" stop-color="#ECB118"/></linearGradient><filter id="filter0_d_262_362" width="457.432" height="533" x="0.8125" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_262_362"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_262_362" result="shape"/></filter></defs></svg> \ No newline at end of file diff --git a/website/app/api.ts b/website/src/api.ts similarity index 85% rename from website/app/api.ts rename to website/src/api.ts index 1a18cde2..3a7454e6 100644 --- a/website/app/api.ts +++ b/website/src/api.ts @@ -4,9 +4,8 @@ import type { BundlerOutput, SidebarGroup, } from "../../api/src/types"; - import { COMPONENTS } from "./components/Content"; -import { getBuildHash } from "./utils"; +import { getBuildHash } from "./env"; export type { BundleResponse, @@ -22,12 +21,7 @@ type GetBundleArgs = { path?: string; }; -const PRODUCTION = process.env.NODE_ENV === "production"; - -// const API_URL = -// process.env.API_URL || -// (PRODUCTION ? "https://api.docs.page" : "http://localhost:8080"); -const API_URL = "https://staging-api.docs.page"; +const API_URL = process.env.API_URL || "http://localhost:8080"; export async function getBundle(args: GetBundleArgs): Promise<BundlerOutput> { const params = new URLSearchParams({ @@ -68,11 +62,10 @@ type GetPreviewBundleArgs = { }; export async function getPreviewBundle( - args: GetPreviewBundleArgs, + args: GetPreviewBundleArgs ): Promise<BundlerOutput> { const response = await fetch(`${API_URL}/preview`, { method: "POST", - duplex: "half", headers: new Headers({ "docs-page-preview": "true", // Disables caching on preview requests }), diff --git a/website/app/components/Anchors.tsx b/website/src/components/Anchors.tsx similarity index 100% rename from website/app/components/Anchors.tsx rename to website/src/components/Anchors.tsx diff --git a/website/app/components/Button.tsx b/website/src/components/Button.tsx similarity index 79% rename from website/app/components/Button.tsx rename to website/src/components/Button.tsx index ee507f1f..501b6a0f 100644 --- a/website/app/components/Button.tsx +++ b/website/src/components/Button.tsx @@ -1,16 +1,17 @@ import { ChevronRightIcon } from "lucide-react"; -import { type ComponentProps, cloneElement, createElement } from "react"; +import Link from "next/link"; +import { type ComponentProps, cloneElement } from "react"; import { cn } from "~/utils"; type Props = | ({ - as: "a"; + element: "a"; href: string; children: string; cta?: boolean; } & ComponentProps<"a">) | ({ - as: "button"; + element: "button"; children: string; cta?: boolean; } & ComponentProps<"button">); @@ -18,10 +19,10 @@ type Props = export function Button({ className, cta, ...props }: Props) { let el: React.ReactNode; - if (props.as === "button") { + if (props.element === "button") { el = <button {...props} />; } else { - el = <a {...props} href={props.href} />; + el = <Link {...props} href={props.href} />; } const child = ( @@ -36,6 +37,7 @@ export function Button({ className, cta, ...props }: Props) { )} > <span>{props.children}</span> + {/* @ts-expect-error: Weird issue with icons */} <ChevronRightIcon size={18} /> </div> ); diff --git a/website/app/components/Content.tsx b/website/src/components/Content.tsx similarity index 100% rename from website/app/components/Content.tsx rename to website/src/components/Content.tsx diff --git a/website/src/components/DocSearch.tsx b/website/src/components/DocSearch.tsx new file mode 100644 index 00000000..cf1fe47b --- /dev/null +++ b/website/src/components/DocSearch.tsx @@ -0,0 +1,58 @@ +import docsearch from "@docsearch/js"; +import Head from "next/head"; +import { useEffect, useImperativeHandle, useRef } from "react"; +import "@docsearch/css/dist/style.css"; + +export type DocSearchProps = { + appId: string; + indexName: string; + apiKey: string; + forwardedRef?: React.ForwardedRef<DocSearchHandle>; +}; + +export type DocSearchHandle = { + trigger(): void; +}; + +export default function DocSearch(props: DocSearchProps) { + const { appId, indexName, apiKey, forwardedRef } = props; + const container = useRef<HTMLDivElement>(null); + const button = useRef<HTMLButtonElement | null>(null); + + useImperativeHandle(forwardedRef, () => ({ + trigger() { + if (button.current) { + button.current.click(); + } + }, + })); + + useEffect(() => { + if (!container.current) return; + + docsearch({ + container: container.current, + appId, + indexName, + apiKey, + }); + + const mounted = container.current.firstElementChild; + if (mounted) { + button.current = mounted as HTMLButtonElement; + } + }, [apiKey, appId, indexName]); + + return ( + <> + <Head> + <link + rel="preconnect" + crossOrigin="anonymous" + href={`https://${appId}-dsn.algolia.net`} + /> + </Head> + <div ref={container} className="hidden" /> + </> + ); +} diff --git a/website/app/components/Edit.tsx b/website/src/components/Edit.tsx similarity index 86% rename from website/app/components/Edit.tsx rename to website/src/components/Edit.tsx index 41cae486..6452dc6f 100644 --- a/website/app/components/Edit.tsx +++ b/website/src/components/Edit.tsx @@ -1,4 +1,4 @@ -import { PencilIcon } from "lucide-react"; +import { PencilIcon, StarIcon } from "lucide-react"; import { usePageContext, useSourceUrl } from "~/context"; export function Edit() { @@ -15,6 +15,7 @@ export function Edit() { href={url} className="group border border-black/20 dark:border-white/20 transition-all hover:border-black/70 hover:dark:border-white/70 rounded-md px-2.5 py-1.5 inline-flex items-center gap-2" > + {/* @ts-expect-error: Weird issue with icons */} <PencilIcon size={16} className="opacity-50 transition-all group-hover:opacity-75" diff --git a/website/app/components/Footer.tsx b/website/src/components/Footer.tsx similarity index 100% rename from website/app/components/Footer.tsx rename to website/src/components/Footer.tsx diff --git a/website/app/components/GitHubCard.tsx b/website/src/components/GitHubCard.tsx similarity index 95% rename from website/app/components/GitHubCard.tsx rename to website/src/components/GitHubCard.tsx index a6afe70d..33d08b26 100644 --- a/website/app/components/GitHubCard.tsx +++ b/website/src/components/GitHubCard.tsx @@ -39,12 +39,14 @@ export function GitHubCard() { </div> <div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity duration-200 ease-in-out"> <div className="flex items-center gap-1"> + {/* @ts-expect-error: Weird issue with icons */} <StarIcon size={12} /> <code className="text-[10px]"> {ctx.bundle.stars.toLocaleString()} </code> </div> <div className="flex items-center gap-1"> + {/* @ts-expect-error: Weird issue with icons */} <GitForkIcon size={12} /> <code className="text-[10px]"> {ctx.bundle.forks.toLocaleString()} diff --git a/website/app/components/Header.tsx b/website/src/components/Header.tsx similarity index 100% rename from website/app/components/Header.tsx rename to website/src/components/Header.tsx diff --git a/website/app/layouts/HeroGradient.tsx b/website/src/components/HeroGradient.tsx similarity index 100% rename from website/app/layouts/HeroGradient.tsx rename to website/src/components/HeroGradient.tsx diff --git a/website/app/components/Icon.tsx b/website/src/components/Icon.tsx similarity index 100% rename from website/app/components/Icon.tsx rename to website/src/components/Icon.tsx diff --git a/website/app/components/Link.tsx b/website/src/components/Link.tsx similarity index 78% rename from website/app/components/Link.tsx rename to website/src/components/Link.tsx index e4bddb5b..76d78f2b 100644 --- a/website/app/components/Link.tsx +++ b/website/src/components/Link.tsx @@ -1,4 +1,4 @@ -import { Link as InternalLink } from "@remix-run/react"; +import InternalLink from "next/link"; import type { ComponentProps } from "react"; import { useHref } from "~/context"; import { isExternalLink } from "~/utils"; @@ -14,5 +14,5 @@ export function Link(props: LinkProps) { ); } - return <InternalLink {...props} to={href} />; + return <InternalLink {...props} href={"/getting-started"} />; } diff --git a/website/app/components/Locale.tsx b/website/src/components/Locale.tsx similarity index 98% rename from website/app/components/Locale.tsx rename to website/src/components/Locale.tsx index 4378d4dc..72633a8d 100644 --- a/website/app/components/Locale.tsx +++ b/website/src/components/Locale.tsx @@ -26,6 +26,7 @@ export function Locale() { <Menu> <MenuButton className="flex items-center gap-1.5 bg-gray-200/60 hover:bg-gray-200/80 dark:bg-white/10 hover:dark:bg-white/20 transition-all rounded-full pl-4 pr-3 py-1.5 text-xs font-bold"> <span>{iso639LanguageCodes[locale]}</span> + {/* @ts-expect-error: Weird issue with icons */} <ChevronDownIcon size={12} /> </MenuButton> <Transition diff --git a/website/app/components/Logo.tsx b/website/src/components/Logo.tsx similarity index 100% rename from website/app/components/Logo.tsx rename to website/src/components/Logo.tsx diff --git a/website/app/components/MenuToggle.tsx b/website/src/components/MenuToggle.tsx similarity index 100% rename from website/app/components/MenuToggle.tsx rename to website/src/components/MenuToggle.tsx diff --git a/website/src/components/NProgress.tsx b/website/src/components/NProgress.tsx new file mode 100644 index 00000000..ec73c577 --- /dev/null +++ b/website/src/components/NProgress.tsx @@ -0,0 +1,34 @@ +import Router from "next/router"; +import NProgress from "nprogress"; + +let timer: Timer; +let state: string; +const delay = 250; + +NProgress.configure({ showSpinner: false }); + +function load() { + if (state === "loading") { + return; + } + + state = "loading"; + + timer = setTimeout(() => { + NProgress.start(); + }, delay); // only show progress bar if it takes longer than the delay +} + +function stop() { + state = "stop"; + clearTimeout(timer); + NProgress.done(); +} + +Router.events.on("routeChangeStart", load); +Router.events.on("routeChangeComplete", stop); +Router.events.on("routeChangeError", stop); + +export default function () { + return null; +} diff --git a/website/app/components/PreviousNext.tsx b/website/src/components/PreviousNext.tsx similarity index 100% rename from website/app/components/PreviousNext.tsx rename to website/src/components/PreviousNext.tsx diff --git a/website/app/components/RefBadge.tsx b/website/src/components/RefBadge.tsx similarity index 100% rename from website/app/components/RefBadge.tsx rename to website/src/components/RefBadge.tsx diff --git a/website/app/components/Scripts.tsx b/website/src/components/Scripts.tsx similarity index 90% rename from website/app/components/Scripts.tsx rename to website/src/components/Scripts.tsx index a8e62555..a979d753 100644 --- a/website/app/components/Scripts.tsx +++ b/website/src/components/Scripts.tsx @@ -1,5 +1,6 @@ +import Script from "next/script"; import { usePageContext } from "~/context"; -import { getEnvironment } from "~/utils"; +import { getEnvironment } from "~/env"; export function Scripts() { const ctx = usePageContext(); @@ -13,13 +14,13 @@ export function Scripts() { return ( <> {!!scripts?.googleTagManager && ( - <script + <Script async src={`https://www.googletagmanager.com/gtag/js?id=${scripts.googleTagManager}`} /> )} {!!scripts?.googleAnalytics && ( - <script + <Script async dangerouslySetInnerHTML={{ __html: ` @@ -30,7 +31,7 @@ export function Scripts() { /> )} {"domain" in ctx && !!ctx.domain && !!scripts?.plausible && ( - <script + <Script async defer data-domain={ctx.domain} diff --git a/website/app/components/Search.tsx b/website/src/components/Search.tsx similarity index 76% rename from website/app/components/Search.tsx rename to website/src/components/Search.tsx index 7f31a91e..e13e6729 100644 --- a/website/app/components/Search.tsx +++ b/website/src/components/Search.tsx @@ -1,12 +1,20 @@ import { SearchIcon } from "lucide-react"; -import { lazy, useRef } from "react"; +import dynamic from "next/dynamic"; +import { forwardRef, useRef } from "react"; import { usePageContext } from "~/context"; -import type { DocSearchHandle } from "./DocSearch"; -const DocSearch = lazy(() => import("./DocSearch")); +import type { DocSearchHandle, DocSearchProps } from "./DocSearch"; + +const DocSearchDynamic = dynamic(() => import("./DocSearch"), { + ssr: false, + loading: () => null, +}); + +const DocSearch = forwardRef<DocSearchHandle, DocSearchProps>((props, ref) => { + return <DocSearchDynamic {...props} forwardedRef={ref} />; +}); type Props = { - // If children are provided, will render as the child. children?: (toggle: () => void) => React.ReactNode; }; @@ -19,6 +27,7 @@ export function Search(props: Props) { const hasSearch = !!docsearch; function handleSearchEvent() { + console.log("handleSearchEvent", docsearchRef.current); if (docsearchRef.current) { docsearchRef.current.trigger(); } diff --git a/website/app/components/Sidebar.tsx b/website/src/components/Sidebar.tsx similarity index 94% rename from website/app/components/Sidebar.tsx rename to website/src/components/Sidebar.tsx index 409f9042..724826df 100644 --- a/website/app/components/Sidebar.tsx +++ b/website/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ -import { NavLink, useLocation } from "@remix-run/react"; import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; import { type ReactElement, cloneElement, useState } from "react"; import { useHref, usePageContext, useSidebar } from "~/context"; import { cn, getHref } from "~/utils"; @@ -77,7 +78,7 @@ function SidebarLinks( // Renders a group of sidebar links, either as a link or a group of links. function SidebarGroup(props: { group: Pages[number] } & { depth: number }) { - const location = useLocation(); + const location = useRouter(); const ctx = usePageContext(); // A recursive function to determine if this group @@ -90,7 +91,7 @@ function SidebarGroup(props: { group: Pages[number] } & { depth: number }) { } } else if (page.href) { const href = getHref(ctx, page.href); - if (location.pathname === href) { + if (location.asPath === href) { return true; } } @@ -149,19 +150,17 @@ function SidebarAnchor(props: { collapse?: ReactElement; onClick?: () => void; }) { + const router = useRouter(); const href = useHref(props.href ?? ""); const className = cn("relative group flex items-center pr-5 gap-2 py-2 pl-3"); const element = props.href ? ( - <NavLink - end - to={href} + <Link + href={href} onClick={props.collapse ? props.onClick : undefined} - className={({ isActive }) => - cn(className, { - "nav-link-active": isActive, - }) - } + className={cn(className, { + "nav-link-active": router.asPath === href, + })} /> ) : ( <div diff --git a/website/app/components/TableOfContents.tsx b/website/src/components/TableOfContents.tsx similarity index 100% rename from website/app/components/TableOfContents.tsx rename to website/src/components/TableOfContents.tsx diff --git a/website/app/components/Tabs.tsx b/website/src/components/Tabs.tsx similarity index 87% rename from website/app/components/Tabs.tsx rename to website/src/components/Tabs.tsx index 113c994b..24edaaf0 100644 --- a/website/app/components/Tabs.tsx +++ b/website/src/components/Tabs.tsx @@ -1,7 +1,6 @@ -import { NavLink } from "@remix-run/react"; -import { MenuIcon } from "lucide-react"; +import Link from "next/link"; import { useActiveTab, usePageContext, useTabs } from "~/context"; -import { cn, getHref, isExternalLink } from "~/utils"; +import { cn, getHref } from "~/utils"; import { MenuToggle } from "./MenuToggle"; type Props = { @@ -29,8 +28,8 @@ export function Tabs(props: Props) { return ( <li key={href}> - <NavLink - to={href} + <Link + href={href} className={cn( "relative top-px flex items-center h-12 border-b-[1.5px] border-transparent", { @@ -41,7 +40,7 @@ export function Tabs(props: Props) { )} > {tab.title} - </NavLink> + </Link> </li> ); })} diff --git a/website/app/components/Theme.tsx b/website/src/components/Theme.tsx similarity index 98% rename from website/app/components/Theme.tsx rename to website/src/components/Theme.tsx index ad8954c9..959bb9c0 100644 --- a/website/app/components/Theme.tsx +++ b/website/src/components/Theme.tsx @@ -1,7 +1,7 @@ import { Switch } from "@headlessui/react"; import Color from "color"; import { MoonIcon, SunDimIcon } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { type Context, usePageContext } from "~/context"; import { useInlineScript } from "~/hooks"; import { cn } from "~/utils"; @@ -95,7 +95,7 @@ export function ThemeToggle() { const key = getThemeKey(ctx); const [enabled, setEnabled] = useState<boolean>(); - useLayoutEffect(() => { + useEffect(() => { const isDark = document.documentElement.getAttribute("data-theme") === "dark"; setEnabled(isDark); diff --git a/website/app/components/mdx/Accordion.tsx b/website/src/components/mdx/Accordion.tsx similarity index 100% rename from website/app/components/mdx/Accordion.tsx rename to website/src/components/mdx/Accordion.tsx diff --git a/website/app/components/mdx/Callout.tsx b/website/src/components/mdx/Callout.tsx similarity index 100% rename from website/app/components/mdx/Callout.tsx rename to website/src/components/mdx/Callout.tsx diff --git a/website/app/components/mdx/Card.tsx b/website/src/components/mdx/Card.tsx similarity index 100% rename from website/app/components/mdx/Card.tsx rename to website/src/components/mdx/Card.tsx diff --git a/website/app/components/mdx/CodeBlock.tsx b/website/src/components/mdx/CodeBlock.tsx similarity index 100% rename from website/app/components/mdx/CodeBlock.tsx rename to website/src/components/mdx/CodeBlock.tsx diff --git a/website/app/components/mdx/CodeGroup.tsx b/website/src/components/mdx/CodeGroup.tsx similarity index 100% rename from website/app/components/mdx/CodeGroup.tsx rename to website/src/components/mdx/CodeGroup.tsx diff --git a/website/app/components/mdx/Heading.tsx b/website/src/components/mdx/Heading.tsx similarity index 100% rename from website/app/components/mdx/Heading.tsx rename to website/src/components/mdx/Heading.tsx diff --git a/website/app/components/mdx/Image.tsx b/website/src/components/mdx/Image.tsx similarity index 100% rename from website/app/components/mdx/Image.tsx rename to website/src/components/mdx/Image.tsx diff --git a/website/app/components/mdx/InvalidComponent.tsx b/website/src/components/mdx/InvalidComponent.tsx similarity index 95% rename from website/app/components/mdx/InvalidComponent.tsx rename to website/src/components/mdx/InvalidComponent.tsx index 3ade06dc..23788359 100644 --- a/website/app/components/mdx/InvalidComponent.tsx +++ b/website/src/components/mdx/InvalidComponent.tsx @@ -1,5 +1,4 @@ import type { ComponentProps } from "react"; -import { Error } from "./Callout"; type InvalidComponentProps = ComponentProps<"div"> & { // The name of the original component, provided by the bundler. diff --git a/website/app/components/mdx/Link.tsx b/website/src/components/mdx/Link.tsx similarity index 100% rename from website/app/components/mdx/Link.tsx rename to website/src/components/mdx/Link.tsx diff --git a/website/app/components/mdx/Property.tsx b/website/src/components/mdx/Property.tsx similarity index 100% rename from website/app/components/mdx/Property.tsx rename to website/src/components/mdx/Property.tsx diff --git a/website/app/components/mdx/Section.tsx b/website/src/components/mdx/Section.tsx similarity index 100% rename from website/app/components/mdx/Section.tsx rename to website/src/components/mdx/Section.tsx diff --git a/website/app/components/mdx/Steps.tsx b/website/src/components/mdx/Steps.tsx similarity index 100% rename from website/app/components/mdx/Steps.tsx rename to website/src/components/mdx/Steps.tsx diff --git a/website/app/components/mdx/Table.tsx b/website/src/components/mdx/Table.tsx similarity index 100% rename from website/app/components/mdx/Table.tsx rename to website/src/components/mdx/Table.tsx diff --git a/website/app/components/mdx/Tabs.tsx b/website/src/components/mdx/Tabs.tsx similarity index 100% rename from website/app/components/mdx/Tabs.tsx rename to website/src/components/mdx/Tabs.tsx diff --git a/website/app/components/mdx/Tweet.tsx b/website/src/components/mdx/Tweet.tsx similarity index 100% rename from website/app/components/mdx/Tweet.tsx rename to website/src/components/mdx/Tweet.tsx diff --git a/website/app/components/mdx/Video.tsx b/website/src/components/mdx/Video.tsx similarity index 100% rename from website/app/components/mdx/Video.tsx rename to website/src/components/mdx/Video.tsx diff --git a/website/app/components/mdx/Vimeo.tsx b/website/src/components/mdx/Vimeo.tsx similarity index 100% rename from website/app/components/mdx/Vimeo.tsx rename to website/src/components/mdx/Vimeo.tsx diff --git a/website/app/components/mdx/YouTube.tsx b/website/src/components/mdx/YouTube.tsx similarity index 100% rename from website/app/components/mdx/YouTube.tsx rename to website/src/components/mdx/YouTube.tsx diff --git a/website/app/components/mdx/Zapp.tsx b/website/src/components/mdx/Zapp.tsx similarity index 100% rename from website/app/components/mdx/Zapp.tsx rename to website/src/components/mdx/Zapp.tsx diff --git a/website/app/context.ts b/website/src/context.ts similarity index 67% rename from website/app/context.ts rename to website/src/context.ts index 61eb2f04..635fd598 100644 --- a/website/app/context.ts +++ b/website/src/context.ts @@ -1,6 +1,17 @@ +import type { GetServerSidePropsContext } from "next/types"; import { createContext, useContext, useEffect, useState } from "react"; -import type { BundlerOutput, SidebarGroup } from "./api"; -import { getAssetSrc, getHref, getLocale, isExternalLink } from "./utils"; +import domains from "../../domains.json"; +import { type BundlerOutput, type SidebarGroup, getBundle } from "./api"; +import { getEnvironment } from "./env"; +import { trackPageRequest } from "./plausible"; +import { + Redirect, + ensureLeadingSlash, + getAssetSrc, + getHref, + getLocale, + isExternalLink, +} from "./utils"; type BaseContext = { // The relative path of the current page, e.g. `/contributing`. @@ -22,17 +33,95 @@ export type DocsPageContext = BaseContext & { // The repository name, e.g. `docs.page`. repository: string; // The branch or tag of the repository, e.g. `main`. - ref?: string; + ref: string | null; // The domain assigned to the repository, e.g. `use.docs.page`. - domain?: string; + domain: string | null; // Whether the page is using a vanity domain, e.g. `:org.docs.page/repo` - vanity?: boolean; + vanity: boolean; // The page is not in preview mode. preview: false; }; export type Context = DocsPageContext | PreviewContext; +export async function getRequestContext( + request: GetServerSidePropsContext["req"], + opts: { + owner: string; + repository: string; + path: string; + ref: string | undefined; + } +) { + const { owner, repository, ref, path } = opts; + + const vanity = request.headers["x-docs-page-vanity-domain"] !== undefined; + + const bundle = await getBundle({ + owner, + repository, + path, + ref, + }); + + // Get the current environment. + const environment = getEnvironment(); + + // Check whether the repository has a domain assigned. + const domain = domains + .find(([, repo]) => repo === `${owner}/${repository}`) + ?.at(0); + + // Check if the user has set a redirect in the frontmatter of this page. + const redirectTo = + typeof bundle.frontmatter.redirect === "string" + ? bundle.frontmatter.redirect + : undefined; + + // Redirect to the specified URL. + if (redirectTo && redirectTo.length > 0) { + if (redirectTo.startsWith("http://") || redirectTo.startsWith("https://")) { + throw new Redirect(redirectTo); + } + + let url = ""; + if (vanity) { + url = `https://${owner}.docs.page/${repository}`; + if (ref) url += `~${ref}`; + url += redirectTo; + } else if (domain && environment === "production") { + // If there is a domain setup, always redirect to it. + url = `https://${domain}`; + if (ref) url += `/~${ref}`; + url += redirectTo; + } else { + const requestUrl = new URL(request.url!); + // If no domain, redirect to docs.page. + url = `${requestUrl.origin}/${owner}/${repository}`; + if (ref) url += `~${ref}`; + url += redirectTo; + } + + throw new Redirect(url); + } + + if (process.env.NODE_ENV === "production") { + // Track the page request. + await trackPageRequest(request, owner, repository); + } + + return { + path: ensureLeadingSlash(path), + owner, + repository, + ref: ref || null, + domain: domain && environment === "production" ? domain : null, + vanity, + bundle, + preview: false, + } satisfies Context; +} + export const PageContext = createContext<Context | undefined>(undefined); // Returns the current page context. @@ -41,7 +130,7 @@ export function usePageContext(): Context { if (!context) { throw new Error( - "usePageContext must be used within a PageContext.Provider", + "usePageContext must be used within a PageContext.Provider" ); } @@ -55,7 +144,7 @@ export function useAssetSrc(path: string) { const isExternal = isExternalLink(path); const [src, setSrc] = useState( - isExternal || !isPreview ? getAssetSrc(ctx, path) : "", + isExternal || !isPreview ? getAssetSrc(ctx, path) : "" ); useEffect(() => { diff --git a/website/src/env.ts b/website/src/env.ts new file mode 100644 index 00000000..9f644710 --- /dev/null +++ b/website/src/env.ts @@ -0,0 +1,38 @@ +export type SharedEnvironmentVariables = { + VERCEL: string | null; + VERCEL_ENV?: string | null; + VERCEL_GIT_COMMIT_SHA: string | null; +}; + +declare global { + interface Window { + ENV: SharedEnvironmentVariables; + } +} + +// Returns the shared environment variables, from either the server or the client. +export function getSharedEnvironmentVariables(): SharedEnvironmentVariables { + return typeof window === "undefined" + ? { + VERCEL: process.env.VERCEL || null, + VERCEL_ENV: process.env.VERCEL_ENV || null, + VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA || null, + } + : window.ENV; +} + +// Returns the current environment, either `production`, `preview` or `development`. +export function getEnvironment() { + const ENV = getSharedEnvironmentVariables(); + + return ENV.VERCEL + ? ENV.VERCEL_ENV === "production" + ? "production" + : "preview" + : "development"; +} + +// Returns the current build hash, either the Vercel Git commit SHA or `development`. +export function getBuildHash() { + return getSharedEnvironmentVariables().VERCEL_GIT_COMMIT_SHA || "development"; +} diff --git a/website/app/hooks.tsx b/website/src/hooks.tsx similarity index 90% rename from website/app/hooks.tsx rename to website/src/hooks.tsx index 97994b4e..1fcc047c 100644 --- a/website/app/hooks.tsx +++ b/website/src/hooks.tsx @@ -1,4 +1,4 @@ -import { RefObject, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; const scripts: Record<string, Promise<void>> = {}; @@ -35,23 +35,10 @@ function loadScript(src: string): Promise<void> { return scripts[src]; } -// Hook to load an external script if it hasn't been loaded yet. -export function useExternalScript(src: string): boolean { - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - loadScript(src) - .then(() => setLoaded(true)) - .catch(console.error); - }, [src]); - - return loaded; -} - export function useInlineScript(script: string) { const ref = useRef<HTMLDivElement>(null); - useLayoutEffect(() => { + useEffect(() => { if (ref.current) { ref.current.innerHTML = ""; const range = document.createRange(); @@ -63,3 +50,15 @@ export function useInlineScript(script: string) { return <div ref={ref} dangerouslySetInnerHTML={{ __html: script }} />; } + +export function useExternalScript(src: string): boolean { + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + loadScript(src) + .then(() => setLoaded(true)) + .catch(console.error); + }, [src]); + + return loaded; +} diff --git a/website/src/layouts/Documentation.tsx b/website/src/layouts/Documentation.tsx new file mode 100644 index 00000000..91a46d66 --- /dev/null +++ b/website/src/layouts/Documentation.tsx @@ -0,0 +1,212 @@ +import dynamic from "next/dynamic"; +import Head from "next/head"; +import Script from "next/script"; +import { useState } from "react"; +import { Content } from "~/components/Content"; +import { Edit } from "~/components/Edit"; +import { Footer } from "~/components/Footer"; +import { Header } from "~/components/Header"; +import { PreviousNext } from "~/components/PreviousNext"; +import { Scripts } from "~/components/Scripts"; +import { Sidebar } from "~/components/Sidebar"; +import { TableOfContents } from "~/components/TableOfContents"; +import { Tabs } from "~/components/Tabs"; +import { ThemeScripts } from "~/components/Theme"; +import { type Context, PageContext, useTabs } from "~/context"; +import { cn, getAssetSrc } from "~/utils"; + +import "nprogress/nprogress.css"; + +const NProgress = dynamic(() => import("~/components/NProgress"), { + ssr: false, +}); + +export function Documentation({ ctx }: { ctx: Context }) { + // Get the page title, starting with page frontmatter, then the config, and finally defaulting to "Documentation". + const title = + ctx.bundle.frontmatter.title || ctx.bundle.config.name || "Documentation"; + + // Get the page description, starting with page frontmatter, then the config, and finally defaulting to undefined. + const description = + ctx.bundle.frontmatter.description || ctx.bundle.config.description; + + // Get the page image, starting with page frontmatter, then the config, and finally defaulting to the social preview. + let image = ctx.bundle.frontmatter.image + ? String(ctx.bundle.frontmatter.image) + : ctx.bundle.config.socialPreview; + + // If there is no image, generate one. + if (image === undefined && !ctx.preview) { + const params = JSON.stringify({ + owner: ctx.owner, + repository: ctx.repository, + title, + description, + // Use the light logo, and fallback to the dark logo + logo: + ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark + ? getAssetSrc( + ctx, + ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark || "", + ) + : undefined, + }); + + const base64String = + typeof window !== "undefined" + ? window.btoa(params) + : Buffer.from(params).toString("base64"); + + image = `https://og.docs.page?params=${base64String}`; + } + // If it has been set to false, disable the image. + else if (image === false) { + image = undefined; + } + // Otherwise the image is a path, so we need to resolve it. + else { + image = getAssetSrc(ctx, String(image)); + } + + const noindex = + ctx.bundle.frontmatter.noindex === true || + ctx.bundle.config.seo?.noindex === true; + + return ( + <PageContext.Provider value={ctx}> + <Head> + <title>{String(title)}</title> + <meta property="og:title" content={String(title)} /> + <meta name="twitter:title" content={String(title)} /> + + {description ? ( + <> + <meta name="description" content={String(description)} /> + <meta property="og:description" content={String(description)} /> + <meta name="twitter:description" content={String(description)} /> + </> + ) : null} + + {image ? ( + <> + <meta property="og:image" content={image} /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:image" content={image} /> + </> + ) : null} + + {ctx.bundle.config.favicon?.light ? ( + <link + rel="icon" + media={ + ctx.bundle.config.favicon?.dark + ? // If there is a dark favicon, add a media query to prefer light mode only. + "(prefers-color-scheme: light)" + : undefined + } + href={getAssetSrc(ctx, ctx.bundle.config.favicon.light)} + /> + ) : null} + + {ctx.bundle.config.favicon?.dark ? ( + <link + rel="icon" + media={ + ctx.bundle.config.favicon?.light + ? // If there is a light favicon, add a media query to prefer dark mode only. + "(prefers-color-scheme: dark)" + : undefined + } + href={getAssetSrc(ctx, ctx.bundle.config.favicon.dark)} + /> + ) : null} + + {noindex ? <meta name="robots" content="noindex" /> : null} + </Head> + <Script + dangerouslySetInnerHTML={{ + __html: `window.__docsPage = ${JSON.stringify(ctx)}`, + }} + /> + <Scripts /> + <NProgress /> + <DocumentationLayout /> + </PageContext.Provider> + ); +} + +export function DocumentationLayout() { + const hasTabs = useTabs().length > 0; + const [sidebar, setSidebar] = useState(false); + + function toggleSidebar() { + setSidebar((prev) => { + const open = !prev; + + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return open; + }); + } + + return ( + <> + <ThemeScripts /> + <Scripts /> + <section className="fixed z-10 inset-x-0 top-0 bg-background-dark/90 backdrop-blur"> + <Header onMenuToggle={toggleSidebar} /> + <Tabs onMenuToggle={toggleSidebar} /> + </section> + <div className="max-w-8xl mx-auto px-5"> + <section + className={cn( + "fixed z-10 w-[17rem] bottom-0 overflow-y-auto translate-x-[-19rem] lg:translate-x-0 transition-transform", + { + "top-16": !hasTabs && !sidebar, + "top-28": hasTabs && !sidebar, + "translate-x-0 top-0 z-20 bg-background border-r border-black/10 dark:border-white/10": + sidebar, + }, + )} + > + <Sidebar onMenuToggle={toggleSidebar} /> + </section> + <div + className={cn("relative lg:pl-[17rem]", { + "pt-16": !hasTabs, + "pt-28": hasTabs, + })} + > + <div + role="button" + className={cn( + "bg-background/50 z-10 absolute inset-0 lg:opacity-0 transition-opacity", + { + "pointer-events-none opacity-0": !sidebar, + "pointer-events-auto opacity-100": sidebar, + }, + )} + onClick={() => toggleSidebar()} + onKeyDown={() => toggleSidebar()} + /> + <section className="pt-8 ps-4 lg:ps-16 pe-4 flex"> + <div className="min-w-0 flex-1 pr-0 xl:pr-12"> + <Content /> + <Edit /> + <PreviousNext /> + <div className="h-px bg-black/5 dark:bg-white/5 my-12" /> + <Footer /> + </div> + <div className="hidden xl:block relative lg:w-[17rem]"> + <TableOfContents /> + </div> + </section> + </div> + </div> + </> + ); +} diff --git a/website/app/layouts/ErrorLayout.tsx b/website/src/layouts/Error.tsx similarity index 98% rename from website/app/layouts/ErrorLayout.tsx rename to website/src/layouts/Error.tsx index 2cc1afe4..fff0df82 100644 --- a/website/app/layouts/ErrorLayout.tsx +++ b/website/src/layouts/Error.tsx @@ -20,7 +20,7 @@ type Props = error: BundleErrorResponse; }; -export function ErrorLayout(props: Props) { +export function Error(props: Props) { // If an error is thrown from the bundler, we can render // specific error messages based on the error code. if ("error" in props) { diff --git a/website/app/layouts/Footer.tsx b/website/src/layouts/Footer.tsx similarity index 98% rename from website/app/layouts/Footer.tsx rename to website/src/layouts/Footer.tsx index 6f6c1012..9ca45af8 100644 --- a/website/app/layouts/Footer.tsx +++ b/website/src/layouts/Footer.tsx @@ -5,7 +5,7 @@ export function Footer() { <footer className="max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-2 py-12"> <div className="w-full mx-auto md:flex justify-end md:border-r border-white/10 md:pr-12"> <div className="flex items-center gap-3 justify-center"> - <img src="/logo.png" alt="Logo" className="h-6" /> + <img src="/_docs.page/logo.png" alt="Logo" className="h-6" /> <svg xmlns="http://www.w3.org/2000/svg" width="87" diff --git a/website/app/layouts/Header.tsx b/website/src/layouts/Header.tsx similarity index 95% rename from website/app/layouts/Header.tsx rename to website/src/layouts/Header.tsx index e289203d..a068b9d8 100644 --- a/website/app/layouts/Header.tsx +++ b/website/src/layouts/Header.tsx @@ -1,12 +1,12 @@ -import { Link, NavLink } from "@remix-run/react"; import { ChevronRightIcon } from "lucide-react"; +import Link from "next/link"; import { Icon } from "~/components/Icon"; export function Header() { return ( <header className="max-w-5xl mx-auto py-8 px-3 flex flex-col sm:flex-row gap-6 items-center"> - <Link to="/" className="group flex items-center gap-3"> - <img src="/logo.png" alt="docs.page logo" className="h-6" /> + <Link href="/" className="group flex items-center gap-3"> + <img src="/_docs.page/logo.png" alt="docs.page logo" className="h-6" /> <svg xmlns="http://www.w3.org/2000/svg" width="87" @@ -56,15 +56,15 @@ export function Header() { </a> </li> <li> - <NavLink - to="/preview" + <Link + href="/preview" className="flex items-center gap-1 hover:opacity-75 transition-opacity border border-brand-50/90 px-4 py-1.5 rounded-full" > <span className="flex items-center gap-1"> <span>Local Preview</span> <ChevronRightIcon size={16} className="inline" /> </span> - </NavLink> + </Link> </li> </ul> {/* <div className="border rounded-md py-3 px-2"> diff --git a/website/src/layouts/Site.tsx b/website/src/layouts/Site.tsx new file mode 100644 index 00000000..d8783f0e --- /dev/null +++ b/website/src/layouts/Site.tsx @@ -0,0 +1,40 @@ +import Head from "next/head"; +import { useInlineScript } from "~/hooks"; + +const title = "docs.page | Ship documentation, like you ship code"; +const description = + "Publish beautiful online documentation instantly, from your code editor using markdown and a public GitHub repository."; +const image = "https://docs.page/_docs.page/social-preview.png"; + +export function Site({ children }: { children: React.ReactNode }) { + const scripts = useInlineScript(`<script>(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + const root = document.documentElement; + root.style.setProperty('--background-dark', '224, 71%, 4%'); + })()</script>`); + + return ( + <> + <Head> + <title>{title}</title> + <meta name="description" content={description} /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={description} /> + <meta property="og:image" content={image} /> + + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:title" content={title} /> + <meta name="twitter:description" content={description} /> + <meta name="twitter:image" content={image} /> + <meta name="twitter:site" content="@invertaseio" /> + </Head> + {scripts} + <script + defer + data-domain="docs.page" + src="https://plausible.io/js/script.js" + /> + {children} + </> + ); +} diff --git a/website/app/routes/_layout.get-started/Card.tsx b/website/src/layouts/get-started/Card.tsx similarity index 100% rename from website/app/routes/_layout.get-started/Card.tsx rename to website/src/layouts/get-started/Card.tsx diff --git a/website/app/routes/_layout.get-started/route.tsx b/website/src/layouts/get-started/index.tsx similarity index 89% rename from website/app/routes/_layout.get-started/route.tsx rename to website/src/layouts/get-started/index.tsx index ce3e890c..d7c4195a 100644 --- a/website/app/routes/_layout.get-started/route.tsx +++ b/website/src/layouts/get-started/index.tsx @@ -1,6 +1,3 @@ -import { type ComponentProps, useEffect, useState } from "react"; - -import { NavLink } from "@remix-run/react"; import { BookIcon, CheckIcon, @@ -8,26 +5,18 @@ import { ExternalLinkIcon, EyeIcon, } from "lucide-react"; +import Link from "next/link"; +import { type ComponentProps, useEffect, useState } from "react"; import { useInlineScript } from "~/hooks"; import { Footer } from "~/layouts/Footer"; import { Header } from "~/layouts/Header"; -import { getLinkDescriptors, getMetadata } from "~/meta"; import { cn } from "~/utils"; +import { Site } from "../Site"; import { Card } from "./Card"; -export const links = getLinkDescriptors; -export const meta = getMetadata; - export default function GetStartedRoute() { - const scripts = useInlineScript(`<script>(() => { - document.documentElement.setAttribute('data-theme', 'dark'); - const root = document.documentElement; - root.style.setProperty('--background-dark', '224, 71%, 4%'); - })()</script>`); - return ( - <> - {scripts} + <Site> <Header /> <section className="max-w-5xl w-full mx-auto py-20 px-6"> <div className=""> @@ -44,7 +33,7 @@ export default function GetStartedRoute() { </div> </section> <Footer /> - </> + </Site> ); } @@ -68,7 +57,7 @@ function Install() { description="Add docs.page to your project" asset={ <img - src="/assets/get-started/terminal.png" + src="/_docs.page/assets/get-started/terminal.png" alt="Terminal Command" className="" /> @@ -116,7 +105,7 @@ function AddContent() { description="Add markdown to a page" asset={ <img - src="/assets/get-started/add-content-editor.png" + src="/_docs.page/assets/get-started/add-content-editor.png" alt="Markdown" className="w-full" /> @@ -153,7 +142,7 @@ function PreviewDocs() { description="Preview your docs.page site" asset={ <img - src="/assets/get-started/preview.png" + src="/_docs.page/assets/get-started/preview.png" alt="Markdown" className="w-full" /> @@ -161,9 +150,9 @@ function PreviewDocs() { meta={ <p> View your documentation locally before publishing it to the web using{" "} - <NavLink to="/preview" className="underline"> + <Link href="/preview" className="underline"> Local Preview - </NavLink>{" "} + </Link>{" "} mode. </p> } @@ -194,7 +183,7 @@ function PublishChanges() { description="Make your changes public" asset={ <img - src="/assets/get-started/publish.png" + src="/_docs.page/assets/get-started/publish.png" alt="Markdown" className="w-full" /> diff --git a/website/app/routes/_layout._index/Afilliation.tsx b/website/src/layouts/homepage/Affiliation.tsx similarity index 100% rename from website/app/routes/_layout._index/Afilliation.tsx rename to website/src/layouts/homepage/Affiliation.tsx diff --git a/website/app/routes/_layout._index/CallToAction.tsx b/website/src/layouts/homepage/CallToAction.tsx similarity index 94% rename from website/app/routes/_layout._index/CallToAction.tsx rename to website/src/layouts/homepage/CallToAction.tsx index 84732433..17e28d3f 100644 --- a/website/app/routes/_layout._index/CallToAction.tsx +++ b/website/src/layouts/homepage/CallToAction.tsx @@ -1,5 +1,5 @@ import { HandshakeIcon } from "lucide-react"; -import { Button } from "../../components/Button"; +import { Button } from "~/components/Button"; export function CallToAction() { return ( diff --git a/website/app/routes/_layout._index/Demo.tsx b/website/src/layouts/homepage/Demo.tsx similarity index 82% rename from website/app/routes/_layout._index/Demo.tsx rename to website/src/layouts/homepage/Demo.tsx index 2cd168d2..a6e44994 100644 --- a/website/app/routes/_layout._index/Demo.tsx +++ b/website/src/layouts/homepage/Demo.tsx @@ -14,7 +14,10 @@ export function Demo() { preload="metadata" className="w-full rounded-xl" > - <source src="/docs-page-hero-video.webm#t=1" type="video/webm" /> + <source + src="/_docs.page/docs-page-hero-video.webm#t=1" + type="video/webm" + /> </video> </div> </section> diff --git a/website/app/routes/_layout._index/Features.tsx b/website/src/layouts/homepage/Features.tsx similarity index 99% rename from website/app/routes/_layout._index/Features.tsx rename to website/src/layouts/homepage/Features.tsx index 446c3708..13c350a7 100644 --- a/website/app/routes/_layout._index/Features.tsx +++ b/website/src/layouts/homepage/Features.tsx @@ -6,7 +6,6 @@ import { Grid2X2Icon, Heading1Icon, PencilIcon, - RefreshCcwDot, RefreshCcwDotIcon, SearchIcon, SwatchBookIcon, diff --git a/website/app/routes/_layout._index/Hero.tsx b/website/src/layouts/homepage/Hero.tsx similarity index 86% rename from website/app/routes/_layout._index/Hero.tsx rename to website/src/layouts/homepage/Hero.tsx index f253de3a..d45e5117 100644 --- a/website/app/routes/_layout._index/Hero.tsx +++ b/website/src/layouts/homepage/Hero.tsx @@ -1,4 +1,4 @@ -import { Button } from "../../components/Button"; +import { Button } from "~/components/Button"; export function Hero() { return ( @@ -17,12 +17,12 @@ export function Hero() { </p> <div className="flex flex-col sm:flex-row justify-center gap-3"> <div className="flex justify-center"> - <Button as="a" cta href="/get-started"> + <Button element="a" cta href="/get-started"> Start Publishing </Button> </div> <div className="flex justify-center"> - <Button as="a" href="https://use.docs.page"> + <Button element="a" href="https://use.docs.page"> Documentation </Button> </div> diff --git a/website/app/routes/_layout._index/Platform.tsx b/website/src/layouts/homepage/Platform.tsx similarity index 89% rename from website/app/routes/_layout._index/Platform.tsx rename to website/src/layouts/homepage/Platform.tsx index 351fa60b..ddbaf6c5 100644 --- a/website/app/routes/_layout._index/Platform.tsx +++ b/website/src/layouts/homepage/Platform.tsx @@ -1,4 +1,4 @@ -import { BookTextIcon, LockIcon } from "lucide-react"; +import { BookTextIcon } from "lucide-react"; const ASSET_VERSION = 2; @@ -89,7 +89,7 @@ function PlatformCard(props: PlatformCardProps) { function Manage() { return ( <img - src={`/assets/manage-docs-as-code.png?v=${ASSET_VERSION}`} + src={`/_docs.page/assets/manage-docs-as-code.png?v=${ASSET_VERSION}`} alt="Manage Docs as Code" className="absolute inset-x-0 top-[10px]" /> @@ -99,7 +99,7 @@ function Manage() { function BeautifulByDesign() { return ( <img - src={`/assets/beautiful-by-design.png?v=${ASSET_VERSION}`} + src={`/_docs.page/assets/beautiful-by-design.png?v=${ASSET_VERSION}`} alt="Publish Instantly" className="absolute inset-x-0 top-0 lg:top-2 scale-75 lg:scale-100" /> @@ -109,7 +109,7 @@ function BeautifulByDesign() { function Preview() { return ( <img - src={`/assets/collaborate-with-live-preview.png?v=${ASSET_VERSION}`} + src={`/_docs.page/assets/collaborate-with-live-preview.png?v=${ASSET_VERSION}`} alt="Publish Instantly" className="absolute inset-0 top-2 scale-95" /> @@ -119,7 +119,7 @@ function Preview() { function Publish() { return ( <img - src={`/assets/publish-instantly.png?v=${ASSET_VERSION}`} + src={`/_docs.page/assets/publish-instantly.png?v=${ASSET_VERSION}`} alt="Publish Instantly" className="absolute inset-x-0 top-2" /> @@ -129,7 +129,7 @@ function Publish() { function Customize() { return ( <img - src={`/assets/customise-and-theme.png?v=${ASSET_VERSION}`} + src={`/_docs.page/assets/customise-and-theme.png?v=${ASSET_VERSION}`} alt="Publish Instantly" /> ); diff --git a/website/app/routes/_layout._index/Testimonials.tsx b/website/src/layouts/homepage/Testimonials.tsx similarity index 100% rename from website/app/routes/_layout._index/Testimonials.tsx rename to website/src/layouts/homepage/Testimonials.tsx diff --git a/website/app/routes/_layout._index/route.tsx b/website/src/layouts/homepage/index.tsx similarity index 58% rename from website/app/routes/_layout._index/route.tsx rename to website/src/layouts/homepage/index.tsx index c29d1383..3fd42cf1 100644 --- a/website/app/routes/_layout._index/route.tsx +++ b/website/src/layouts/homepage/index.tsx @@ -1,20 +1,18 @@ +import { HeroGradient } from "~/components/HeroGradient"; +import { Footer } from "~/layouts/Footer"; import { Header } from "~/layouts/Header"; -import { getLinkDescriptors, getMetadata } from "~/meta"; -import { Footer } from "../../layouts/Footer"; -import { HeroGradient } from "../../layouts/HeroGradient"; -import { Affiliation } from "./Afilliation"; +import { Site } from "~/layouts/Site"; + +import { Affiliation } from "./Affiliation"; import { CallToAction } from "./CallToAction"; import { Demo } from "./Demo"; import { Features } from "./Features"; import { Hero } from "./Hero"; import { Platform } from "./Platform"; -export const links = getLinkDescriptors; -export const meta = getMetadata; - -export default function Homepage() { +export function Homepage() { return ( - <> + <Site> <HeroGradient fadeInMs={850} /> <Header /> <Hero /> @@ -25,6 +23,6 @@ export default function Homepage() { {/* <Testimonials /> */} <CallToAction /> <Footer /> - </> + </Site> ); } diff --git a/website/app/routes/_layout.preview.$/Toolbar.tsx b/website/src/layouts/preview/Toolbar.tsx similarity index 98% rename from website/app/routes/_layout.preview.$/Toolbar.tsx rename to website/src/layouts/preview/Toolbar.tsx index 3fdef51a..ca7acc1a 100644 --- a/website/app/routes/_layout.preview.$/Toolbar.tsx +++ b/website/src/layouts/preview/Toolbar.tsx @@ -6,8 +6,8 @@ import { Transition, TransitionChild, } from "@headlessui/react"; -import { useNavigate } from "@remix-run/react"; import { ListTreeIcon, OctagonXIcon, TriangleAlert } from "lucide-react"; +import { useRouter } from "next/router"; import { Fragment, useState } from "react"; import { useCheckResult, useFiles, useRestart } from "./utils"; @@ -29,14 +29,14 @@ export function Toolbar() { function Restart() { const restart = useRestart(); - const navigate = useNavigate(); + const router = useRouter(); return ( <button type="button" onClick={async () => { await restart.mutateAsync(); - navigate("/preview"); + router.push("/preview"); }} title="Restart preview mode" className="size-9 hover:bg-black rounded-full flex items-center justify-center" diff --git a/website/app/routes/_layout.preview.$/route.tsx b/website/src/layouts/preview/index.tsx similarity index 75% rename from website/app/routes/_layout.preview.$/route.tsx rename to website/src/layouts/preview/index.tsx index b5114dd0..86248093 100644 --- a/website/app/routes/_layout.preview.$/route.tsx +++ b/website/src/layouts/preview/index.tsx @@ -1,8 +1,5 @@ -import { NavLink, redirect, useFetcher, useParams } from "@remix-run/react"; import { QueryClientProvider } from "@tanstack/react-query"; -import type { ActionFunctionArgs, MetaFunction } from "@vercel/remix"; import { useEffect, useState } from "react"; -import { getPreviewBundle } from "../../api"; import { useTrigger } from "./trigger"; import { ConfigurationFileNotFoundError, @@ -16,29 +13,17 @@ import { useSelectDirectory, } from "./utils"; -import docsearch from "@docsearch/css/dist/style.css?url"; +import Link from "next/link"; +import { useRouter } from "next/router"; import { Button } from "~/components/Button"; import { PageContext } from "~/context"; -import { DocsLayout } from "~/layouts/DocsLayout"; import { Footer } from "~/layouts/Footer"; import { Header } from "~/layouts/Header"; -import { getLinkDescriptors, getMetadata } from "~/meta"; -import { cn, ensureLeadingSlash, isExternalLink } from "~/utils"; +import { cn, ensureLeadingSlash } from "~/utils"; +import { DocumentationLayout } from "../Documentation"; +import { Site } from "../Site"; import { Toolbar } from "./Toolbar"; -export const links = getLinkDescriptors; - -export const meta: MetaFunction = () => { - return [ - ...getMetadata(), - { - tagName: "link", - rel: "stylesheet", - href: docsearch, - }, - ]; -}; - export default function PreviewOutlet() { return ( <QueryClientProvider client={queryClient}> @@ -47,56 +32,18 @@ export default function PreviewOutlet() { ); } -export const action = async (args: ActionFunctionArgs) => { - const json = await args.request.json(); - const bundle = await getPreviewBundle(json).catch((response) => { - throw response; - }); - - // Check if the user has set a redirect in the frontmatter of this page. - const redirectTo = - typeof bundle.frontmatter.redirect === "string" - ? bundle.frontmatter.redirect - : undefined; - - // Redirect to the specified URL. - if (redirectTo && redirectTo.length > 0) { - const url = isExternalLink(String(redirectTo)) - ? String(redirectTo) - : `/preview${ensureLeadingSlash(String(redirectTo))}`; - - throw redirect(url); - } - - return { - bundle, - }; -}; - function Preview() { const [client, setClient] = useState(false); - const params = useParams(); - const path = params["*"] || ""; + const router = useRouter(); + const path = router.asPath.replace("/preview", ""); - const fetcher = useFetcher<typeof action>({ key: "bundle" }); const directory = useDirectoryHandle(); const content = usePageContent(path, directory.data); - const bundle = fetcher.data?.bundle; useEffect(() => { setClient(true); }, []); - useEffect(() => { - if (content.data) { - console.log("Submitting content", content.data); - fetcher.submit(content.data, { - method: "POST", - encType: "application/json", - }); - } - }, [fetcher.submit, content.data]); - if ( content.isFetched && content.error && @@ -171,24 +118,24 @@ function Preview() { ); } - if (bundle && directory.data) { + if (content.data) { return ( <PageContext.Provider value={{ path: ensureLeadingSlash(path), - bundle, + bundle: content.data, preview: true, getFile, }} > - <DocsLayout /> + <DocumentationLayout /> <Toolbar /> </PageContext.Provider> ); } return ( - <> + <Site> <Header /> <section className="max-w-5xl w-full mx-auto py-20 px-6"> <div> @@ -208,14 +155,14 @@ function Preview() { <div className="text-center mt-12 opacity-75"> Need to get started with docs.page? <br /> - <NavLink to="/get-started" className="underline"> + <Link href="/get-started" className="underline"> Check out the getting started guide - </NavLink> + </Link> . </div> </section> <Footer /> - </> + </Site> ); } @@ -263,7 +210,7 @@ function Trigger() { </p> ) : null} <Button - as="button" + element="button" type="button" cta onClick={() => { @@ -281,7 +228,7 @@ function Trigger() { return ( <Button cta - as="button" + element="button" disabled={disabled} className={cn({ "opacity-50": disabled, diff --git a/website/app/routes/_layout.preview.$/trigger.ts b/website/src/layouts/preview/trigger.ts similarity index 100% rename from website/app/routes/_layout.preview.$/trigger.ts rename to website/src/layouts/preview/trigger.ts diff --git a/website/app/routes/_layout.preview.$/utils.ts b/website/src/layouts/preview/utils.ts similarity index 91% rename from website/app/routes/_layout.preview.$/utils.ts rename to website/src/layouts/preview/utils.ts index b9bb9433..c50f3c94 100644 --- a/website/app/routes/_layout.preview.$/utils.ts +++ b/website/src/layouts/preview/utils.ts @@ -1,12 +1,18 @@ import { type CheckResult, check } from "@docs.page/cli"; -import { QueryClient, useMutation, useQuery } from "@tanstack/react-query"; +import { + QueryClient, + keepPreviousData, + useMutation, + useQuery, +} from "@tanstack/react-query"; import { type DBSchema, type IDBPDatabase, openDB } from "idb"; -import mime from "mime"; +import mime from "mime-types"; +import type { BundleErrorResponse, BundlerOutput } from "~/api"; import { ensureLeadingSlash } from "~/utils"; const DATABASE = "docs.page"; const DATABASE_VERSION = 2; -const REFETCH_INTERVAL = 1000; +const REFETCH_INTERVAL = false; interface Database extends DBSchema { handles: { @@ -170,7 +176,7 @@ export function useDirectoryHandle() { } // Store other files as blob URLs else { - const type = mime.getType(file.name) ?? undefined; + const type = mime.lookup(file.name) || undefined; const buffer = await file.arrayBuffer(); const blob = new Blob([buffer], { type }); await db.put("files", URL.createObjectURL(blob), key); @@ -241,6 +247,7 @@ export function usePageContent( // If there's an error, don't refetch files. return ctx.state.error ? false : REFETCH_INTERVAL; }, + placeholderData: keepPreviousData, retry: false, queryFn: async () => { const db = await openDatabase(); @@ -269,13 +276,30 @@ export function usePageContent( throw new FileNotFoundError(filePath, filePaths); } - return { + const payload = { config: { yaml: yamlConfig ?? null, json: jsonConfig ?? null, }, markdown: file1 ?? file2 ?? null, }; + + const response = await fetch(`${location.origin}/api/preview`, { + method: "POST", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error("Failed to fetch preview bundle"); + } + + const data = await response.json(); + + if ("bundle" in data) { + return data.bundle as BundlerOutput; + } + + throw new Error("Unexpected response from Preview API"); }, }); } @@ -384,3 +408,11 @@ export class ConfigurationFileNotFoundError extends Error { super("Configuration file not found"); } } + +export class PreviewBundleError extends Error { + error: BundleErrorResponse; + constructor(error: BundleErrorResponse) { + super("Preview Bundle Error"); + this.error = error; + } +} diff --git a/website/src/pages/404.tsx b/website/src/pages/404.tsx new file mode 100644 index 00000000..8b9c4fde --- /dev/null +++ b/website/src/pages/404.tsx @@ -0,0 +1,10 @@ +import { Error } from "~/layouts/Error"; + +export default function NotFound() { + return ( + <Error + title="Not Found" + description="The page you were looking for could not be found." + /> + ); +} diff --git a/website/src/pages/500.tsx b/website/src/pages/500.tsx new file mode 100644 index 00000000..b22e6ba9 --- /dev/null +++ b/website/src/pages/500.tsx @@ -0,0 +1,10 @@ +import { Error } from "~/layouts/Error"; + +export default function ServerError() { + return ( + <Error + title="Something went wrong" + description="An unexpected error occurred. Please try again later." + /> + ); +} diff --git a/website/src/pages/[...path].tsx b/website/src/pages/[...path].tsx new file mode 100644 index 00000000..0ba11332 --- /dev/null +++ b/website/src/pages/[...path].tsx @@ -0,0 +1,115 @@ +import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; +import type { BundleErrorResponse } from "~/api"; +import { type Context, getRequestContext } from "~/context"; +import type { SharedEnvironmentVariables } from "~/env"; + +import { Documentation } from "~/layouts/Documentation"; +import { Error } from "~/layouts/Error"; +import { Site } from "~/layouts/Site"; +import { Redirect, bundleCodeToStatusCode } from "~/utils"; + +export const getServerSideProps = (async ({ params, req, res }) => { + const env = { + VERCEL: process.env.VERCEL || null, + VERCEL_ENV: process.env.VERCEL_ENV || null, + VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA || null, + } satisfies SharedEnvironmentVariables; + + const param = params?.path ? params.path : []; + const chunks = Array.isArray(param) ? param : [param]; + + // Any paths with only one chunk are invalid, and should be handled + // by other page routes. + if (chunks.length === 1) { + return { notFound: true }; + } + + // Any paths with two chunks or more are a repository page. + let [owner, repository, ...path] = chunks; + let ref: string | undefined; + + // Check if the repo includes a ref (invertase/foo~bar) + if (repository.includes("~")) { + [repository, ref] = repository.split("~"); + } + + let ctx: Context | null = null; + + try { + ctx = await getRequestContext(req, { + owner, + repository, + ref, + path: path.join("/"), + }); + } catch (error) { + // If error is a Redirect instance + if (error instanceof Redirect) { + return { + redirect: { + destination: error.url, + permanent: false, + }, + }; + } + + // If error is a Response instance + if (error instanceof Response) { + // Parse the response body as JSON. + const body = (await error.json()) as BundleErrorResponse; + + // Set the status code to the bundle error code. + res.statusCode = bundleCodeToStatusCode(body); + + return { + props: { + error: body, + }, + }; + } + + throw error; + } + + // Cache the response for 1 second, and revalidate in the background. + res.setHeader( + "Cache-Control", + "public, s-maxage=1, stale-while-revalidate=59" + ); + + return { props: { env, ctx } }; +}) satisfies GetServerSideProps< + | { + env: SharedEnvironmentVariables; + ctx: Context; + } + | { + error: BundleErrorResponse; + } +>; + +export default function Route({ + env, + ctx, + error, +}: InferGetServerSidePropsType<typeof getServerSideProps>) { + // If there is an error, render the error page. + if (error) { + return ( + <Site> + <Error error={error} /> + </Site> + ); + } + + return ( + <> + <script + dangerouslySetInnerHTML={{ + __html: `window.ENV = ${env ? JSON.stringify(env) : "{}"}`, + }} + /> + <Documentation ctx={ctx} /> + </> + ); +} diff --git a/website/src/pages/_app.tsx b/website/src/pages/_app.tsx new file mode 100644 index 00000000..32508aa6 --- /dev/null +++ b/website/src/pages/_app.tsx @@ -0,0 +1,6 @@ +import "../styles.css"; +import type { AppProps } from "next/app"; + +export default function App({ Component, pageProps }: AppProps) { + return <Component {...pageProps} />; +} diff --git a/website/src/pages/_document.tsx b/website/src/pages/_document.tsx new file mode 100644 index 00000000..f288ea7a --- /dev/null +++ b/website/src/pages/_document.tsx @@ -0,0 +1,38 @@ +import { Head, Html, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + <Html lang="en"> + <Head> + <meta charSet="utf-8" /> + <meta name="theme-color" content="#0B0D0E" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link + rel="preconnect" + href="https://fonts.gstatic.com" + crossOrigin="anonymous" + /> + <link + href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=auto" + rel="stylesheet" + /> + <link + href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" + rel="stylesheet" + /> + <link href="/_docs.page/fa/fontawesome.min.css" rel="stylesheet" /> + <link href="/_docs.page/fa/brands.min.css" rel="stylesheet" /> + <link href="/_docs.page/fa/solid.min.css" rel="stylesheet" /> + </Head> + <body + className="antialiased bg-background text-gray-900 dark:text-gray-200" + style={{ + textRendering: "optimizeLegibility", + }} + > + <Main /> + <NextScript /> + </body> + </Html> + ); +} diff --git a/website/src/pages/api/preview.ts b/website/src/pages/api/preview.ts new file mode 100644 index 00000000..9317c69a --- /dev/null +++ b/website/src/pages/api/preview.ts @@ -0,0 +1,25 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { + type BundleErrorResponse, + type BundlerOutput, + getPreviewBundle, +} from "~/api"; + +type ResponseData = { + bundle: BundlerOutput; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<ResponseData>, +) { + if (req.method !== "POST") { + return res.status(405); + } + + const bundle = await getPreviewBundle(JSON.parse(req.body)); + + res.status(200).json({ + bundle, + }); +} diff --git a/website/src/pages/get-started.tsx b/website/src/pages/get-started.tsx new file mode 100644 index 00000000..c63f69cc --- /dev/null +++ b/website/src/pages/get-started.tsx @@ -0,0 +1,2 @@ +import GetStarted from "~/layouts/get-started"; +export default GetStarted; diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx new file mode 100644 index 00000000..875c0c7b --- /dev/null +++ b/website/src/pages/index.tsx @@ -0,0 +1,2 @@ +import { Homepage } from "~/layouts/homepage"; +export default Homepage; \ No newline at end of file diff --git a/website/src/pages/preview/[[...path]].tsx b/website/src/pages/preview/[[...path]].tsx new file mode 100644 index 00000000..7686c3e5 --- /dev/null +++ b/website/src/pages/preview/[[...path]].tsx @@ -0,0 +1,2 @@ +import Preview from "~/layouts/preview"; +export default Preview; diff --git a/website/app/plausible.ts b/website/src/plausible.ts similarity index 72% rename from website/app/plausible.ts rename to website/src/plausible.ts index 6520a6a5..72c6eecb 100644 --- a/website/app/plausible.ts +++ b/website/src/plausible.ts @@ -1,17 +1,21 @@ +import type { GetServerSidePropsContext } from "next"; import { getClientIp } from "request-ip"; export async function trackPageRequest( - request: Request, + request: GetServerSidePropsContext["req"], owner: string, repository: string, ): Promise<void> { + const userAgent = request.headers["User-Agent"]; + try { await fetch("https://plausible.io/api/event", { method: "POST", headers: new Headers({ "Content-Type": "application/json", - "User-Agent": request.headers.get("User-Agent") || "", - // @ts-expect-error - request-ip types use none-generic Request + "User-Agent": Array.isArray(userAgent) + ? userAgent.join(" ") + : userAgent || "", "X-Forwarded-For": getClientIp(request) ?? "", }), body: JSON.stringify({ diff --git a/website/app/styles.css b/website/src/styles.css similarity index 99% rename from website/app/styles.css rename to website/src/styles.css index 3865d49a..5d4338b2 100644 --- a/website/app/styles.css +++ b/website/src/styles.css @@ -38,7 +38,7 @@ html[data-theme="dark"] { /* Apply theme based variables */ :root { - --docsearch-primary-color: hsl(var(--primary)); + --docsearch-primary-color: hsl(var(--primary)) !important; --docsearch-modal-background: hsl(var(--background)) !important; --docsearch-footer-background: hsl(var(--background)) !important; --docsearch-container-background: hsl(var(--background) / 0.9) !important; diff --git a/website/app/utils.ts b/website/src/utils.ts similarity index 54% rename from website/app/utils.ts rename to website/src/utils.ts index 8a2971e1..1d53c120 100644 --- a/website/app/utils.ts +++ b/website/src/utils.ts @@ -1,112 +1,17 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; import cx from "classnames"; import { twMerge } from "tailwind-merge"; -import type { Context } from "~/context"; -import domains from "../../domains.json"; import type { BundleResponse } from "./api"; +import type { Context } from "./context"; -// Gets the request params from the incoming request, which could be a client -// request using a domain, or a server request using a path. -export function getRequestParams(args: LoaderFunctionArgs) { - let owner: string; - let repository: string; - let ref: string | undefined; - let path = args.params["*"] || ""; +export class Redirect extends Error { + url: string; - const url = new URL(args.request.url); - - // A rewritten request comes through with a header containing `x-docs-page-domain`, - // which is the domain the request was rewritten from so we treat it as a vanity domain. - let vanity = args.request.headers.get("x-docs-page-domain") !== null; - - // If it's a request to localhost, docs.page or staging.docs.page, we can extract - // the owner and repository from the URL e.g. https://docs.page/invertase/melos/getting-started - if ( - url.hostname === "localhost" || - url.hostname === "docs.page" || - url.hostname === "staging.docs.page" - ) { - const chunks = path.split("/"); - owner = chunks.at(0)!; - repository = chunks.at(1)!; - path = chunks.slice(2).join("/"); - } - // If it's a vanity domain request (from the client), we can extract the owner and repository from the URL - // e.g. https://invertase.docs.page/melos/getting-started - else if (url.hostname.endsWith(".docs.page")) { - const chunks = url.hostname.split("."); - owner = chunks.at(0)!; - repository = path.split("/").at(0)!; // Also includes the ref if it's present - path = path.split("/").slice(1).join("/"); - vanity = true; - } - // Else it's a custom domain, e.g. https://melos.invertase.dev/getting-started - else { - const domain = domains.find(([host]) => host === url.host)?.at(0); - - if (!domain) { - throw new Error( - `Client host request "${url.host}" does not match a domain.`, - ); - } - - [owner, repository] = domain.split("/"); - } - - if (!owner || !repository) { - console.error("Invalid routing scenario for request", url.toString()); - throw new Response("Invalid routing scenario for request", { status: 404 }); - } - - // Check if the repo includes a ref (invertase/foo~bar) - if (repository.includes("~")) { - [repository, ref] = repository.split("~"); - } - - // Check if the first path segment is a ref, e.g. `use.docs.page/~next`. - if (path.startsWith("~")) { - ref = path.split("/")[0].slice(1); - path = path.split("/").slice(1).join("/"); - } - - return { owner, repository, ref, path, vanity }; -} - -export type SharedEnvironmentVariables = { - VERCEL?: string; - VERCEL_ENV?: string; - VERCEL_GIT_COMMIT_SHA?: string; -}; - -declare global { - interface Window { - ENV: SharedEnvironmentVariables; + constructor(url: string) { + super(); + this.url = url; } } -// Returns the shared environment variables, from either the server or the client. -export function getSharedEnvironmentVariables(): SharedEnvironmentVariables { - return typeof window === "undefined" - ? (process.env as SharedEnvironmentVariables) - : window.ENV; -} - -// Returns the current environment, either `production`, `preview` or `development`. -export function getEnvironment() { - const ENV = getSharedEnvironmentVariables(); - - return ENV.VERCEL - ? ENV.VERCEL_ENV === "production" - ? "production" - : "preview" - : "development"; -} - -// Returns the current build hash, either the Vercel Git commit SHA or `development`. -export function getBuildHash() { - return getSharedEnvironmentVariables().VERCEL_GIT_COMMIT_SHA || "development"; -} - // Helper function to merge Tailwind CSS classes with classnames. export function cn(...inputs: cx.ArgumentArray) { return twMerge(cx(inputs)); @@ -210,7 +115,7 @@ export function getHref(ctx: Context, path: string) { // Ensure all links start with the custom domain if it's set. else if (ctx.domain) { href += `https://${ctx.domain}`; - } + } // Prefix the path with the owner and repository, e.g. `/invertase/docs.page`. else { href = `/${ctx.owner}/${ctx.repository}`; @@ -236,22 +141,6 @@ export function getHref(ctx: Context, path: string) { return `${href}${normalizedPath}`; } -// Matches a URL matching a pattern with a given path. Rules are: -// `/foo/*` matches `/foo`, `/foo/bar`, `/foo/bar/baz/etc` -// `/foo/:bar` matches `/foo/bar`, `/foo/baz`, etc -// `/foo/:bar/*` matches `/foo/bar`, `/foo/bar/baz/etc` -// `/foo/:bar/:baz` matches `/foo/bar/baz`, `/foo/bar/qux`, etc -// `/foo/*/bar` matches `/foo/bar`, `/foo/etc/bar`, etc -export function matchPathPattern(pattern: string, path: string) { - // Escape regex special characters in the pattern except for * and : - const regexPattern = pattern - .replace(/([.+?^=!:${}()|\[\]\/\\])/g, "\\$1") - .replace(/\*/g, ".*") // Replace * with .* - .replace(/\/:([^\/]+)/g, "/([^/]+)"); // Replace /:param with /([^/]+) - - return new RegExp(`^${regexPattern}$`).test(path); -} - // Returns the bundle error response as a JSON response. export function bundleErrorResponse(bundle: BundleResponse): Response { if (bundle.code === "OK") { diff --git a/website/tailwind.config.js b/website/tailwind.config.ts similarity index 90% rename from website/tailwind.config.js rename to website/tailwind.config.ts index 60e671e8..47b75899 100644 --- a/website/tailwind.config.js +++ b/website/tailwind.config.ts @@ -1,9 +1,9 @@ +import type { Config } from "tailwindcss"; import defaultTheme from "tailwindcss/defaultTheme"; import plugin from "tailwindcss/plugin"; -/** @type {import('tailwindcss').Config} */ -export default { - content: ["./app/**/*.{ts,tsx}"], +const config: Config = { + content: ["./src/**/**/*.{js,ts,jsx,tsx,mdx}"], darkMode: ["class", '[data-theme="dark"]'], theme: { extend: { @@ -53,3 +53,4 @@ export default { }), ], }; +export default config; diff --git a/website/tsconfig.json b/website/tsconfig.json index 45192694..6a079061 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -1,38 +1,23 @@ { - "include": [ - "**/*.ts", - "**/*.tsx", - "**/.server/**/*.ts", - "**/.server/**/*.tsx", - "**/.client/**/*.ts", - "**/.client/**/*.tsx" - ], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": [ - "@remix-run/node", - "vite/client", - "@types/wicg-file-system-access" - // "@remix-run/react/future/single-fetch.d.ts" - ], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "target": "ES2022", - "strict": true, + "target": "ES2018", + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, "paths": { "@docs.page/cli": ["../packages/cli/src/index.ts"], - "~/*": ["./app/*"] - }, - - // Vite takes care of building everything, not tsc. - "noEmit": true - } + "~/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "fsa.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] } diff --git a/website/vite.config.ts b/website/vite.config.ts deleted file mode 100644 index 337d8c77..00000000 --- a/website/vite.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -import path from "node:path"; -import { vitePlugin as remix } from "@remix-run/dev"; -import { installGlobals } from "@remix-run/node"; -import { vercelPreset } from "@vercel/remix/vite"; -import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -installGlobals(); - -export default defineConfig({ - resolve: { - alias: { - // Use source files from the local workspace rather than dist files. - "@docs.page/cli": path.resolve(__dirname, "../packages/cli/src/index.ts"), - }, - }, - plugins: [ - remix({ - presets: [vercelPreset()], - future: { - unstable_singleFetch: false, // Not working on Vercel - v3_fetcherPersist: true, - v3_relativeSplatPath: true, - v3_throwAbortReason: true, - }, - }), - tsconfigPaths(), - ], -});