docs: huge migration to runtime asset cache

This commit is contained in:
MapleLeaf
2022-01-07 02:32:50 -06:00
parent 666215319e
commit ba603fca9e
41 changed files with 270 additions and 450 deletions

View File

@@ -0,0 +1,10 @@
import { createContext, useContext } from "react"
import { AssetBuilder } from "../asset-builder/asset-builder.js"
import { raise } from "../helpers/raise.js"
const Context = createContext<AssetBuilder>()
export const AssetBuilderProvider = Context.Provider
export const useAssetBuilder = () =>
useContext(Context) ?? raise("AssetBuilderProvider not found")

View File

@@ -0,0 +1,85 @@
import { RequestHandler } from "express"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
import { mkdir, stat, writeFile } from "node:fs/promises"
import { dirname, extname, join, parse } from "node:path"
export type Asset = {
file: string
url: string
content: Buffer
}
export type AssetTransformer = {
transform: (asset: Asset) => Promise<AssetTransformResult | undefined>
}
export type AssetTransformResult = {
content: string
type: string
}
export class AssetBuilder {
// map of asset urls to asset objects
private library = new Map<string, Asset>()
constructor(
private cacheFolder: string,
private transformers: AssetTransformer[],
) {}
// accepts a path to a file, then returns a url to where the built file will be served
// the url will include a hash of the file contents
file(file: string | URL): string {
if (file instanceof URL) {
file = file.pathname
}
const existing = this.library.get(file)
if (existing) {
return existing.url
}
const content = readFileSync(file)
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
const { name, ext } = parse(file)
const url = `/${name}.${hash}${ext}`
this.library.set(url, { file, url, content })
return url
}
middleware(): RequestHandler {
return async (req, res, next) => {
try {
const asset = this.library.get(req.path)
if (!asset) return next()
const file = join(this.cacheFolder, asset.url)
const extension = extname(file)
const stats = await stat(file).catch(() => undefined)
if (stats?.isFile()) {
res
.status(200)
.type(extension.endsWith("tsx") ? "text/javascript" : extension)
.sendFile(file)
return
}
for (const transformer of this.transformers) {
const result = await transformer.transform(asset)
if (result) {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, result.content)
return res.type(extension).send(result.content)
}
}
next()
} catch (error) {
next(error)
}
}
}
}

View File

@@ -0,0 +1,28 @@
import { build } from "esbuild"
import { readFile } from "node:fs/promises"
import { dirname } from "node:path"
import { AssetTransformer } from "./asset-builder.js"
export const transformEsbuild: AssetTransformer = {
async transform(asset) {
if (asset.file.match(/\.tsx?$/)) {
const scriptBuild = await build({
bundle: true,
stdin: {
contents: await readFile(asset.file, "utf-8"),
sourcefile: asset.file,
loader: "tsx",
resolveDir: dirname(asset.file),
},
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
})
return {
content: scriptBuild.outputFiles[0]!.text,
type: "text/javascript",
}
}
},
}

View File

@@ -0,0 +1,17 @@
import postcss from "postcss"
import tailwindcss from "tailwindcss"
import { AssetTransformer } from "./asset-builder.js"
export const transformPostCss: AssetTransformer = {
async transform(asset) {
if (!asset.file.match(/\.css$/)) return
const result = await postcss(tailwindcss).process(asset.content, {
from: asset.file,
})
return {
content: result.css,
type: "text/css",
}
},
}

View File

