diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index d7640470..83b2b78b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -29,7 +29,7 @@ export const metadata: Metadata = { title: "Code Hike", description: "Use Markdown and React to build rich content websites. Documentation, tutorials, blogs, videos, interactive walkthroughs, and more.", - metadataBase: new URL("https://codehike.org"), + // metadataBase: new URL("https://codehike.org"), openGraph: { title: "Code Hike", images: `https://codehike.org/codehike.png`, diff --git a/apps/web/components/blocks-to-context.tsx b/apps/web/components/blocks-to-context.tsx new file mode 100644 index 00000000..9329c261 --- /dev/null +++ b/apps/web/components/blocks-to-context.tsx @@ -0,0 +1,50 @@ +"use client" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import React from "react" + +const BlocksContext = React.createContext(null) + +export function BlocksToContext({ + children, + ...rest +}: { + children: React.ReactNode + rest: any +}) { + return ( + {children} + ) +} + +export function useBlocksContext(name: string) { + return React.useContext(BlocksContext)[name] +} + +export function WithTooltip({ + children, + name, +}: { + children: React.ReactNode + name: string +}) { + const block = useBlocksContext(name) + const className = block.isCode + ? "p-0 [&>*]:my-0 border-none overflow-auto rounded-none" + : "" + return ( + + + + {children} + + {block?.children} + + + ) +} diff --git a/apps/web/components/code.tsx b/apps/web/components/code.tsx index 3b929f48..d5ecffc3 100644 --- a/apps/web/components/code.tsx +++ b/apps/web/components/code.tsx @@ -26,7 +26,13 @@ import { tooltip } from "./annotations/tooltip" export async function InlineCode({ codeblock }: { codeblock: RawCode }) { const highlighted = await highlight(codeblock, theme) - return + return ( + + ) } export async function Code({ diff --git a/apps/web/components/ui/hover-card.tsx b/apps/web/components/ui/hover-card.tsx new file mode 100644 index 00000000..4570f898 --- /dev/null +++ b/apps/web/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/apps/web/content/blog/build-time-components.mdx b/apps/web/content/blog/build-time-components.mdx new file mode 100644 index 00000000..2e2bb42b --- /dev/null +++ b/apps/web/content/blog/build-time-components.mdx @@ -0,0 +1,533 @@ +--- +title: Build-time Components +description: Why React Server Components are a leap forward for content-driven websites +date: 2024-09-04 +authors: [pomber] +draft: false +--- + +import { + Demo, + Chain, + BlocksToContext, + WithTooltip, +} from "./build-time-components" + +In content-driven websites, it's common to have content that needs some transformation or refinement before being rendered. For example, a blog written in Markdown might need syntax highlighting for code blocks. + +Let's use a small example to illustrate the problem. + +We have a Markdown file with links, we want to make those links show the open graph image of the linked URL in a hover card: + + + +```md content.md -w +# Hello + +Use [Next.js](https://nextjs.org) and [Code Hike](https://codehike.org) +``` + + + +We'll see three ways to solve this problem. + +But first, let's do a quick recap of how the content is transformed from Markdown to the JS we end up deploying to a CDN. + +## What happens to Markdown when we run `next build` + +We have a Next.js app using the Pages Router, the `@next/mdx` plugin, and [static exports](https://nextjs.org/docs/pages/building-your-application/deploying/static-exports). + +Let's see what happens to the `pages/index.jsx` page when we run `next build`: + +```jsx pages/index.jsx -w +import Content from "./content.md" + +function MyLink({ href, children }) { + return {children} +} + +export default function Page() { + return +} +``` + + + +## !intro + +The _`import Content from "./content.md"`_ will make the MDX loader process the `content.md` file. + +## !!steps + +```md ! content.md -w +# Hello + +This is [Code Hike](https://codehike.org) +``` + +### !next + +The mdx loader will process `content.md` in several steps. + +The first step is transforming the source string into a markdown abstract syntax tree (mdast). + +## !!steps + +```json ! Markdown Abstract Syntax Tree -w +{ + "type": "root", + "children": [ + { + "type": "heading", + "depth": 1, + "children": [{ "type": "text", "value": "Hello" }] + }, + { + "type": "paragraph", + "children": [ + { "type": "text", "value": "This is " }, + { + "type": "link", + "url": "https://codehike.org", + "children": [{ "type": "text", "value": "Code Hike" }] + } + ] + } + ] +} +``` + +### !this + +### Remark Plugins + +If there are any remark plugins, they will be applied one by one to the mdast. + +This is where you can plug any transformation you want to apply to the markdown. + +### !next + +After all the remark plugins are applied, the mdast is transformed to another AST: HTML abstract syntax tree (hast). + +It's called HTML abstract syntax tree, but it won't be used to generate HTML, it will be used to generate JSX, which is similar enough. + +## !!steps + +```json ! HTML Abstract Syntax Tree -w +{ + "type": "root", + "children": [ + { + "type": "element", + "tagName": "h1", + "children": [{ "type": "text", "value": "Hello" }] + }, + { + "type": "element", + "tagName": "p", + "children": [ + { "type": "text", "value": "This is " }, + { + "type": "element", + "tagName": "a", + "properties": { "href": "https://codehike.org" }, + "children": [{ "type": "text", "value": "Code Hike" }] + } + ] + } + ] +} +``` + +### !this + +### Rehype Plugins + +If there are any rehype plugins, they will be applied to the hast, one by one. + +At this stage is common to add, for example, syntax highlighting to code blocks. A rehype plugin will find any `pre` element and replace its content with styled `span`s. + +### !next + +The hast is then transformed to another AST: the esast (ECMAScript Abstract Syntax Tree). + +The esast is then transformed to a JSX file. + +This JSX file is the output of the mdx loader, which will pass the control back to the bundler. + +## !!steps + +```jsx ! Compiled Markdown -w +export default function MDXContent(props = {}) { + const _components = { + a: "a", + h1: "h1", + p: "p", + ...props.components, + } + return ( + <> + <_components.h1>Hello + <_components.p> + {"This is "} + <_components.a href="https://codehike.org">Code Hike + + + ) +} +``` + +### !next + +The bundler now understands what the _`import Content from "./content.md"`_ was importing. So it can finish processing the `pages/index.jsx` file, and bundle it together with the compiled `content.md` file. + +It will also compile the JSX away and minify the code, but for clarity let's ignore that. + +## !!steps + +```jsx ! out/pages/index.js -w +import React from "react" + +function Content(props = {}) { + const _components = { + a: "a", + h1: "h1", + p: "p", + ...props.components, + } + return ( + <> + <_components.h1>Hello + <_components.p> + {"This is "} + <_components.a href="https://codehike.org">Code Hike + + + ) +} + +function MyLink({ href, children }) { + return {children} +} + +export default function Page() { + return +} +``` + + + +--- + +Now let's go back to our problem: we want to show the open graph image of the linked URL in a hover card. + +## Client-side approach + +If you didn't know anything about the build process, your first thought might be to fetch the image on the client-side when the link is rendered. So let's start with that. + + + +Let's assume we already have a _`async function scrape(url)`_ that given a URL it fetches the HTML, finds the open graph image tag, and returns the `content` attribute, which is the URL of the image we want. + +We also have a _`function LinkWithCard({ href, children, image })`_ that renders a link with a hover card that shows the image (I'm using [shadcn's Hover Card](https://ui.shadcn.com/docs/components/hover-card)). + +## !one + +!isCode true + +```jsx scraper.js +const regex = / + {children} + + {href} + + + ) +} +``` + + + +A component that solves this client-side would look like this: + +```jsx pages/index.jsx -w +import { useEffect, useState } from "react" +import Content from "./content.mdx" +import { scrape } from "./scraper" +import { LinkWithCard } from "./card" + +function MyLink({ href, children }) { + // !mark(1:6) + const [image, setImage] = useState(null) + useEffect(() => { + scrape(href).then((data) => { + setImage(data.image) + }) + }, [href]) + return ( + + {children} + + ) +} + +export default function Page() { + return +} +``` + +This is a simple approach that gets the job done, but it has some major downsides: + +- every user will be doing fetches for every link in the page +- we are shipping the scraper code to the client + +For different use cases, this approach could even be impossible. For example, if instead of the open graph image we wanted to show a screenshot of the linked URL. + +## Build-time plugin approach + +A more efficient way to solve this problem is to move the scraping part to build-time using something like a rehype plugin: + +```jsx next.config.mjs -w +import { visit } from "unist-util-visit" +import { scrape } from "./scraper" + +function rehypeLinkImage() { + return async (tree) => { + const links = [] + visit(tree, (node) => { + if (node.tagName === "a") { + links.push(node) + } + }) + const promises = links.map(async (node) => { + const url = node.properties.href + const { image } = await scrape(url) + node.properties["data-image"] = image + }) + await Promise.all(promises) + } +} +``` + +This plugin adds a `data-image` attribute to every _``_ tag in the HTML syntax tree (don't worry if you can't follow the code, the fact that it's hard to follow is one of the points I want to make later). + +We can then use this attribute in our component and pass it to the _``_ component: + +```jsx pages/index.jsx -w +import Content from "./content.mdx" +import { LinkWithCard } from "./card" + +function MyLink({ href, children, ...props }) { + const image = props["data-image"] + return ( + + {children} + + ) +} + +export default function Page() { + return +} +``` + +We solve the downsides of the client-side approach. But is this approach strictly better? + +## Comparing the two approaches + +The **build-time plugin** approach: + +- ✅ Fetches on build-time, saving the users from making redundant work +- ✅ Doesn't ship the scraper code to the client + +But the **client-side** approach has some advantages too: + +- ✅ All the behavior is contained in one component, for example, if we want to add the open graph description to the hover card, we can do it in one place +- ✅ We can use the component from other places, not just markdown +- ✅ We don't need to learn how to write rehype plugins + +**It's a trade-off between developer experience and user experience.** + +In this case, the user experience wins. But what if we could remove the trade-off? + +## React Server Components approach + +A third option, that before Next.js 13 wasn't possible, is to use React Server Components: + +```jsx app/page.jsx -w +import { LinkWithCard } from "./card" +import { scrape } from "./scraper" + +async function MyLink({ href, children }) { + const { image } = await scrape(href) + return ( + + {children} + + ) +} + +export default function Page() { + return +} +``` + +With React Server Components (using Next.js App Router), we have one more step when we run `next build`: + + + +## !!steps + +```jsx ! bundled js -w +import React from "react" +import { LinkWithCard } from "./card" +import { scrape } from "./scraper" + +function Content(props = {}) { + const _components = { + a: "a", + h1: "h1", + p: "p", + ...props.components, + } + return ( + <> + <_components.h1>Hello + <_components.p> + {"This is "} + {/* !mark */} + <_components.a href="https://codehike.org">Code Hike + + + ) +} + +async function MyLink({ href, children }) { + const { image } = await scrape(href) + return ( + + {children} + + ) +} + +export default function Page() { + return +} +``` + +### !next + +Since _`function Page()`_ is a server component, it will be run at build-time and replaced by its result. + +The output of _`function Page()`_ is: + +{/* prettier-ignore */} +```jsx -w +<> +

Hello

+

+ {"This is "} + {/* !mark(1:3) */} + + Code Hike + +

+ +``` + +But _`function MyLink()`_ is also a server component, so it will also be resolved at build-time. + +Running _`Code Hike`_ means we are running _`scrape("https://codehike.org")`_ at build-time and replacing the element with: + +```jsx -w + + Code Hike + +``` + +And since we are not using the _`function scrape()`_ anymore, the _`import { scrape } from "./scraper"`_ will be removed from the bundle. + +## !!steps + +```jsx ! out/app/page.js -w +import { LinkWithCard } from "./card" + +export default function Page() { + return ( + <> +

Hello

+

+ {"This is "} + + Code Hike + +

+ + ) +} +``` + +
+ +Just to be clear because the name React **Server** Components can be confusing: **this is happening at build-time**, we can deploy the static output of the build to a CDN. + +Comparing this to the other two approaches, we have all the advantages: + +- ✅ Fetches on build-time, saving the users from making redundant work +- ✅ Doesn't ship the scraper code to the client +- ✅ All the behavior is contained in one component, for example, if we want to add the open graph description to the hover card, we can do it in one place +- ✅ We can use the component from other places, not just markdown +- ✅ We don't need to learn how to write rehype plugins + +without any of the downsides. + +**This approach has the best of both worlds. Best UX, best DX.** + +## Conclusion + +In the talk [Mind the Gap](https://www.youtube.com/watch?v=zqhE-CepH2g), Ryan Florence explains how we have different solutions for handling the network in web apps. Different solutions with different trade-offs. With the introduction of React Server Components, components are now able to _cross_ the network and those trade-offs are gone. + +This same technology that abstracted the network layer is also abstracting the build-time layer. + +I hope these wins in developer experience translate into [richer](/blog/rich-content-websites) content-driven websites. + +--- + +If you need more examples of the new possibilities that React Server Components bring to content websites, here are some that I've been exploring: + +- [Showing Typescript compiler information in codeblocks](/docs/code/twoslash) +- [Transpiling codeblocks to other languages](/docs/code/transpile) +- [Transforming TailwindCSS classes to CSS](https://x.com/pomber/status/1821554941753729451) diff --git a/apps/web/content/blog/build-time-components.tsx b/apps/web/content/blog/build-time-components.tsx new file mode 100644 index 00000000..ddf5f921 --- /dev/null +++ b/apps/web/content/blog/build-time-components.tsx @@ -0,0 +1,154 @@ +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" +import { Block, CodeBlock, parseProps } from "codehike/blocks" +import { z } from "zod" +import { Code } from "@/components/code" +import { Fragment } from "react" +export { BlocksToContext, WithTooltip } from "@/components/blocks-to-context" + +export function Chain(props: unknown) { + const { intro, steps, outro } = parseProps( + props, + Block.extend({ + intro: Block.optional(), + steps: z.array( + Block.extend({ + code: CodeBlock, + this: Block.optional(), + next: Block.optional(), + }), + ), + outro: Block.optional(), + }), + ) + return ( +
+
+ {intro && ( + <> + +
{intro?.children}
+ + )} +
+ {steps.map((step, i) => ( + +
+ +
+ +
+ {step.this && ( + <> + +
+ {step.this.children} +
+ + )} +
+ {i < steps.length - 1 && ( +
+ +
+ {step.next?.children} +
+
+ )} +
+ ))} +
{outro?.children}
+
+ ) +} + +function Arrow({ intro }: { intro?: boolean }) { + return ( +
+ {!intro &&
} +
+
+
+
+
+ ) +} + +export function Demo({ children }: { children: React.ReactNode }) { + const preview = ( +
+
+ Hover the links: +
+
+

Hello

+

+ Use{" "} + + Next.js + {" "} + and{" "} + + Code Hike + +

+
+
+ ) + return ( +
+
{children}
+ {preview} +
+ ) +} + +function LinkWithCard({ + href, + children, + image, +}: { + href: string + children: React.ReactNode + image: string +}) { + return ( + + + {children} + {href} + + + {href} + + + ) +} diff --git a/apps/web/content/blog/from-remark-to-rsc.mdx b/apps/web/content/blog/from-remark-to-rsc.mdx deleted file mode 100644 index e79b9f42..00000000 --- a/apps/web/content/blog/from-remark-to-rsc.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: From Remark to React Server Components -description: The right abstraction for better flexibility and composability -date: 2024-02-20 -authors: [pomber] -draft: true ---- - -Content usually needs some kind of transformation before being rendered. - -A remark plugin is a function that transforms pieces of a markdown file. diff --git a/apps/web/content/blog/the-curse-of-markdown.mdx b/apps/web/content/blog/the-curse-of-markdown.mdx index 71c944db..16bbcb23 100644 --- a/apps/web/content/blog/the-curse-of-markdown.mdx +++ b/apps/web/content/blog/the-curse-of-markdown.mdx @@ -6,4 +6,6 @@ authors: [pomber] draft: true --- -test +Tradeoffs: https://youtu.be/zqhE-CepH2g?si=7iYgDUjAhJNVmYJN&t=446 + +- technical cost vs product benefit diff --git a/apps/web/package.json b/apps/web/package.json index a45766c4..cea779f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "@code-hike/lighter": "0.9.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.0.3", diff --git a/apps/web/public/blog/build-time-components.png b/apps/web/public/blog/build-time-components.png new file mode 100644 index 00000000..0c956e5e Binary files /dev/null and b/apps/web/public/blog/build-time-components.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10a45101..18e9a442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-hover-card': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-select': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1046,6 +1049,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-hover-card@1.1.1': + resolution: {integrity: sha512-IwzAOP97hQpDADYVKrEEHUH/b2LA+9MgB0LgdmnbFO2u/3M5hmEofjjr2M6CyzUblaAqJdFm6B7oFtU72DPXrA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.0.0': resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} peerDependencies: @@ -5297,6 +5313,23 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-hover-card@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-id@1.0.0(react@18.2.0)': dependencies: '@babel/runtime': 7.23.7