docs: huge migration to runtime asset cache
This commit is contained in:
2
packages/docs/.gitignore
vendored
Normal file
2
packages/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.asset-cache
|
||||
node_modules
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { BuildOptions } from "esbuild"
|
||||
import packageJson from "./package.json"
|
||||
|
||||
export const esbuildConfig: BuildOptions = {
|
||||
entryPoints: [packageJson.source],
|
||||
bundle: true,
|
||||
outfile: packageJson.main,
|
||||
format: "esm",
|
||||
target: "node16",
|
||||
platform: "node",
|
||||
external: Object.keys(packageJson.dependencies),
|
||||
}
|
||||
@@ -2,16 +2,14 @@
|
||||
"name": "reacord-docs-new",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"source": "./src/main.tsx",
|
||||
"main": "./dist/main.js",
|
||||
"scripts": {
|
||||
"build": "esmo --no-warnings scripts/build.ts",
|
||||
"dev": "esmo --no-warnings scripts/dev.ts | pino-colada",
|
||||
"start": "NODE_ENV=production pnpm serve | pino-colada",
|
||||
"serve": "node --experimental-import-meta-resolve --experimental-json-modules --no-warnings --enable-source-maps dist/main.js",
|
||||
"serve": "esmo --experimental-import-meta-resolve --experimental-json-modules --no-warnings --enable-source-maps src/main.tsx | pino-colada",
|
||||
"dev": "nodemon --exec \"pnpm serve\" --watch src --ext ts,tsx,md,css",
|
||||
"start": "NODE_ENV=production pnpm serve",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^1.0.5",
|
||||
"@tailwindcss/typography": "^0.5.0",
|
||||
"clsx": "^1.1.1",
|
||||
"compression": "^1.7.4",
|
||||
@@ -34,7 +32,6 @@
|
||||
"tailwindcss": "^3.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@heroicons/react": "^1.0.5",
|
||||
"@types/browser-sync": "^2.26.3",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.13",
|
||||
@@ -46,8 +43,8 @@
|
||||
"@types/wait-on": "^5.3.1",
|
||||
"autoprefixer": "^10.4.1",
|
||||
"browser-sync": "^2.27.7",
|
||||
"chokidar": "^3.5.2",
|
||||
"execa": "^6.0.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"rxjs": "^7.5.1",
|
||||
"tsup": "^5.11.10",
|
||||
"type-fest": "^2.8.0",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { build } from "esbuild"
|
||||
import { esbuildConfig } from "../esbuild.config"
|
||||
await build(esbuildConfig)
|
||||
@@ -1,138 +0,0 @@
|
||||
import browserSync from "browser-sync"
|
||||
import chokidar from "chokidar"
|
||||
import type { ExecaChildProcess } from "execa"
|
||||
import { execa } from "execa"
|
||||
import pino from "pino"
|
||||
import { concatMap, debounceTime, Observable, tap } from "rxjs"
|
||||
import waitOn from "wait-on"
|
||||
import packageJson from "../package.json"
|
||||
|
||||
const console = pino()
|
||||
|
||||
function awaitChildStopped(child: ExecaChildProcess) {
|
||||
if (child.killed) return
|
||||
return new Promise((resolve) => child.once("close", resolve))
|
||||
}
|
||||
|
||||
class App {
|
||||
app: ExecaChildProcess | undefined
|
||||
|
||||
async start() {
|
||||
console.info(this.app ? "Restarting app..." : "Starting app...")
|
||||
|
||||
await this.stop()
|
||||
|
||||
const [command, ...flags] = packageJson.scripts.serve.split(/\s+/)
|
||||
this.app = execa(command!, flags, {
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
})
|
||||
|
||||
this.app.catch((error) => {
|
||||
if (error.signal !== "SIGINT") {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
void this.app.on("close", () => {
|
||||
this.app = undefined
|
||||
})
|
||||
|
||||
await waitOn({ resources: ["http-get://localhost:3000"] })
|
||||
|
||||
console.info("App running")
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.app) {
|
||||
if (this.app.pid != undefined) {
|
||||
process.kill(-this.app.pid, "SIGINT")
|
||||
} else {
|
||||
this.app.kill("SIGINT")
|
||||
}
|
||||
await awaitChildStopped(this.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Builder {
|
||||
child = execa("tsup", ["--watch"], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
async stop() {
|
||||
this.child.kill()
|
||||
await awaitChildStopped(this.child)
|
||||
}
|
||||
}
|
||||
|
||||
class Browser {
|
||||
browser = browserSync.create()
|
||||
|
||||
constructor() {
|
||||
this.browser.emitter.on("init", () => {
|
||||
console.info("Browsersync started")
|
||||
})
|
||||
this.browser.emitter.on("browser:reload", () => {
|
||||
console.info("Browser reloaded")
|
||||
})
|
||||
}
|
||||
|
||||
init() {
|
||||
this.browser.init({
|
||||
proxy: "http://localhost:3000",
|
||||
port: 3001,
|
||||
ui: false,
|
||||
logLevel: "silent",
|
||||
})
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.browser.reload()
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.browser.exit()
|
||||
}
|
||||
}
|
||||
|
||||
class Watcher {
|
||||
subscription = new Observable<string>((subscriber) => {
|
||||
chokidar
|
||||
.watch(packageJson.main, { ignored: /node_modules/, ignoreInitial: true })
|
||||
.on("all", (_, path) => subscriber.next(path))
|
||||
})
|
||||
.pipe(
|
||||
tap((path) => console.info(`Changed:`, path)),
|
||||
debounceTime(100),
|
||||
concatMap(async () => {
|
||||
await this.app.start()
|
||||
this.browser.reload()
|
||||
}),
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
constructor(private app: App, private browser: Browser) {}
|
||||
|
||||
stop() {
|
||||
this.subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App()
|
||||
const builder = new Builder()
|
||||
const browser = new Browser()
|
||||
const watcher = new Watcher(app, browser)
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.info("Shutting down...")
|
||||
try {
|
||||
await Promise.all([app, browser, watcher, builder].map((it) => it.stop()))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
process.exit()
|
||||
})
|
||||
|
||||
await app.start()
|
||||
browser.init()
|
||||
10
packages/docs/src/asset-builder/asset-builder-context.tsx
Normal file
10
packages/docs/src/asset-builder/asset-builder-context.tsx
Normal 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")
|
||||
85
packages/docs/src/asset-builder/asset-builder.ts
Normal file
85
packages/docs/src/asset-builder/asset-builder.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/docs/src/asset-builder/transform-esbuild.ts
Normal file
28
packages/docs/src/asset-builder/transform-esbuild.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
17
packages/docs/src/asset-builder/transform-postcss.ts
Normal file
17
packages/docs/src/asset-builder/transform-postcss.ts
Normal 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",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
`
|
||||
}
|
||||
3
packages/docs/src/helpers/raise.ts
Normal file
3
packages/docs/src/helpers/raise.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function raise(error: unknown): never {
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { RequestHandler } from "express"
|
||||
|
||||
export function serveFile(path: string): RequestHandler {
|
||||
return (req, res) => res.sendFile(path)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
35
packages/docs/src/landing/landing.tsx
Normal file
35
packages/docs/src/landing/landing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user