diff --git a/.vscode/settings.json b/.vscode/settings.json index eba9248c..c57bccf7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,8 @@ { "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "*.0.mdx": "${capture}.*" + "*.0.mdx": "${capture}.*", + "*.mdx": "${capture}.*" }, "explorer.fileNesting.expand": false } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 83b2b78b..12d61be5 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -14,7 +14,12 @@ import { Metadata } from "next" export default function Layout({ children }: { children: ReactNode }) { return ( - + + {/* */} diff --git a/apps/web/content/blog/the-curse-of-markdown.mdx b/apps/web/content/blog/the-curse-of-markdown.mdx index a4a80aae..0302f2dc 100644 --- a/apps/web/content/blog/the-curse-of-markdown.mdx +++ b/apps/web/content/blog/the-curse-of-markdown.mdx @@ -1,62 +1,115 @@ --- title: The Curse of Markdown -description: And the website wasteland -date: 2024-10-12 +description: And the content website wasteland +date: 2024-11-21 authors: [pomber] draft: true --- -import { Chart } from "./the-curse-of-markdown" +import { Layout, RainbowList } from "./the-curse-of-markdown" -Markdown is so good that it can hurt you. + -first +## !!steps lean - +### The Lean-Rich Spectrum -In a world without Markdown, +We can think of content websites on a scale from lean to rich. - +A good example of a **lean content website** is [nat.org](https://nat.org), it's just text with minimal styling. -chapters:s +Making nat.org richer—by adding interactive elements or a fancy layout—won't add much value to its purpose, only distraction from the content. -- introducing richness vs ax -- pre-markdown tradeoffs -- enter markdown -- the tradeoff wall -- enter mdx -- enter rsc -- the content presentation gap -- enter code hike +## !!steps rich -Tradeoffs: https://youtu.be/zqhE-CepH2g?si=7iYgDUjAhJNVmYJN&t=446 +A great example of a **rich content website** is the [Tailwind CSS](https://tailwindcss.com) landing page. -- technical cost vs product benefit +It uses interactive examples, animations, and engaging visuals to highlight how TailwindCSS works and what it can do. -examples of websites that mdx allow: +Making the site leaner—by removing these rich elements—would make it harder for visitors to understand what TailwindCSS offers. -- interactive blogs -- interactive docs +## !!steps rich ---- +Along the spectrum from lean to rich, we can find websites like: -it doesnt mean that there aren't websites in this zone. but they pay a high price, either because they have resources to do so, or because they care a lot about the product. + -Examples are: +- Static blogs +- Documentation websites +- Visual essays +- Explorable explanations +- Interactive tutorials -- stripe -- swiftui + ---- +## !!steps richness -specific tools like: +### The Content-Richness Sweet Spot -- swiftui docc -- tutorialkit -- codehike before v1 +For each piece of content, there is a sweet spot of richness. The perfect richness level depends on how complex the content is. ---- +Not enough richness makes it harder for the author to express their ideas and for the reader to understand them. + +Too much richness requires extra effort from the author and leads to more distractions for the reader. + +## !!steps effort + +### The Cost of Richness + +Making a website richer requires more effort. + +**In a world without Markdown** or any markup language other than HTML, **the cost of building a website grows linearly with its richness**. A slightly richer website requires a slightly higher effort to build. + +## !!steps markdown + +If we add Markdown back into the equation, the cost of building lean websites becomes much lower. Markdown’s simplicity and tooling make it almost effortless to create text-focused sites like blogs or documentation. + +Tools like MDX, Markdoc, and plugins extend the range of websites that can be built with Markdown, allowing for richer websites with some extra effort. + +## !!steps wall + +### The Tradeoff Wall + +But Markdown has its limits. Beyond a certain level, Markdown stops being helpful and the cost of building remains as high as before. + +**This jump in cost disrupts the trade-off between richness and effort**. For content with a sweet spot just beyond Markdown’s limits, the additional effort often seems too high for the small gain in richness, leading to a preference for staying with Markdown and sacrificing richness. + +## !!steps population + +The impact of Markdown becomes clear if we plot a random sample of content websites. (Disclaimer: the data is made-up, based on my perception of the state of the web) + +Most websites are on the lean side. A few outliers sit just beyond Markdown’s limits. At the richer end we see a decent share of websites, where the tradeoff between effort and richness starts to make sense again. + +## !!steps wasteland + +### The Website Wasteland + +That sparse area just beyond the limits of Markdown is of vital importance to the web. These websites are not only a joy to read but also the ones that explore the web's possibilities, embracing the medium and evoking a truly web-native feel. + +Currently, these websites are outliers created by individuals who care deeply about the reader's experience or by companies willing to invest extra effort. We need more of them. + +## !!steps repopulating + +### Repopulating the Wasteland + +To bring more websites into this space, we need tools that lower the effort required to create richer content. + +Some new tools address this by focusing on **specific content-presentation types**, like [Swift-DocC](https://github.com/swiftlang/swift-docc), that compiles Swift into rich developer documentation, or [TutorialKit](https://github.com/stackblitz/tutorialkit) a tool for creating interactive tutorials. + +## !!steps repopulating2 + +While those tools are great, they target very specific types of websites. This was also the case with Code Hike before version 1.0—a tool I built that was limited to a set of layouts without much flexibility. + +But I believe that more **general-purpose tools** are possible. Tools that bring the trade-off between richness and effort back into balance. + +That's my vision for Code Hike v1, and the idea behind [fine-grained markdown](/blog/fine-grained-markdown). + +## !!steps why + +### Why does it matter? + +Imagine how many ideas are held back because their authors don’t have the right tools to express them. -The curse: +By lowering the barriers to richer content websites, we can unlock the web’s unmatched potential to support the full spectrum of richness, allowing every idea to be expressed in the most fitting way for its depth and complexity. -More often than not, the answer is to stick with Markdown and compromise on the complexity of the site. It’s easier, it’s familiar, and it lets you avoid the cost of adopting more complicated solutions. And that’s where the curse lies: Markdown is so effective at the simple stuff that we often don't even try to build things that are slightly more ambitious. The result is a gap in the spectrum of static sites — a whole category of rich, content-driven sites that never get built because the trade-off doesn’t seem worth it. + diff --git a/apps/web/content/blog/the-curse-of-markdown.steps.tsx b/apps/web/content/blog/the-curse-of-markdown.steps.tsx new file mode 100644 index 00000000..313c3e0b --- /dev/null +++ b/apps/web/content/blog/the-curse-of-markdown.steps.tsx @@ -0,0 +1,257 @@ +const lean = { + richAxis: "text-yellow-500", + costAxis: "opacity-0", + richScreenshot: "", + leanScreenshot: "opacity-100", + extra: "opacity-0", + points: [ + { rich: 10, cost: 50, pop: [], className: "bg-teal-600" }, + { rich: 20, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 30, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 40, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 50, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 60, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 70, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 80, cost: 50, pop: [], className: "bg-zinc-800" }, + { rich: 90, cost: 50, pop: [], className: "bg-zinc-800" }, + { + rich: 65, + cost: 33.6, + pop: circle(65, 40.1, 60, 2.5), + className: "bg-teal-600 !opacity-0", + }, + { + rich: 75, + cost: 47.6, + pop: circle(75, 56.3, 60, 2.5), + className: "bg-teal-600 !opacity-0", + }, + ], +} + +const rich = { + leanScreenshot: "opacity-100", + richScreenshot: "opacity-100", + points: [{ rich: 90, className: "bg-pink-600" }], +} + +const spectrum = { + points: [ + { rich: 10, className: "bg-teal-500" }, + { rich: 20, className: "bg-cyan-500" }, + { rich: 30, className: "bg-sky-500" }, + { rich: 40, className: "bg-blue-500" }, + { rich: 50, className: "bg-indigo-500" }, + { rich: 60, className: "bg-violet-500" }, + { rich: 70, className: "bg-purple-500" }, + { rich: 80, className: "bg-fuchsia-500" }, + { rich: 90, className: "bg-pink-500" }, + ], +} + +const richness = { + leanScreenshot: "opacity-0", + richScreenshot: "opacity-0", +} + +const effort = { + leanScreenshot: "opacity-0", + richScreenshot: "opacity-0", + costAxis: "text-yellow-500", + points: [ + { rich: 10, cost: 10 }, + { rich: 20, cost: 20 }, + { rich: 30, cost: 30 }, + { rich: 40, cost: 40 }, + { rich: 50, cost: 50 }, + { rich: 60, cost: 60 }, + { rich: 70, cost: 70 }, + { rich: 80, cost: 80 }, + { rich: 90, cost: 90 }, + ], +} + +const markdown = { + costAxis: "", + points: [ + { rich: 10, cost: 10, pop: line(10, 9.9, 10.3, 24) }, + { rich: 20, cost: 10.8, pop: line(20, 10.3, 11.6, 24) }, + { rich: 30, cost: 12.9, pop: line(30, 11.6, 14.6, 24) }, + { rich: 40, cost: 16.9, pop: line(40, 14.6, 19.9, 24) }, + { rich: 50, cost: 23.6, pop: line(50, 19.9, 28.2, 24) }, + { rich: 60, pop: line(60, 55, 65, 2) }, + { rich: 70, pop: line(70, 65, 75, 3) }, + { rich: 80, pop: line(80, 75, 85, 4) }, + { rich: 90, pop: line(90, 85, 95, 12) }, + ], +} + +const wall = { + wall: "opacity-50 top-0 bottom-0 bg-pink-500/10 border-x-2 border-pink-500/80", +} + +const distribution = { + extra: "opacity-100", + costAxis: "", + points: [ + { rich: 10, pop: line(10, 9.9, 10.3, 16) }, + { rich: 20, pop: line(20, 10.3, 11.6, 24) }, + { rich: 30, pop: line(30, 11.6, 14.6, 24) }, + { rich: 40, pop: line(40, 14.6, 19.9, 24) }, + { rich: 50, pop: line(50, 19.9, 28.2, 24) }, + { rich: 60, pop: line(60, 55, 65, 2) }, + { rich: 70, pop: line(70, 65, 75, 3) }, + { rich: 80, pop: line(80, 75, 85, 4) }, + { rich: 90, pop: line(90, 85, 95, 12) }, + ], +} + +const wasteland = { + wall: "opacity-50 top-0 bottom-0 right-[15%] bg-pink-500/10 border-x-2 border-pink-500/80", + points: [ + { rich: 10, className: "bg-zinc-500" }, + { rich: 20, className: "bg-zinc-500" }, + { rich: 30, className: "bg-zinc-500" }, + { rich: 40, className: "bg-zinc-500" }, + { rich: 50, className: "bg-zinc-500" }, + // { rich: 60, className: "bg-zinc-500" }, + // { rich: 70, className: "bg-zinc-500" }, + // { rich: 80, className: "bg-zinc-500" }, + { rich: 90, className: "bg-zinc-500" }, + ], +} +const repopulating = { + points: [ + { + rich: 65, + cost: 33.6, + className: + "bg-purple-500 data-[main=true]:opacity-0 data-[extra]:animate-appear", + }, + { + rich: 75, + cost: 47.6, + className: + "bg-purple-500 data-[main=true]:opacity-0 data-[extra]:animate-appear", + }, + { rich: 60, className: "bg-zinc-500" }, + { rich: 70, className: "bg-zinc-500" }, + { rich: 80, className: "bg-zinc-500" }, + ], +} +const repopulating2 = { + extra: "", + points: [ + { + rich: 60, + cost: 33.6, + className: "bg-violet-500 data-[extra]:animate-appear", + pop: line(60, 28.2, 40.1, 24), + }, + { + rich: 70, + cost: 47.6, + className: "bg-purple-500 data-[extra]:animate-appear", + pop: line(70, 40.1, 56.3, 24), + }, + { + rich: 80, + cost: 66.2, + className: "bg-fuchsia-500 data-[extra]:animate-appear", + pop: line(80, 56.3, 77.4, 24), + }, + { rich: 90, pop: line(92.5, 77.4, 115, 24, 7.5) }, + { rich: 65, className: "!opacity-0" }, + { rich: 75, className: "!opacity-0" }, + ], +} +const why = { + wall: "opacity-0 top-[50%] bottom-[50%]", + points: [ + { rich: 10, className: "bg-teal-500" }, + { rich: 20, className: "bg-cyan-500" }, + { rich: 30, className: "bg-sky-500" }, + { rich: 40, className: "bg-blue-500" }, + { rich: 50, className: "bg-indigo-500" }, + { rich: 60, className: "bg-violet-500" }, + { rich: 70, className: "bg-purple-500" }, + { rich: 80, className: "bg-fuchsia-500" }, + { rich: 90, className: "bg-pink-500" }, + ], +} + +export const data = merge([ + lean, + rich, + spectrum, + richness, + effort, + markdown, + wall, + distribution, + wasteland, + repopulating, + repopulating2, + why, +]) + +function merge(rawSteps: any) { + let prev = rawSteps[0] + return rawSteps.map((step: any) => { + const points = [...prev.points] + const currentPoints = step.points || [] + currentPoints.forEach((point: any) => { + let i = points.findIndex((p) => p.rich === point.rich) + const prevPoint = points[i] + const newPoint = { ...prevPoint, ...point } + points[i] = newPoint + }) + prev = { ...prev, ...step, points } + return prev + }) +} + +function extra( + rich: number, + cost: number, + n: number, + noise: number, + angle: number = 0, +) { + return Array.from({ length: n * 2 }, (_, i) => { + const dx = (Math.random() * 2 - 1) * noise + const dy = (Math.random() * 2 - 1) * 0 + return { + rich: rich + dx, + cost: cost + Math.sin(angle) * dx + Math.cos(angle) * dy, + } + }) +} + +function line(richc: number, costl: number, costr: number, n: number, rd = 5) { + return Array.from({ length: n * 2 }, (_, i) => { + const costc = (costr + costl) / 2 + const costd = costr - costc + + let rich = 101 + let cost = 101 + while (rich > 100 || rich < 0 || cost < 0 || cost > 100) { + const r = Math.random() * 2 - 1 + const noise = Math.random() * 2 - 1 + rich = richc + r * rd + cost = costc + r * costd + noise * (5 + costd) + } + + return { rich, cost } + }) +} + +function circle(rich: number, cost: number, n: number, radius: number) { + return Array.from({ length: n }, (_, i) => { + const angle = Math.random() * Math.PI * 2 + const r = radius * Math.random() + const dx = Math.cos(angle) * r + const dy = Math.sin(angle) * r + return { rich: rich + dx, cost: cost + dy } + }) +} diff --git a/apps/web/content/blog/the-curse-of-markdown.tsx b/apps/web/content/blog/the-curse-of-markdown.tsx index 916c5531..8984342c 100644 --- a/apps/web/content/blog/the-curse-of-markdown.tsx +++ b/apps/web/content/blog/the-curse-of-markdown.tsx @@ -1,41 +1,264 @@ -const first = [ - { rich: 10, cost: 50, name: "readme" }, - { rich: 20, cost: 50, name: "static blog" }, - { rich: 40, cost: 50, name: "static docs" }, - { rich: 60, cost: 50, name: "interactive blog" }, - { rich: 75, cost: 50, name: "interactive tutorial" }, - { rich: 90, cost: 50, name: "landing page" }, -] - -const second = [ - { rich: 10, cost: 10, name: "readme" }, - { rich: 20, cost: 18, name: "static blog" }, - { rich: 40, cost: 43, name: "static docs" }, - { rich: 60, cost: 56, name: "interactive blog" }, - { rich: 75, cost: 80, name: "interactive tutorial" }, - { rich: 90, cost: 95, name: "landing page" }, -] - -const w = 300 -const h = 200 - -export function Chart({ name }: { name: string }) { - const points = name === "first" ? first : second - // Scatter plot +import { Block, parseProps } from "codehike/blocks" +import { + Selectable, + Selection, + SelectionProvider, +} from "codehike/utils/selection" +import { z } from "zod" +import { data } from "./the-curse-of-markdown.steps" +import { cn } from "@/lib/utils" +import React from "react" + +function Chart({ data }: { data: any }) { + const points = data.points + + return ( +
+ + {points.map((point: any, i: number) => ( + + ))} + + +
+ ) +} + +function Wasteland({ wallClassName = "" }: { wallClassName: string }) { + return ( +
+ ) +} + +function Screenshots({ lean, rich }: { lean?: string; rich?: string }) { return ( -
+ <>
+
- {points.map(({ rich, cost }, i) => ( -
- ))} + nat.org screenshot +
+
+
+ nat.org screenshot
+ + ) +} + +function Axis({ cost, rich }: { cost?: string; rich?: string }) { + return ( + <> +
+
+
+ Richness +
+
+
+
+
Cost
+
+ + ) +} + +function Point({ + rich, + cost, + pop, + index, + className, + extraClassName, +}: { + rich: number + cost: number + name: string + pop: any[] + className?: string + extraClassName: string + index: number +}) { + const extra = pop.map(({ rich, cost }, i) => ( + +
+ + )) + return ( + <> + {extra} +
+ + ) +} + +function RandomOpacity({ children }: { children: React.ReactNode }) { + const opacity = React.useMemo(() => Math.random() * 0.5 + 0.2, []) + return
{children}
+} + +export function Layout(props: unknown) { + const { steps } = parseProps(props, Block.extend({ steps: z.array(Block) })) + return ( + <> +
TODO: small screen
+ +
+
+ ( + + ))} + /> +
+
+
+ {steps.map((step, i) => ( + +
+ {step.children} +
+
+ ))} +
+
+ + ) +} + +export function RainbowList({ children }: { children: React.ReactNode }) { + return ( +
+ {children}
) } diff --git a/apps/web/public/blog/curse/nat.org.png b/apps/web/public/blog/curse/nat.org.png new file mode 100644 index 00000000..bdc59e1b Binary files /dev/null and b/apps/web/public/blog/curse/nat.org.png differ diff --git a/apps/web/public/blog/curse/tailwindcss.com.png b/apps/web/public/blog/curse/tailwindcss.com.png new file mode 100644 index 00000000..221acaa1 Binary files /dev/null and b/apps/web/public/blog/curse/tailwindcss.com.png differ diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index eb7ebf0e..8c840492 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -42,10 +42,15 @@ const config = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + appear: { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + appear: "appear 0.2s ease-out", }, }, }, diff --git a/packages/codehike/src/utils/scroller.tsx b/packages/codehike/src/utils/scroller.tsx index 33bbc945..5bc384db 100644 --- a/packages/codehike/src/utils/scroller.tsx +++ b/packages/codehike/src/utils/scroller.tsx @@ -6,6 +6,8 @@ const ObserverContext = React.createContext( const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect +export type MarginConfig = string | { top: number; height: number } + export function Scroller({ onIndexChange, triggerPosition = "50%", @@ -14,26 +16,48 @@ export function Scroller({ }: { onIndexChange: (index: number) => void triggerPosition?: TriggerPosition - rootMargin?: string + rootMargin?: MarginConfig children: React.ReactNode }) { const [observer, setObserver] = React.useState() const vh = useWindowHeight() + const ratios = React.useRef>({}) useIsomorphicLayoutEffect(() => { const windowHeight = vh || 0 const handleIntersect: IntersectionObserverCallback = (entries) => { + let enteringIndex = -1 entries.forEach((entry) => { + const index = +entry.target.getAttribute("data-index")! + ratios.current[index] = entry.intersectionRatio if (entry.intersectionRatio > 0) { - // get entry.target index - const index = entry.target.getAttribute("data-index") - onIndexChange(+index!) + enteringIndex = index } }) + + if (enteringIndex >= 0) { + // on enter + onIndexChange(enteringIndex) + } else { + // on exit (go tos the higher intersection) + const sorted = Object.entries(ratios.current).sort( + ([, a], [, b]) => b - a, + ) + const [index] = sorted[0] + if (ratios.current[+index] > 0) { + onIndexChange(+index) + } + } } - const observer = newIntersectionObserver( - handleIntersect, - rootMargin || defaultRootMargin(windowHeight, triggerPosition), - ) + const rm = !rootMargin + ? defaultRootMargin(windowHeight, triggerPosition) + : typeof rootMargin === "string" + ? rootMargin + : `${-rootMargin.top}px 0px ${-( + windowHeight - + rootMargin.top - + rootMargin.height + )}px` + const observer = newIntersectionObserver(handleIntersect, rm) setObserver(observer) return () => observer.disconnect() @@ -50,11 +74,16 @@ function newIntersectionObserver( handleIntersect: IntersectionObserverCallback, rootMargin: string, ) { - return new IntersectionObserver(handleIntersect, { - rootMargin, - threshold: 0.000001, - root: null, - }) + try { + return new IntersectionObserver(handleIntersect, { + rootMargin, + threshold: 0.000001, + root: null, + }) + } catch (e) { + // console.error({ rootMargin }) + throw e + } } export function ObservedDiv({ diff --git a/packages/codehike/src/utils/selection.tsx b/packages/codehike/src/utils/selection.tsx index ed0da519..35653019 100644 --- a/packages/codehike/src/utils/selection.tsx +++ b/packages/codehike/src/utils/selection.tsx @@ -1,6 +1,6 @@ "use client" import React from "react" -import { ObservedDiv, Scroller } from "./scroller.js" +import { MarginConfig, ObservedDiv, Scroller } from "./scroller.js" const StepsContext = React.createContext<{ selectedIndex: number @@ -15,7 +15,7 @@ export function SelectionProvider({ rootMargin, ...rest }: React.HTMLAttributes & { - rootMargin?: string + rootMargin?: MarginConfig }) { const [selectedIndex, selectIndex] = React.useState(0) return (