@@ -1,23 +0,0 @@
import * as React from "react"
import { mainLinks } from "../data/main-links"
import type { AppLinkProps } from "./app-link"
import { AppLink } from "./app-link"
import { PopoverMenu } from "./popover-menu"
export type MainNavigationMobileMenuData = {
guideLinks: AppLinkProps[]
}
export function render(data: MainNavigationMobileMenuData) {
return (
<PopoverMenu>
{mainLinks.map((link) => (
<AppLink {...link} key={link.to} className={PopoverMenu.itemClass} />
))}
<hr className="border-0 h-[2px] bg-black/50" />
{data.guideLinks.map((link) => (
<AppLink {...link} key={link.to} className={PopoverMenu.itemClass} />
))}
</PopoverMenu>
)
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from "react"
import React from "react"
import type { Merge } from "type-fest"
export function Script({
children,
...props
}: Merge<ComponentPropsWithoutRef<"script">, { children: string }>) {
return (
<script
type="module"
dangerouslySetInnerHTML={{ __html: children }}
{...props}
/>
)
}

View File

@@ -1,26 +1,13 @@
import clsx from "clsx"
import React from "react"
import { AppLink } from "../components/app-link"
import { MainNavigation } from "../components/main-navigation"
import { guideLinks } from "../data/guide-links"
import { Html } from "../html"
import {
docsProseClass,
linkClass,
maxWidthContainer,
} from "../styles/components"
import { AppLink } from "../navigation/app-link"
import { guideLinks } from "../navigation/guide-links"
import { MainNavigation } from "../navigation/main-navigation"
import { docsProseClass, linkClass, maxWidthContainer } from "../ui/components"
export default function DocsPage({
title,
description,
html,
}: {
title: string
description: string
html: string
}) {
export default function GuidePage({ html }: { html: string }) {
return (
<Html title={`${title} | Reacord`} description={description}>
<>
<header className="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex">
<div className={maxWidthContainer}>
<MainNavigation />
@@ -42,6 +29,6 @@ export default function DocsPage({
dangerouslySetInnerHTML={{ __html: html }}
/>
</main>
</Html>
</>
)
}

View File

@@ -1,51 +0,0 @@
import { build } from "esbuild"
import { readFile } from "node:fs/promises"
import { dirname } from "node:path"
import React from "react"
import { Script } from "../components/script"
let nextId = 0
export async function createHydrater<Data>(scriptFilePath: string) {
const id = `hydrate-root-${nextId}`
nextId += 1
const scriptSource = await readFile(scriptFilePath, "utf-8")
const scriptBuild = await build({
bundle: true,
stdin: {
contents: [scriptSource, clientBootstrap(id)].join(";\n"),
sourcefile: scriptFilePath,
loader: "tsx",
resolveDir: dirname(scriptFilePath),
},
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
})
const serverModule = await import(scriptFilePath)
return function Hydrater({ data }: { data: Data }) {
return (
<>
<div id={id} data-server-data={JSON.stringify(data)}>
{serverModule.render(data)}
</div>
<Script>{scriptBuild.outputFiles[0]?.text!}</Script>
</>
)
}
}
function clientBootstrap(id: string) {
return /* ts */ `
import { createRoot } from "react-dom"
const rootElement = document.querySelector("#${id}")
const data = JSON.parse(rootElement.dataset.serverData)
createRoot(rootElement).render(render(data))
`
}

View File

@@ -0,0 +1,3 @@
export function raise(error: unknown): never {
throw error instanceof Error ? error : new Error(String(error))
}

View File

@@ -1,7 +0,0 @@
import type { Response } from "express"
import { renderToStaticMarkup } from "react-dom/server.js"
export function sendJsx(res: Response, jsx: React.ReactElement) {
res.set("Content-Type", "text/html")
res.send(`<!DOCTYPE html>\n${renderToStaticMarkup(jsx)}`)
}

View File

@@ -1,26 +0,0 @@
import { build } from "esbuild"
import type { RequestHandler } from "express"
import { readFile } from "node:fs/promises"
import { dirname } from "node:path"
export async function serveCompiledScript(
scriptFilePath: string,
): Promise<RequestHandler> {
const scriptBuild = await build({
bundle: true,
stdin: {
contents: await readFile(scriptFilePath, "utf-8"),
sourcefile: scriptFilePath,
loader: "tsx",
resolveDir: dirname(scriptFilePath),
},
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
})
return (req, res) => {
res.setHeader("Content-Type", "application/javascript")
res.end(scriptBuild.outputFiles[0]!.contents)
}
}

View File

@@ -1,5 +0,0 @@
import type { RequestHandler } from "express"
export function serveFile(path: string): RequestHandler {
return (req, res) => res.sendFile(path)
}

View File

@@ -1,25 +0,0 @@
import type { RequestHandler } from "express"
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import type { Result } from "postcss"
import postcss from "postcss"
import tailwindcss from "tailwindcss"
const tailwindTemplatePath = fileURLToPath(
await import.meta.resolve!("tailwindcss/tailwind.css"),
)
const tailwindTemplate = await readFile(tailwindTemplatePath, "utf-8")
let result: Result | undefined
export function serveTailwindCss(): RequestHandler {
return async (req, res) => {
if (!result || process.env.NODE_ENV !== "production") {
result = await postcss(tailwindcss).process(tailwindTemplate, {
from: tailwindTemplatePath,
})
}
res.set("Content-Type", "text/css").send(result.css)
}
}

View File

@@ -1,10 +0,0 @@
import { useState } from "react"
import { useWindowEvent } from "./use-window-event"
export function useScrolled() {
const [scrolled, setScrolled] = useState(
typeof window !== "undefined" ? window.scrollY > 0 : false,
)
useWindowEvent("scroll", () => setScrolled(window.scrollY > 0))
return scrolled
}

View File

@@ -1,11 +0,0 @@
import { useEffect } from "react"
export function useWindowEvent<EventType extends keyof WindowEventMap>(
type: EventType,
handler: (event: WindowEventMap[EventType]) => void,
) {
useEffect(() => {
window.addEventListener(type, handler)
return () => window.removeEventListener(type, handler)
})
}

View File

@@ -1,6 +1,11 @@
import packageJson from "reacord/package.json"
import type { ReactNode } from "react"
import React from "react"
import { useAssetBuilder } from "./asset-builder/asset-builder-context.js"
const tailwindCssPath = new URL(
await import.meta.resolve!("tailwindcss/tailwind.css"),
).pathname
export function Html({
title = "Reacord",
@@ -11,6 +16,7 @@ export function Html({
description?: string
children: ReactNode
}) {
const assets = useAssetBuilder()
return (
<html lang="en" className="bg-slate-900 text-slate-100">
<head>
@@ -28,10 +34,11 @@ export function Html({
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Rubik:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap"
rel="stylesheet"
/>
<link href="/tailwind.css" rel="stylesheet" />
<link href="/prism-theme.css" rel="stylesheet" />
<script type="module" src="/popover-menu.client.js" />
<link href={assets.file(tailwindCssPath)} rel="stylesheet" />
<link
href={assets.file(new URL("ui/prism-theme.css", import.meta.url))}
rel="stylesheet"
/>
<title>{title}</title>
</head>

View File

@@ -0,0 +1,35 @@
import packageJson from "reacord/package.json"
import React from "react"
import { renderMarkdownFile } from "../helpers/markdown"
import { MainNavigation } from "../navigation/main-navigation"
import { maxWidthContainer } from "../ui/components"
const landingExample = await renderMarkdownFile(
new URL("landing-example.md", import.meta.url).pathname,
)
export function Landing() {
return (
<div className="flex flex-col min-w-0 min-h-screen text-center">
<header className={maxWidthContainer}>
<MainNavigation />
</header>
<div className="px-4 pb-8 flex flex-1">
<main className="px-4 py-6 rounded-lg shadow bg-slate-800 space-y-5 m-auto w-full max-w-xl">
<h1 className="text-6xl font-light">reacord</h1>
<section
className="mx-auto text-sm sm:text-base"
dangerouslySetInnerHTML={{ __html: landingExample.html }}
/>
<p className="text-2xl font-light">{packageJson.description}</p>
<a
href="/guides/getting-started"
className="inline-block px-4 py-3 text-xl transition rounded-lg bg-emerald-700 hover:translate-y-[-2px] hover:bg-emerald-800 hover:shadow"
>
Get Started
</a>
</main>
</div>
</div>
)
}

View File

@@ -1,23 +1,35 @@
import compression from "compression"
import type { ErrorRequestHandler, Request } from "express"
import type { ErrorRequestHandler, Request, Response } from "express"
import express from "express"
import Router from "express-promise-router"
import httpTerminator from "http-terminator"
import pino from "pino"
import pinoHttp from "pino-http"
import * as React from "react"
import { renderToStaticMarkup } from "react-dom/server.js"
import { AssetBuilderProvider } from "./asset-builder/asset-builder-context.js"
import { AssetBuilder } from "./asset-builder/asset-builder.js"
import { transformEsbuild } from "./asset-builder/transform-esbuild.js"
import { transformPostCss } from "./asset-builder/transform-postcss.js"
import { fromProjectRoot } from "./constants"
import GuidePage from "./guides/guide-page"
import { renderMarkdownFile } from "./helpers/markdown"
import { sendJsx } from "./helpers/send-jsx"
import { serveCompiledScript } from "./helpers/serve-compiled-script"
import { serveFile } from "./helpers/serve-file"
import { serveTailwindCss } from "./helpers/tailwind"
import DocsPage from "./pages/docs"
import { Landing } from "./pages/landing"
import { Html } from "./html.js"
import { Landing } from "./landing/landing"
const logger = pino()
const port = process.env.PORT || 3000
const assets = new AssetBuilder(fromProjectRoot(".asset-cache"), [
transformEsbuild,
transformPostCss,
])
export function sendJsx(res: Response, jsx: React.ReactElement) {
res.set("Content-Type", "text/html")
res.send(`<!DOCTYPE html>\n${renderToStaticMarkup(jsx)}`)
}
const errorHandler: ErrorRequestHandler = (error, request, response, next) => {
response.status(500).send(error.message)
logger.error(error)
@@ -26,36 +38,31 @@ const errorHandler: ErrorRequestHandler = (error, request, response, next) => {
const router = Router()
.use(pinoHttp({ logger }))
.use(compression())
.get("/tailwind.css", serveTailwindCss())
.use(assets.middleware())
.get(
"/prism-theme.css",
serveFile(fromProjectRoot("src/styles/prism-theme.css")),
)
.get(
"/popover-menu.client.js",
await serveCompiledScript(
fromProjectRoot("src/components/popover-menu.client.tsx"),
),
)
.get("/docs/*", async (req: Request<{ 0: string }>, res) => {
.get("/guides/*", async (req: Request<{ 0: string }>, res) => {
const { html, data } = await renderMarkdownFile(
fromProjectRoot(`src/docs/${req.params[0]}.md`),
new URL(`guides/${req.params[0]}.md`, import.meta.url).pathname,
)
sendJsx(
res,
<DocsPage
title={data.title}
description={data.description}
html={html}
/>,
<AssetBuilderProvider value={assets}>
<Html title={`${data.title} | Reacord`} description={data.description}>
<GuidePage html={html} />
</Html>
</AssetBuilderProvider>,
)
})
.get("/", (req, res) => {
sendJsx(res, <Landing />)
sendJsx(
res,
<AssetBuilderProvider value={assets}>
<Html>
<Landing />
</Html>
</AssetBuilderProvider>,
)
})
.use(errorHandler)

View File

@@ -1,5 +1,5 @@
import React from "react"
import { ExternalLink } from "./external-link"
import { ExternalLink } from "../dom/external-link"
export type AppLinkProps = {
type: "internal" | "external"

View File

@@ -2,15 +2,14 @@ import glob from "fast-glob"
import grayMatter from "gray-matter"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import type { AppLinkProps } from "../components/app-link"
import { fromProjectRoot } from "../constants"
import type { AppLinkProps } from "./app-link"
const docsFolderPath = fromProjectRoot("src/docs")
const guideFiles = await glob("**/*.md", { cwd: docsFolderPath })
const docsFolder = new URL("../guides", import.meta.url).pathname
const guideFiles = await glob("**/*.md", { cwd: docsFolder })
const entries = await Promise.all(
guideFiles.map(async (file) => {
const content = await readFile(join(docsFolderPath, file), "utf-8")
const content = await readFile(join(docsFolder, file), "utf-8")
const { data } = grayMatter(content)
let order = Number(data.order)
@@ -19,7 +18,7 @@ const entries = await Promise.all(
}
return {
route: `/docs/${file.replace(/\.mdx?$/, "")}`,
route: `/guides/${file.replace(/\.mdx?$/, "")}`,
title: String(data.title || ""),
order,
}

View File

@@ -4,13 +4,13 @@ import {
ExternalLinkIcon,
} from "@heroicons/react/solid/esm"
import React from "react"
import type { AppLinkProps } from "../components/app-link"
import { inlineIconClass } from "../styles/components"
import { inlineIconClass } from "../ui/components"
import type { AppLinkProps } from "./app-link"
export const mainLinks: AppLinkProps[] = [
{
type: "internal",
to: "/docs/getting-started",
to: "/guides/getting-started",
label: (
<>
<DocumentTextIcon className={inlineIconClass} /> Guides
@@ -19,7 +19,7 @@ export const mainLinks: AppLinkProps[] = [
},
{
type: "internal",
to: "/docs/api",
to: "/guides/api",
label: (
<>
<CodeIcon className={inlineIconClass} /> API Reference

View File

@@ -1,9 +1,9 @@
import React from "react"
import { guideLinks } from "../data/guide-links"
import { mainLinks } from "../data/main-links"
import { linkClass } from "../styles/components"
import { linkClass } from "../ui/components"
import { PopoverMenu } from "../ui/popover-menu"
import { AppLink } from "./app-link"
import { PopoverMenu } from "./popover-menu"
import { guideLinks } from "./guide-links"
import { mainLinks } from "./main-links"
export function MainNavigation() {
return (

View File

@@ -1,39 +0,0 @@
import packageJson from "reacord/package.json"
import React from "react"
import { MainNavigation } from "../components/main-navigation"
import { fromProjectRoot } from "../constants"
import { renderMarkdownFile } from "../helpers/markdown"
import { Html } from "../html"
import { maxWidthContainer } from "../styles/components"
const landingExample = await renderMarkdownFile(
fromProjectRoot("src/components/landing-example.md"),
)
export function Landing() {
return (
<Html>
<div className="flex flex-col min-w-0 min-h-screen text-center">
<header className={maxWidthContainer}>
<MainNavigation />
</header>
<div className="px-4 pb-8 flex flex-1">
<main className="px-4 py-6 rounded-lg shadow bg-slate-800 space-y-5 m-auto w-full max-w-xl">
<h1 className="text-6xl font-light">reacord</h1>
<section
className="mx-auto text-sm sm:text-base"
dangerouslySetInnerHTML={{ __html: landingExample.html }}
/>
<p className="text-2xl font-light">{packageJson.description}</p>
<a
href="/docs/getting-started"
className="inline-block px-4 py-3 text-xl transition rounded-lg bg-emerald-700 hover:translate-y-[-2px] hover:bg-emerald-800 hover:shadow"
>
Get Started
</a>
</main>
</div>
</div>
</Html>
)
}

View File

@@ -1,9 +1,11 @@
import { MenuAlt4Icon } from "@heroicons/react/outline/esm"
import clsx from "clsx"
import React from "react"
import { linkClass } from "../styles/components"
import { useAssetBuilder } from "../asset-builder/asset-builder-context.js"
import { linkClass } from "./components"
export function PopoverMenu({ children }: { children: React.ReactNode }) {
const assets = useAssetBuilder()
return (
<div data-popover className="relative">
<button data-popover-button title="Menu" className={linkClass}>
@@ -16,6 +18,10 @@ export function PopoverMenu({ children }: { children: React.ReactNode }) {
>
{children}
</div>
<script
type="module"
src={assets.file(new URL("popover-menu.client.tsx", import.meta.url))}
/>
</div>
)
}