├── .editorconfig ├── .env.example ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── app ├── components │ ├── content.tsx │ ├── heading.tsx │ ├── link.tsx │ ├── preview-banner.tsx │ ├── rich-text-view.tsx │ └── table-of-contents.tsx ├── entry.client.tsx ├── entry.server.tsx ├── generated │ └── schema.server.ts ├── graphql │ ├── EmbeddedAsset.fragment.gql │ ├── EmbeddedPage.fragment.gql │ ├── GetAllNavItemsQuery.gql │ ├── GetAllPages.gql │ ├── GetFirstPageFromChapter.gql │ ├── GetPage.gql │ └── Page.fragment.gql ├── hooks │ ├── useActiveHeading.tsx │ └── useMarkdownHeadings.tsx ├── lib │ └── hygraph.server.ts ├── root.tsx ├── routes │ ├── [sitemap.xml].tsx │ ├── _docs.$chapter.$slug │ │ └── route.tsx │ ├── _docs.$slug │ │ └── route.tsx │ ├── _docs._index │ │ └── route.tsx │ ├── _docs │ │ ├── footer.tsx │ │ ├── github.tsx │ │ ├── header.tsx │ │ ├── logo.tsx │ │ ├── nav │ │ │ ├── NavChapter.fragment.gql │ │ │ ├── NavExternalLink.fragment.gql │ │ │ ├── NavPage.fragment.gql │ │ │ ├── chapter.tsx │ │ │ ├── index.tsx │ │ │ └── nav-link.tsx │ │ └── route.tsx │ ├── api.exit-preview.tsx │ └── api.preview.tsx ├── tailwind.css └── utils │ ├── parse-cookie.server.ts │ ├── preview-mode.server.ts │ ├── seo.ts │ ├── sitemap.server.ts │ └── slugify.ts ├── codegen.yml ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon.ico └── og-image.png ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = false 13 | end_of_line = lf 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Hygraph 2 | HYGRAPH_ENDPOINT= 3 | HYGRAPH_DEV_TOKEN= 4 | HYGRAPH_PROD_TOKEN= 5 | 6 | # For preview mode 7 | # Your token needs to match the one in the CMS 8 | PREVIEW_SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | .vercel 5 | .idea 6 | /build 7 | .env 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hygraph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docs-starter 2 | 3 | > A [Remix](https://remix.run/) starter for creating a documentation websites with [Hygraph](https://hygraph.com). 4 | 5 | ![Hygraph Docs Starter](https://github.com/hygraph/docs-starter/assets/26466516/778a8b02-0dce-4bf6-bb31-d9739375d25e) 6 | 7 | ## Getting Started 8 | 9 | The simplest way to get started is by cloning the Hygraph project and deploying it to Vercel. 10 | 11 | [![Clone project](https://hygraph.com/button)](https://app.hygraph.com/clone/1bc5b8c08db04e629d98dc54d6bfe5e5) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHygraph%2Fdocs-starter&env=HYGRAPH_ENDPOINT,HYGRAPH_DEV_TOKEN,PREVIEW_SECRET,HYGRAPH_PROD_TOKEN&envDescription=The%20Enviroment%20variables%20for%20the%20project&envLink=https%3A%2F%2Fdocs.withheadlesscms.com%2Fgetting-started&demo-title=Documentation%20Starter%20Demo&demo-description=See%20the%20docs%20starter%20in%20action!&demo-url=https%3A%2F%2Fdocs.withheadlesscms.com&demo-image=https%3A%2F%2Fgithub.com%2Fhygraph%2Fdocs-starter%2Fassets%2F26466516%2F778a8b02-0dce-4bf6-bb31-d9739375d25e) 12 | 13 | For the complete getting started guide, check our [documentation](https://docs.withheadlesscms.com/getting-started). 14 | 15 | ## Features 16 | 17 | - Built with Remix 18 | - Embeddable content entries with Hygraph 19 | - Rich Text content editing 20 | - Table of Contents for docs pages 21 | - Render custom embeds using Rich Text Renderer 22 | - Styled using Tailwind CSS 23 | - Responsive and mobile-friendly 24 | - SEO support (Open Graph & Twitter tags, sitemap, etc.) 25 | - Preview mode support 26 | -------------------------------------------------------------------------------- /app/components/content.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { ClientOnly } from 'remix-utils/client-only'; 3 | 4 | import type { GetPageQuery } from '~/generated/schema.server'; 5 | import { useMarkdownHeadings } from '~/hooks/useMarkdownHeadings'; 6 | 7 | type ContentProps = { 8 | page: GetPageQuery['page']; 9 | disableToc?: boolean; 10 | }; 11 | 12 | import { RichTextView } from './rich-text-view'; 13 | import { TableOfContents } from './table-of-contents'; 14 | 15 | export function Content({ page, disableToc }: ContentProps) { 16 | const contentRef = useRef(null); 17 | const { links } = useMarkdownHeadings({ 18 | content: page?.content?.markdown as string, 19 | }); 20 | 21 | const hasLinks = links && links?.length > 0; 22 | 23 | return ( 24 |
25 |
26 | 27 |
28 | 29 | {hasLinks && !disableToc && ( 30 | 31 | {() => ( 32 | 38 | )} 39 | 40 | )} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/components/heading.tsx: -------------------------------------------------------------------------------- 1 | import { slugify } from '~/utils/slugify'; 2 | 3 | type HeadingProps = { 4 | as: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 5 | children: any; 6 | }; 7 | 8 | export function Heading({ as, children }: HeadingProps) { 9 | const Component = `${as}` as keyof JSX.IntrinsicElements; 10 | 11 | let title = children?.props?.parent.children; 12 | 13 | if (typeof title === `object`) { 14 | const parsedTitle = title.map((child: any) => child?.text); 15 | 16 | title = parsedTitle.join(``); 17 | } 18 | 19 | const slug = slugify(title); 20 | 21 | return ( 22 | 29 | {children} 30 | 35 | Anchor 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/components/link.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, LinkHTMLAttributes } from 'react'; 2 | import { Link as RemixLink, NavLink } from '@remix-run/react'; 3 | import type { 4 | NavLinkProps, 5 | LinkProps as RemixLinkProps, 6 | } from '@remix-run/react'; 7 | 8 | const stripTrailingSlash = (href: string) => { 9 | if (href === `/`) return href; 10 | 11 | return href.endsWith(`/`) ? href.slice(0, -1) : href; 12 | }; 13 | 14 | export type LinkProps = Partial> & { 15 | href: string; 16 | children: ReactNode; 17 | target?: string; 18 | asNavLink?: boolean; 19 | className?: NavLinkProps['className']; 20 | }; 21 | 22 | export function Link({ href, children, asNavLink, ...props }: LinkProps) { 23 | const anchorLink = href.startsWith(`#`); 24 | 25 | if (href.includes(`http`) || href.includes(`mailto`) || anchorLink) { 26 | return ( 27 | )} 31 | > 32 | {children} 33 | 34 | ); 35 | } 36 | 37 | if (asNavLink) { 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/components/preview-banner.tsx: -------------------------------------------------------------------------------- 1 | export function PreviewBanner() { 2 | return ( 3 |
4 |
5 |
6 | 7 | You're in preview mode (Content served from 8 | DRAFT) —  9 | 10 |
15 | 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/components/rich-text-view.tsx: -------------------------------------------------------------------------------- 1 | import { RichText } from '@graphcms/rich-text-react-renderer'; 2 | import { EmbedReferences, EmbedProps } from '@graphcms/rich-text-types'; 3 | 4 | import type { 5 | GetPageQuery, 6 | Page, 7 | EmbeddedPageFragment, 8 | } from '~/generated/schema.server'; 9 | import { Heading } from './heading'; 10 | import { Link } from './link'; 11 | 12 | type PageProps = GetPageQuery['page']; 13 | 14 | export const RichTextView = ({ page }: { page: PageProps }) => { 15 | return ( 16 |
17 |

{page?.title}

18 | {children}, 23 | h2: ({ children }) => {children}, 24 | h3: ({ children }) => {children}, 25 | h4: ({ children }) => {children}, 26 | h5: ({ children }) => {children}, 27 | h6: ({ children }) => {children}, 28 | img: ({ height, width, src, title, altText }) => ( 29 | {altText} 38 | ), 39 | embed: { 40 | Page: ({ slug, title }: EmbedProps) => ( 41 | 45 | {title} 46 | 47 | 48 | ), 49 | }, 50 | }} 51 | /> 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /app/components/table-of-contents.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, RefObject } from 'react'; 2 | import { Link } from '@remix-run/react'; 3 | import cc from 'classcat'; 4 | 5 | import { useActiveHeading } from '~/hooks/useActiveHeading'; 6 | import { slugify } from '~/utils/slugify'; 7 | 8 | type LabelProps = { 9 | children: ReactNode; 10 | }; 11 | 12 | export function Label({ children }: LabelProps) { 13 | return ( 14 |

15 | {children} 16 |

17 | ); 18 | } 19 | 20 | type TOCProps = { 21 | links: { 22 | title: string; 23 | depth: number; 24 | }[]; 25 | contentRef: RefObject; 26 | className?: string; 27 | labelText?: string; 28 | }; 29 | 30 | export function TableOfContents({ links, className, labelText }: TOCProps) { 31 | const { currentIndex } = useActiveHeading(); 32 | 33 | return ( 34 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from '@remix-run/react'; 8 | import { startTransition, StrictMode } from 'react'; 9 | import { hydrateRoot } from 'react-dom/client'; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from 'node:stream'; 8 | 9 | import type { AppLoadContext, EntryContext } from '@remix-run/node'; 10 | import { createReadableStreamFromReadable } from '@remix-run/node'; 11 | import { RemixServer } from '@remix-run/react'; 12 | import { isbot } from 'isbot'; 13 | import { renderToPipeableStream } from 'react-dom/server'; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext, 26 | ) { 27 | return isbot(request.headers.get('user-agent') || '') 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext, 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext, 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext, 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set('Content-Type', 'text/html'); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }), 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | }, 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext, 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set('Content-Type', 'text/html'); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }), 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | }, 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /app/graphql/EmbeddedAsset.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment EmbeddedAsset on Asset { 2 | id 3 | url 4 | mimeType 5 | } 6 | -------------------------------------------------------------------------------- /app/graphql/EmbeddedPage.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment EmbeddedPage on Page { 2 | id 3 | slug 4 | title 5 | } 6 | -------------------------------------------------------------------------------- /app/graphql/GetAllNavItemsQuery.gql: -------------------------------------------------------------------------------- 1 | query GetAllNavItems { 2 | navigations(first: 1) { 3 | id 4 | linkTo { 5 | __typename 6 | ... on Page { 7 | ...NavPage 8 | } 9 | ... on Chapter { 10 | ...NavChapter 11 | } 12 | ... on ExternalLink { 13 | ...NavExternalLink 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/graphql/GetAllPages.gql: -------------------------------------------------------------------------------- 1 | query GetAllPages { 2 | pages { 3 | slug 4 | updatedAt 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app/graphql/GetFirstPageFromChapter.gql: -------------------------------------------------------------------------------- 1 | query GetFirstPageFromChapter($slug: String!) { 2 | chapter(where: { slug: $slug }) { 3 | pages(first: 1) { 4 | slug 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/graphql/GetPage.gql: -------------------------------------------------------------------------------- 1 | query GetPage($slug: String!) { 2 | page(where: { slug: $slug }) { 3 | chapter { 4 | slug 5 | } 6 | ...Page 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/graphql/Page.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment Page on Page { 2 | title 3 | slug 4 | content { 5 | markdown 6 | ... on PageContentRichText { 7 | json 8 | references { 9 | ...EmbeddedAsset 10 | # Custom embeds need added here on the union 11 | ...EmbeddedPage 12 | } 13 | } 14 | } 15 | seo { 16 | title 17 | description 18 | noindex 19 | image { 20 | url 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/hooks/useActiveHeading.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | 3 | const TOP_OFFSET = 75; 4 | 5 | export function getHeaderAnchors(): HTMLAnchorElement[] { 6 | return Array.prototype.filter.call( 7 | document.getElementsByClassName('anchor'), 8 | function (testElement) { 9 | return testElement.parentNode.nodeName === 'H2'; 10 | }, 11 | ); 12 | } 13 | 14 | export function useActiveHeading() { 15 | const [currentIndex, setCurrentIndex] = useState(0); 16 | const timeoutRef = useRef(null); 17 | 18 | useEffect(() => { 19 | function updateActiveLink() { 20 | const pageHeight = document.body.scrollHeight; 21 | const scrollPosition = window.scrollY + window.innerHeight; 22 | const headersAnchors = getHeaderAnchors(); 23 | 24 | if (scrollPosition >= 0 && pageHeight - scrollPosition <= TOP_OFFSET) { 25 | // Scrolled to bottom of page. 26 | setCurrentIndex(headersAnchors.length - 1); 27 | return; 28 | } 29 | 30 | let index = -1; 31 | while (index < headersAnchors.length - 1) { 32 | const headerAnchor = headersAnchors[index + 1]; 33 | const { top } = headerAnchor.getBoundingClientRect(); 34 | 35 | if (top >= TOP_OFFSET) { 36 | break; 37 | } 38 | index += 1; 39 | } 40 | 41 | setCurrentIndex(Math.max(index, 0)); 42 | } 43 | 44 | function throttledUpdateActiveLink() { 45 | if (timeoutRef.current === null) { 46 | timeoutRef.current = window.setTimeout(() => { 47 | timeoutRef.current = null; 48 | updateActiveLink(); 49 | }, 100); 50 | } 51 | } 52 | 53 | document.addEventListener('scroll', throttledUpdateActiveLink); 54 | document.addEventListener('resize', throttledUpdateActiveLink); 55 | 56 | updateActiveLink(); 57 | 58 | return () => { 59 | if (timeoutRef.current != null) { 60 | clearTimeout(timeoutRef.current); 61 | timeoutRef.current = null; 62 | } 63 | document.removeEventListener('scroll', throttledUpdateActiveLink); 64 | document.removeEventListener('resize', throttledUpdateActiveLink); 65 | }; 66 | }, []); 67 | 68 | return { 69 | currentIndex, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /app/hooks/useMarkdownHeadings.tsx: -------------------------------------------------------------------------------- 1 | export function useMarkdownHeadings({ content }: { content: string }) { 2 | const headingRegex = /^(#{2,2} .*[\n\r\v\t\s]*)$/gim; 3 | 4 | const contentHeading = content.match(headingRegex); 5 | const links = contentHeading?.map((link) => { 6 | const level = link.match(/^#{2}/); 7 | 8 | return { 9 | title: link.slice(3), 10 | depth: level ? level[0].length : 0, 11 | }; 12 | }); 13 | 14 | return { links }; 15 | } 16 | -------------------------------------------------------------------------------- /app/lib/hygraph.server.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | 3 | import { getSdk } from '~/generated/schema.server'; 4 | 5 | export const hygraph = new GraphQLClient( 6 | process.env.HYGRAPH_ENDPOINT as string, 7 | ); 8 | 9 | export function sdk({ 10 | preview, 11 | }: { 12 | preview?: boolean; 13 | }): ReturnType { 14 | const API_TOKEN = preview 15 | ? process.env.HYGRAPH_DEV_TOKEN 16 | : process.env.HYGRAPH_PROD_TOKEN; 17 | 18 | hygraph.setHeader(`authorization`, `Bearer ${API_TOKEN}`); 19 | 20 | try { 21 | return getSdk(hygraph); 22 | } catch (error: any) { 23 | console.error(JSON.stringify(error, undefined, 2)); 24 | return error; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | isRouteErrorResponse, 9 | json, 10 | useRouteError, 11 | } from '@remix-run/react'; 12 | import type { LoaderFunctionArgs } from '@remix-run/node'; 13 | 14 | import { getDomainUrl } from '~/utils/seo'; 15 | 16 | import './tailwind.css'; 17 | 18 | export async function loader({ request }: LoaderFunctionArgs) { 19 | return json({ 20 | requestInfo: { 21 | origin: getDomainUrl(request), 22 | path: new URL(request.url).pathname, 23 | }, 24 | }); 25 | } 26 | 27 | export function Layout({ children }: { children: ReactNode }) { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default function App() { 46 | return ; 47 | } 48 | 49 | export function ErrorBoundary() { 50 | const error = useRouteError(); 51 | 52 | return ( 53 | 54 |
55 |
56 |

57 | {isRouteErrorResponse(error) 58 | ? `${error.status} ${error.statusText}` 59 | : error instanceof Error 60 | ? error.message 61 | : 'Unknown Error'} 62 |

63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/routes/[sitemap.xml].tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | 3 | import { getSitemapXml } from '~/utils/sitemap.server'; 4 | 5 | export async function loader({ request }: LoaderFunctionArgs) { 6 | const sitemap = await getSitemapXml(request); 7 | 8 | return new Response(sitemap, { 9 | headers: { 10 | 'Content-Type': 'application/xml', 11 | 'Content-Length': String(Buffer.byteLength(sitemap)), 12 | 'cache-control': 'max-age=900', 13 | }, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/_docs.$chapter.$slug/route.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from '@remix-run/node'; 2 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 3 | 4 | import { sdk } from '~/lib/hygraph.server'; 5 | import { Content } from '~/components/content'; 6 | import { getDomainUrl, getSocialMetas, getUrl } from '~/utils/seo'; 7 | import { isPreviewMode } from '~/utils/preview-mode.server'; 8 | import { useLoaderData } from '@remix-run/react'; 9 | import { GetPageQuery } from '~/generated/schema.server'; 10 | 11 | export const meta: MetaFunction = ({ data }) => { 12 | const requestInfo = data?.requestInfo; 13 | 14 | const title = data?.page?.seo?.title ?? data?.page?.title; 15 | 16 | return getSocialMetas({ 17 | title, 18 | description: data?.page?.seo?.description as string, 19 | requestInfo, 20 | url: getUrl(requestInfo), 21 | noindex: data?.page?.seo?.noindex ?? false, 22 | image: data?.page?.seo?.image?.url, 23 | }); 24 | }; 25 | 26 | export async function loader({ params, request }: LoaderFunctionArgs) { 27 | const { chapter, slug } = params; 28 | 29 | const isInPreview = await isPreviewMode(request); 30 | 31 | const { GetPage } = await sdk({ preview: isInPreview }); 32 | const { page } = await GetPage({ 33 | slug: slug as string, 34 | }); 35 | 36 | if (!page) { 37 | throw new Response(null, { 38 | status: 404, 39 | statusText: 'Not Found', 40 | }); 41 | } 42 | 43 | // Checks if the page chapter is the same as the chapter slug from the params 44 | if (chapter && slug && page.chapter?.slug !== chapter) { 45 | throw new Response(null, { 46 | status: 404, 47 | statusText: 'Not Found', 48 | }); 49 | } 50 | 51 | return json({ 52 | page: page, 53 | requestInfo: { 54 | origin: getDomainUrl(request), 55 | path: new URL(request.url).pathname, 56 | }, 57 | }); 58 | } 59 | 60 | export default function PostRoute() { 61 | const data = useLoaderData(); 62 | 63 | return ; 64 | } 65 | -------------------------------------------------------------------------------- /app/routes/_docs.$slug/route.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from '@remix-run/node'; 2 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 3 | 4 | import { sdk } from '~/lib/hygraph.server'; 5 | import { Content } from '~/components/content'; 6 | import { getDomainUrl, getSocialMetas, getUrl } from '~/utils/seo'; 7 | import { isPreviewMode } from '~/utils/preview-mode.server'; 8 | import { useLoaderData } from '@remix-run/react'; 9 | import { GetPageQuery } from '~/generated/schema.server'; 10 | 11 | export const meta: MetaFunction = ({ data }) => { 12 | const requestInfo = data?.requestInfo; 13 | 14 | const title = data?.page?.seo?.title ?? data?.page?.title; 15 | 16 | return getSocialMetas({ 17 | title, 18 | description: data?.page?.seo?.description as string, 19 | requestInfo, 20 | url: getUrl(requestInfo), 21 | noindex: data?.page?.seo?.noindex ?? false, 22 | image: data?.page?.seo?.image?.url, 23 | }); 24 | }; 25 | 26 | export async function loader({ request, params }: LoaderFunctionArgs) { 27 | const { slug } = params; 28 | 29 | // If slug is /homepage, redirect it to the index page to avoid duplicated pages 30 | // Important for SEO 31 | if (slug === `homepage`) { 32 | throw redirect('/', { 33 | status: 301, 34 | }); 35 | } 36 | 37 | const isInPreview = await isPreviewMode(request); 38 | 39 | const { GetPage, GetFirstPageFromChapter } = await sdk({ 40 | preview: isInPreview, 41 | }); 42 | const { page } = await GetPage({ 43 | slug: slug as string, 44 | }); 45 | 46 | // If there's no page with the given slug, try to find a chapter 47 | if (!page) { 48 | // If there's a chapter, redirect to the first page of the chapter 49 | const { chapter } = await GetFirstPageFromChapter({ slug: slug as string }); 50 | 51 | if (chapter) { 52 | throw redirect(`/${slug}/${chapter.pages[0].slug}`); 53 | } 54 | 55 | // If there's no chapter, redirect to 404 56 | throw new Response(null, { 57 | status: 404, 58 | statusText: 'Not Found', 59 | }); 60 | } 61 | 62 | return json({ 63 | page, 64 | requestInfo: { 65 | origin: getDomainUrl(request), 66 | path: new URL(request.url).pathname, 67 | }, 68 | }); 69 | } 70 | 71 | export default function PostRoute() { 72 | const data = useLoaderData(); 73 | 74 | return ; 75 | } 76 | -------------------------------------------------------------------------------- /app/routes/_docs._index/route.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 2 | import { json } from '@remix-run/node'; 3 | import { sdk } from '~/lib/hygraph.server'; 4 | import { Content } from '~/components/content'; 5 | import { getDomainUrl, getSocialMetas, getUrl } from '~/utils/seo'; 6 | import { isPreviewMode } from '~/utils/preview-mode.server'; 7 | import { GetPageQuery } from '~/generated/schema.server'; 8 | import { useLoaderData } from '@remix-run/react'; 9 | 10 | const fallbackContent: GetPageQuery['page'] = { 11 | title: 'Hygraph Docs Starter', 12 | slug: ``, 13 | content: { 14 | json: { 15 | children: [ 16 | { 17 | type: 'paragraph', 18 | children: [ 19 | { 20 | text: 'Add a homepage in your Hygraph project to replace this default view.', 21 | }, 22 | ], 23 | }, 24 | ], 25 | }, 26 | references: [], 27 | markdown: ``, 28 | }, 29 | seo: { 30 | title: 'Hygraph Docs Starter', 31 | description: `Docs Starter built with Remix and powered by Hygraph. No longer are you tied to markdown files, Git workflows, and writing documentation in your code editor.`, 32 | noindex: false, 33 | image: { 34 | url: ``, 35 | }, 36 | }, 37 | }; 38 | 39 | export const meta: MetaFunction = ({ data }) => { 40 | const requestInfo = data?.requestInfo; 41 | 42 | const title = data?.page?.seo?.title ?? data?.page?.title; 43 | 44 | return getSocialMetas({ 45 | title, 46 | description: data?.page?.seo?.description as string, 47 | requestInfo, 48 | url: getUrl(requestInfo), 49 | noindex: data?.page?.seo?.noindex ?? false, 50 | image: data?.page?.seo?.image?.url, 51 | }); 52 | }; 53 | 54 | export async function loader({ request }: LoaderFunctionArgs) { 55 | const isInPreview = await isPreviewMode(request); 56 | 57 | const { GetPage } = await sdk({ 58 | preview: isInPreview, 59 | }); 60 | const { page } = await GetPage({ 61 | slug: 'homepage', 62 | }); 63 | 64 | const requestInfo = { 65 | origin: getDomainUrl(request), 66 | path: new URL(request.url).pathname, 67 | }; 68 | 69 | return json({ 70 | page: page ?? fallbackContent, 71 | requestInfo, 72 | }); 73 | } 74 | 75 | export default function Index() { 76 | const data = useLoaderData(); 77 | 78 | return ; 79 | } 80 | -------------------------------------------------------------------------------- /app/routes/_docs/footer.tsx: -------------------------------------------------------------------------------- 1 | import { GitHub } from './github'; 2 | import { Link } from '~/components/link'; 3 | 4 | export function Footer() { 5 | return ( 6 |
7 |
8 |
9 |

10 | Docs Starter by{' '} 11 | 12 | Hygraph 13 | 14 |

15 | 16 | 20 | 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/routes/_docs/github.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function GitHub(props: SVGProps) { 4 | return ( 5 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/_docs/header.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { Link, useLocation } from '@remix-run/react'; 3 | import { Popover } from '@headlessui/react'; 4 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/solid'; 5 | import cc from 'classcat'; 6 | 7 | import { Logo } from './logo'; 8 | import { GetAllNavItemsQuery } from '~/generated/schema.server'; 9 | import { Nav } from './nav'; 10 | 11 | export function Header({ navigations }: GetAllNavItemsQuery) { 12 | const buttonRef = useRef(null); 13 | const location = useLocation(); 14 | 15 | useEffect(() => { 16 | // Dirty workaround to close the nav when the route changes 17 | if (buttonRef.current) buttonRef.current.click(); 18 | }, [location]); 19 | 20 | return ( 21 |
22 | 25 | cc([ 26 | open 27 | ? 'fixed inset-0 z-40 overflow-y-auto bg-white' 28 | : 'bg-indigo-700', 29 | ]) 30 | } 31 | > 32 | {({ open, close }) => ( 33 | <> 34 |
35 |
36 | 37 | 38 | 39 |
40 | 41 |
42 | 43 | Open menu 44 | {open ? ( 45 | 50 |
51 |
52 | 53 | 61 | 62 | 63 |
64 |
66 |
67 | 68 | )} 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/routes/_docs/logo.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export function Logo(props: SVGProps) { 4 | return ( 5 | 11 | 15 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/NavChapter.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment NavChapter on Chapter { 2 | id 3 | title 4 | slug 5 | pages { 6 | title 7 | slug 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/NavExternalLink.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment NavExternalLink on ExternalLink { 2 | id 3 | label 4 | url 5 | } 6 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/NavPage.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment NavPage on Page { 2 | id 3 | title 4 | slug 5 | } 6 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/chapter.tsx: -------------------------------------------------------------------------------- 1 | import urljoin from 'url-join'; 2 | import cc from 'classcat'; 3 | 4 | import type { NavChapterFragment } from '~/generated/schema.server'; 5 | import { Link } from '~/components/link'; 6 | 7 | export function NavChapter(props: NavChapterFragment) { 8 | const { title, slug: chapterSlug } = props; 9 | 10 | return ( 11 | <> 12 |
13 |

{title}

14 |
15 | 16 | {props.pages.length > 0 && ( 17 |
    18 | {props.pages.map((page) => ( 19 |
  • 20 | 24 | cc([ 25 | isActive ? 'text-indigo-700' : 'text-gray-700', 26 | 'block py-0.5 hover:text-indigo-700', 27 | ]) 28 | } 29 | > 30 | {page.title} 31 | 32 |
  • 33 | ))} 34 |
35 | )} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/index.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | GetAllNavItemsQuery, 3 | NavChapterFragment, 4 | NavExternalLinkFragment, 5 | NavPageFragment, 6 | } from '~/generated/schema.server'; 7 | import { NavChapter } from './chapter'; 8 | import { ExternalLink, InternalLink } from './nav-link'; 9 | 10 | type NavigationItem = GetAllNavItemsQuery['navigations'][0]; 11 | 12 | type NavRendererProps = NavigationItem['linkTo'][0]; 13 | 14 | export function NavRenderer({ __typename, ...props }: NavRendererProps) { 15 | switch (__typename) { 16 | case 'Chapter': 17 | return ( 18 |
19 | 20 |
21 | ); 22 | case 'ExternalLink': 23 | return ; 24 | case 'Page': 25 | return ; 26 | default: 27 | return <>; 28 | } 29 | } 30 | 31 | export function Nav({ navigations }: GetAllNavItemsQuery) { 32 | return ( 33 |
34 | 35 | {navigations[0].linkTo.map((entry) => ( 36 | 37 | ))} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/routes/_docs/nav/nav-link.tsx: -------------------------------------------------------------------------------- 1 | import cc from 'classcat'; 2 | import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/solid'; 3 | 4 | import { 5 | NavExternalLinkFragment, 6 | NavPageFragment, 7 | } from '~/generated/schema.server'; 8 | import { Link } from '~/components/link'; 9 | 10 | export function ExternalLink(props: NavExternalLinkFragment) { 11 | return ( 12 | 16 | {props.label} 17 | 18 | 19 | ); 20 | } 21 | 22 | export function InternalLink(props: NavPageFragment) { 23 | return ( 24 | 28 | cc([ 29 | isActive ? 'text-indigo-700' : 'text-gray-700', 30 | 'block py-0.5 hover:text-indigo-700', 31 | ]) 32 | } 33 | > 34 | {props.title} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/routes/_docs/route.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, useLoaderData } from '@remix-run/react'; 2 | import { json } from '@remix-run/node'; 3 | import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node'; 4 | import cc from 'classcat'; 5 | 6 | import { sdk } from '~/lib/hygraph.server'; 7 | 8 | import { Header } from './header'; 9 | import { Footer } from './footer'; 10 | import { Nav } from './nav'; 11 | import { getDomainUrl, getSocialMetas, getUrl } from '~/utils/seo'; 12 | import { PreviewBanner } from '~/components/preview-banner'; 13 | import { isPreviewMode } from '~/utils/preview-mode.server'; 14 | 15 | export const meta: MetaFunction = ({ data }) => { 16 | const requestInfo = data?.requestInfo; 17 | 18 | return [ 19 | ...getSocialMetas({ 20 | requestInfo, 21 | url: getUrl(requestInfo), 22 | }), 23 | { 24 | name: 'theme-color', 25 | content: '#1d4ed8', 26 | }, 27 | ]; 28 | }; 29 | 30 | export async function loader({ request }: LoaderFunctionArgs) { 31 | const isInPreview = await isPreviewMode(request); 32 | 33 | const { GetAllNavItems } = await sdk({ preview: isInPreview }); 34 | const { navigations } = await GetAllNavItems(); 35 | 36 | return json({ 37 | navigations, 38 | isInPreview, 39 | requestInfo: { 40 | origin: getDomainUrl(request), 41 | path: new URL(request.url).pathname, 42 | }, 43 | }); 44 | } 45 | 46 | export default function Layout() { 47 | const { navigations, isInPreview } = useLoaderData(); 48 | 49 | return ( 50 | <> 51 |
52 | {isInPreview && } 53 | 54 |
55 |
56 | 57 |
58 |
59 | {/* 143px is the height of the header + footer height (and border) */} 60 |
61 |
73 |
74 |
75 |
76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /app/routes/api.exit-preview.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node'; 2 | import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'; 3 | 4 | import { parseCookie } from '~/utils/parse-cookie.server'; 5 | import { previewModeCookie } from '~/utils/preview-mode.server'; 6 | 7 | export async function loader({}: LoaderFunctionArgs) { 8 | return redirect('/'); 9 | } 10 | 11 | export async function action({ request }: ActionFunctionArgs) { 12 | const cookie = await parseCookie(request, previewModeCookie); 13 | cookie.stage = 'published'; 14 | 15 | return redirect(`/`, { 16 | headers: { 17 | 'Set-Cookie': await previewModeCookie.serialize(cookie), 18 | }, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /app/routes/api.preview.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from '@remix-run/node'; 2 | import type { LoaderFunctionArgs } from '@remix-run/node'; 3 | 4 | import { sdk } from '~/lib/hygraph.server'; 5 | import { previewModeCookie } from '~/utils/preview-mode.server'; 6 | import { parseCookie } from '~/utils/parse-cookie.server'; 7 | 8 | export async function loader({ request }: LoaderFunctionArgs) { 9 | const requestUrl = new URL(request?.url); 10 | const secret = requestUrl?.searchParams?.get('secret'); 11 | const slug = requestUrl?.searchParams?.get('slug'); 12 | 13 | // This secret should only be known to this API route and the CMS 14 | if (secret !== process.env.PREVIEW_SECRET || !slug) { 15 | return json({ message: 'Invalid token' }, { status: 401 }); 16 | } 17 | 18 | // Check if the provided `slug` exists 19 | const { GetPage } = await sdk({ preview: true }); 20 | 21 | const { page } = await GetPage({ 22 | slug, 23 | }); 24 | 25 | // If the slug doesn't exist prevent preview from being enabled 26 | if (!page) { 27 | return json({ message: 'Invalid slug' }, { status: 401 }); 28 | } 29 | 30 | // Enable preview by setting a cookie 31 | const cookie = await parseCookie(request, previewModeCookie); 32 | cookie.stage = 'draft'; 33 | 34 | return redirect(`/${page.slug}`, { 35 | headers: { 36 | 'Set-Cookie': await previewModeCookie.serialize(cookie), 37 | }, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body, 6 | html { 7 | @apply h-full; 8 | } 9 | 10 | body { 11 | @apply antialiased; 12 | } 13 | -------------------------------------------------------------------------------- /app/utils/parse-cookie.server.ts: -------------------------------------------------------------------------------- 1 | import type { Cookie } from '@remix-run/node'; 2 | 3 | export const parseCookie = async (request: Request, cookie: Cookie) => { 4 | const cookieHeader = request.headers.get('Cookie'); 5 | const parsedCookie = (await cookie.parse(cookieHeader)) || {}; 6 | 7 | return parsedCookie; 8 | }; 9 | -------------------------------------------------------------------------------- /app/utils/preview-mode.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookie } from '@remix-run/node'; 2 | 3 | import { parseCookie } from '~/utils/parse-cookie.server'; 4 | 5 | export const previewModeCookie = createCookie('stage', { 6 | path: '/', 7 | sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax', 8 | secure: process.env.NODE_ENV !== 'development', 9 | httpOnly: true, 10 | secrets: [process.env.PREVIEW_SECRET as string], 11 | }); 12 | 13 | export async function isPreviewMode(request: Request) { 14 | const cookie = await parseCookie(request, previewModeCookie); 15 | 16 | return cookie?.stage === 'draft'; 17 | } 18 | -------------------------------------------------------------------------------- /app/utils/seo.ts: -------------------------------------------------------------------------------- 1 | import urljoin from 'url-join'; 2 | 3 | import type { MetaDescriptor } from '@remix-run/react'; 4 | 5 | const defaultDomain = 'https://docs.withheadlesscms.com'; 6 | 7 | export function removeTrailingSlash(s: string) { 8 | return s.endsWith('/') ? s.slice(0, -1) : s; 9 | } 10 | 11 | export function getUrl(requestInfo?: { origin: string; path: string }) { 12 | return removeTrailingSlash( 13 | urljoin(requestInfo?.origin ?? defaultDomain, requestInfo?.path ?? ''), 14 | ); 15 | } 16 | 17 | export function getDomainUrl(request: Request) { 18 | const host = 19 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host'); 20 | 21 | if (!host) { 22 | throw new Error('Could not determine domain URL.'); 23 | } 24 | 25 | const protocol = host.includes('localhost') ? 'http' : 'https'; 26 | 27 | return `${protocol}://${host}`; 28 | } 29 | 30 | export function getSocialMetas({ 31 | url, 32 | title = 'Docs Starter - Hygraph', 33 | description = 'Docs Starter built with Remix and powered by Hygraph. No longer are you tied to markdown files, Git workflows, and writing documentation in your code editor.', 34 | noindex = false, 35 | image, 36 | requestInfo, 37 | }: { 38 | requestInfo?: { origin: string; path: string }; 39 | image?: string; 40 | url: string; 41 | title?: string; 42 | description?: string; 43 | noindex?: boolean; 44 | }): MetaDescriptor[] { 45 | const origin = requestInfo?.origin; 46 | 47 | const parsedImage = image 48 | ? image 49 | : urljoin(origin ?? defaultDomain, '/og-image.png'); 50 | 51 | return [ 52 | { 53 | title, 54 | }, 55 | { 56 | name: 'description', 57 | content: description, 58 | }, 59 | { 60 | name: 'image', 61 | content: parsedImage, 62 | }, 63 | { 64 | name: 'robots', 65 | content: noindex ? 'noindex' : 'index', 66 | }, 67 | { 68 | tagName: 'link', 69 | rel: 'canonical', 70 | href: getUrl(requestInfo), 71 | }, 72 | // Open Graph 73 | { 74 | property: 'og:url', 75 | content: url, 76 | }, 77 | { 78 | property: 'og:title', 79 | content: title, 80 | }, 81 | { 82 | property: 'og:description', 83 | content: description, 84 | }, 85 | { 86 | property: 'og:type', 87 | content: 'website', 88 | }, 89 | { 90 | property: 'og:image', 91 | content: parsedImage, 92 | }, 93 | // Twitter 94 | { 95 | name: 'twitter:title', 96 | content: title, 97 | }, 98 | { 99 | name: 'twitter:description', 100 | content: description, 101 | }, 102 | { 103 | name: 'twitter:card', 104 | content: 'summary_large_image', 105 | }, 106 | { 107 | name: 'twitter:image', 108 | content: parsedImage, 109 | }, 110 | { 111 | name: 'twitter:alt', 112 | content: title, 113 | }, 114 | { 115 | name: 'twitter:creator', 116 | content: '@hygraph', 117 | }, 118 | { 119 | name: 'twitter:site', 120 | content: '@hygraph', 121 | }, 122 | ]; 123 | } 124 | -------------------------------------------------------------------------------- /app/utils/sitemap.server.ts: -------------------------------------------------------------------------------- 1 | import { getSdk } from '~/generated/schema.server'; 2 | import { hygraph } from '~/lib/hygraph.server'; 3 | import { getDomainUrl, getUrl } from './seo'; 4 | 5 | type SitemapEntry = { 6 | loc: string; 7 | lastmod?: string; 8 | changefreq?: 9 | | 'always' 10 | | 'hourly' 11 | | 'daily' 12 | | 'weekly' 13 | | 'monthly' 14 | | 'yearly' 15 | | 'never'; 16 | priority?: 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0; 17 | }; 18 | 19 | export async function getSitemapXml(request: Request) { 20 | const { GetAllPages } = getSdk(hygraph); 21 | const { pages } = await GetAllPages(); 22 | 23 | const domainUrl = getDomainUrl(request); 24 | 25 | const fields: SitemapEntry[] = pages.map((page) => ({ 26 | loc: getUrl({ 27 | origin: domainUrl, 28 | path: page.slug === 'homepage' ? '/' : page.slug, 29 | }), 30 | lastmod: new Date(page.updatedAt).toISOString(), 31 | changefreq: `daily`, 32 | priority: 0.7, 33 | })); 34 | 35 | function getEntry({ loc, lastmod, changefreq, priority }: SitemapEntry) { 36 | return ` 37 | 38 | ${loc} 39 | ${lastmod ? `${lastmod}` : ''} 40 | ${changefreq ? `${changefreq}` : ''} 41 | ${priority ? `${priority}` : ''} 42 | 43 | `.trim(); 44 | } 45 | 46 | return ` 47 | 48 | 53 | ${fields.map((entry) => getEntry(entry)).join('')} 54 | 55 | `.trim(); 56 | } 57 | -------------------------------------------------------------------------------- /app/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | export function slugify(string: string) { 2 | return string 3 | .toString() // Cast to string 4 | .toLowerCase() // Convert the string to lowercase letters 5 | .trim() // Remove whitespace from both sides of a string 6 | .replace(/\s/g, '-') // Replace each space with - 7 | .replace( 8 | /[^\w\-\u00b4\u00C0-\u00C3\u00c7\u00C9-\u00CA\u00CD\u00D3-\u00D5\u00DA\u00E0-\u00E3\u00E7\u00E9-\u00EA\u00ED\u00F3-\u00F5\u00FA]+/g, 9 | '', 10 | ); // Removes all chars that aren't words, -, ´ or some latin characters (À Á Â Ã Ç É Ê Í Ó Ô Õ Ú à á â ã ç é ê í ó ô õ ú) 11 | } 12 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: ${HYGRAPH_ENDPOINT} 3 | documents: './app/**/*.{gql,graphql,ts,tsx}' 4 | 5 | generates: 6 | ./app/generated/schema.server.ts: 7 | plugins: 8 | - typescript 9 | - typescript-operations 10 | - typescript-graphql-request 11 | - fragment-matcher 12 | 13 | config: 14 | gqlImport: graphql-request#gql 15 | 16 | hooks: 17 | afterAllFileWrite: 18 | - prettier --write 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "hygraph-docs-starter", 4 | "description": "Docs Starter built with Remix and powered by Hygraph", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "remix vite:build", 9 | "dev": "remix vite:dev", 10 | "start": "remix-serve ./build/server/index.js", 11 | "generate": "graphql-codegen -r dotenv/config --config codegen.yml", 12 | "format": "prettier --ignore-path .gitignore --write . --no-error-on-unmatched-pattern", 13 | "typecheck": "tsc" 14 | }, 15 | "dependencies": { 16 | "@graphcms/rich-text-react-renderer": "^0.6.1", 17 | "@graphcms/rich-text-types": "^0.5.1", 18 | "@headlessui/react": "^1.7.18", 19 | "@heroicons/react": "^2.1.3", 20 | "@remix-run/node": "^2.8.1", 21 | "@remix-run/react": "^2.8.1", 22 | "@remix-run/serve": "^2.8.1", 23 | "classcat": "^5.0.4", 24 | "graphql": "^16.8.1", 25 | "graphql-request": "^6.1.0", 26 | "isbot": "^4.4.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "remix-utils": "^7.5.0", 30 | "url-join": "^5.0.0" 31 | }, 32 | "devDependencies": { 33 | "@graphql-codegen/cli": "^5.0.2", 34 | "@graphql-codegen/fragment-matcher": "^5.0.2", 35 | "@graphql-codegen/typescript": "^4.0.6", 36 | "@graphql-codegen/typescript-graphql-request": "^6.2.0", 37 | "@graphql-codegen/typescript-operations": "^4.2.0", 38 | "@remix-run/dev": "^2.8.1", 39 | "@tailwindcss/typography": "^0.5.11", 40 | "@types/react": "^18.2.72", 41 | "@types/react-dom": "^18.2.22", 42 | "@types/url-join": "^4.0.3", 43 | "autoprefixer": "^10.4.19", 44 | "dotenv": "^16.4.5", 45 | "postcss": "^8.4.38", 46 | "prettier": "^3.2.5", 47 | "prettier-plugin-tailwindcss": "^0.5.12", 48 | "tailwindcss": "^3.4.1", 49 | "typescript": "^5.4.3", 50 | "vite": "^5.2.6", 51 | "vite-tsconfig-paths": "^4.3.2" 52 | }, 53 | "sideEffects": false 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hygraph/docs-starter/fb79ba79eb5c890ee90ca7f0985e59806be29ace/public/favicon.ico -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hygraph/docs-starter/fb79ba79eb5c890ee90ca7f0985e59806be29ace/public/og-image.png -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from 'tailwindcss'; 2 | 3 | export default { 4 | content: ['./app/**/*.{js,jsx,ts,tsx}'], 5 | theme: { 6 | extend: { 7 | boxShadow: { 8 | image: '0 2px 6px rgba(0,0,0,.08)', 9 | }, 10 | }, 11 | }, 12 | variants: {}, 13 | plugins: [require('@tailwindcss/typography')], 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from '@remix-run/dev'; 2 | import { installGlobals } from '@remix-run/node'; 3 | import { defineConfig } from 'vite'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [remix(), tsconfigPaths()], 10 | server: { 11 | port: 3000, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------