decentralize asset transformers

This commit is contained in:
MapleLeaf
2022-01-08 16:08:44 -06:00
parent b6c5c41706
commit 924da96c36
13 changed files with 293 additions and 250 deletions

View File

@@ -1,67 +0,0 @@
import type { RequestHandler } from "express"
import express from "express"
import { createHash } from "node:crypto"
import { mkdir, rm } from "node:fs/promises"
import { join, parse } from "node:path"
import { Promisable } from "type-fest"
import { ensureWrite, normalizeAsFilePath } from "../helpers/filesystem.js"
export type Asset = {
inputFile: string
outputFile: string
url: string
content: string
}
export type AssetTransformer = {
transform: (inputFile: string) => Promise<AssetTransformResult | undefined>
}
export type AssetTransformResult = {
content: string
type: string
}
export class AssetBuilder {
private constructor(
private cacheFolder: string,
private transformers: AssetTransformer[],
) {}
static async create(cacheFolder: string, transformers: AssetTransformer[]) {
if (process.env.NODE_ENV !== "production") {
await rm(cacheFolder, { recursive: true }).catch(() => {})
}
await mkdir(cacheFolder, { recursive: true })
return new AssetBuilder(cacheFolder, transformers)
}
async build(input: Promisable<string | URL>, name?: string): Promise<Asset> {
const inputFile = normalizeAsFilePath(await input)
const { content } = await this.transform(inputFile)
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
const parsedInputFile = parse(inputFile)
const url = `/${name || parsedInputFile.name}.${hash}${parsedInputFile.ext}`
const outputFile = join(this.cacheFolder, url)
await ensureWrite(outputFile, content)
return { inputFile, outputFile, url, content }
}
middleware(): RequestHandler {
return express.static(this.cacheFolder, {
immutable: true,
maxAge: "1y",
})
}
private async transform(inputFile: string) {
for (const transformer of this.transformers) {
const result = await transformer.transform(inputFile)
if (result) return result
}
throw new Error(`No transformers found for ${inputFile}`)
}
}

View File

@@ -0,0 +1,96 @@
import type { RequestHandler } from "express"
import express from "express"
import { createHash } from "node:crypto"
import { mkdir, rm } from "node:fs/promises"
import { join, parse } from "node:path"
import React from "react"
import { renderToStaticMarkup } from "react-dom/server"
import ssrPrepass from "react-ssr-prepass"
import { Promisable } from "type-fest"
import { ensureWrite, normalizeAsFilePath } from "../helpers/filesystem.js"
import { AssetBuilderProvider } from "./asset-builder-context.js"
export type AssetTransformer<Asset> = {
transform: (context: AssetTransformContext) => Promise<Asset>
}
export class AssetBuilder {
private constructor(private cacheFolder: string) {}
static async create(cacheFolder: string) {
if (process.env.NODE_ENV !== "production") {
await rm(cacheFolder, { recursive: true }).catch(() => {})
}
await mkdir(cacheFolder, { recursive: true })
return new AssetBuilder(cacheFolder)
}
async build<Asset>(
input: Promisable<string | URL>,
transformer: AssetTransformer<Asset>,
alias?: string,
): Promise<Asset> {
const inputFile = normalizeAsFilePath(await input)
// TODO: cache assets by inputFile in production
return transformer.transform(
new AssetTransformContext({
inputFile,
cacheFolder: this.cacheFolder,
alias: alias || parse(inputFile).name,
}),
)
}
async render(element: React.ReactElement) {
element = (
<AssetBuilderProvider value={this}>
<React.Suspense fallback={<></>}>{element}</React.Suspense>
</AssetBuilderProvider>
)
await ssrPrepass(element)
return `<!DOCTYPE html>\n${renderToStaticMarkup(element)}`
}
middleware(): RequestHandler {
return express.static(this.cacheFolder, {
immutable: true,
maxAge: "1y",
})
}
}
export type AssetTransformOptions = {
inputFile: string
cacheFolder: string
alias: string
}
export class AssetTransformContext {
constructor(private options: AssetTransformOptions) {}
get inputFile() {
return this.options.inputFile
}
get cacheFolder() {
return this.options.cacheFolder
}
get alias() {
return this.options.alias
}
getOutputFileName(content: string) {
const { ext } = parse(this.inputFile)
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
return `${this.alias}.${hash}${ext}`
}
async writeOutputFile(content: string) {
const outputFileName = this.getOutputFileName(content)
const outputFile = join(this.cacheFolder, outputFileName)
await ensureWrite(outputFile, content)
return { outputFileName, outputFile }
}
}

View File

@@ -1,15 +1,15 @@
import React, { ReactNode } from "react" import React, { ReactNode } from "react"
import { normalizeAsFilePath } from "../helpers/filesystem.js" import { normalizeAsFilePath } from "../helpers/filesystem.js"
import { useAssetBuilder } from "./asset-builder-context.js" import { useAssetBuilder } from "./asset-builder-context.js"
import { Asset, AssetBuilder } from "./asset-builder.js" import { AssetBuilder, AssetTransformer } from "./asset-builder.js"
type AssetState = type AssetState =
| { status: "building"; promise: Promise<unknown> } | { status: "building"; promise: Promise<unknown> }
| { status: "built"; asset: Asset } | { status: "built"; asset: unknown }
const cache = new Map<string, AssetState>() const cache = new Map<string, AssetState>()
function useAssetBuild( function useAssetBuild<Asset>(
cacheKey: string, cacheKey: string,
build: (builder: AssetBuilder) => Promise<Asset>, build: (builder: AssetBuilder) => Promise<Asset>,
) { ) {
@@ -29,33 +29,37 @@ function useAssetBuild(
throw state.promise throw state.promise
} }
return state.asset return state.asset as Asset
} }
export function LocalFileAsset({ export function LocalFileAsset<Asset>({
from, from,
as: name, using: transformer,
as: alias,
children, children,
}: { }: {
from: string | URL from: string | URL
using: AssetTransformer<Asset>
as?: string as?: string
children: (url: Asset) => ReactNode children: (url: Asset) => ReactNode
}) { }) {
const inputFile = normalizeAsFilePath(from) const inputFile = normalizeAsFilePath(from)
const asset = useAssetBuild(inputFile, (builder) => { const asset = useAssetBuild(inputFile, (builder) => {
return builder.build(inputFile, name) return builder.build(inputFile, transformer, alias)
}) })
return <>{children(asset)}</> return <>{children(asset)}</>
} }
export function ModuleAsset({ export function ModuleAsset<Asset>({
from, from,
using: transformer,
as: name, as: name,
children, children,
}: { }: {
from: string from: string
using: AssetTransformer<Asset>
as?: string as?: string
children: (url: Asset) => ReactNode children: (url: Asset) => ReactNode
}) { }) {
@@ -63,7 +67,7 @@ export function ModuleAsset({
const asset = useAssetBuild(cacheKey, async (builder) => { const asset = useAssetBuild(cacheKey, async (builder) => {
const inputFile = await import.meta.resolve!(from) const inputFile = await import.meta.resolve!(from)
return await builder.build(inputFile, name) return await builder.build(inputFile, transformer, name)
}) })
return <>{children(asset)}</> return <>{children(asset)}</>

View File

@@ -0,0 +1,29 @@
import grayMatter from "gray-matter"
import MarkdownIt from "markdown-it"
import prism from "markdown-it-prism"
import { readFile } from "node:fs/promises"
import type { AssetTransformer } from "./asset-builder.jsx"
const renderer = new MarkdownIt({
html: true,
linkify: true,
}).use(prism)
export type MarkdownAsset = {
content: { __html: string }
data: Record<string, any>
}
export const markdownTransformer: AssetTransformer<MarkdownAsset> = {
async transform(context) {
const { data, content } = grayMatter(
await readFile(context.inputFile, "utf8"),
)
const html = renderer.render(content)
return {
content: { __html: html },
data,
}
},
}

View File

@@ -0,0 +1,26 @@
import { build } from "esbuild"
import type { AssetTransformer } from "./asset-builder.jsx"
type ScriptAsset = {
url: string
}
export const scriptTransformer: AssetTransformer<ScriptAsset> = {
async transform(context) {
const scriptBuild = await build({
entryPoints: [context.inputFile],
bundle: true,
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
minify: process.env.NODE_ENV === "production",
})
const content = scriptBuild.outputFiles[0]!.text
const { outputFileName } = await context.writeOutputFile(content)
return {
url: "/" + outputFileName,
}
},
}

View File

@@ -0,0 +1,26 @@
import autoprefixer from "autoprefixer"
import cssnano from "cssnano"
import { readFile } from "node:fs/promises"
import type { AcceptedPlugin } from "postcss"
import postcss from "postcss"
import tailwindcss from "tailwindcss"
import type { AssetTransformer } from "./asset-builder.jsx"
export type StylesheetAsset = { url: string }
export const stylesheetTransformer: AssetTransformer<StylesheetAsset> = {
async transform(context) {
const plugins: AcceptedPlugin[] = [tailwindcss, autoprefixer]
if (process.env.NODE_ENV === "production") {
plugins.push(cssnano)
}
const result = await postcss(plugins).process(
await readFile(context.inputFile),
{ from: context.inputFile },
)
const { outputFileName } = await context.writeOutputFile(result.css)
return { url: "/" + outputFileName }
},
}

View File

@@ -1,22 +0,0 @@
import { build } from "esbuild"
import type { AssetTransformer } from "./asset-builder.js"
export const transformEsbuild: AssetTransformer = {
async transform(inputFile) {
if (/\.[jt]sx?$/.test(inputFile)) {
const scriptBuild = await build({
entryPoints: [inputFile],
bundle: true,
target: ["chrome89", "firefox89"],
format: "esm",
write: false,
minify: process.env.NODE_ENV === "production",
})
return {
content: scriptBuild.outputFiles[0]!.text,
type: "text/javascript",
}
}
},
}

View File

@@ -1,24 +0,0 @@
import grayMatter from "gray-matter"
import MarkdownIt from "markdown-it"
import prism from "markdown-it-prism"
import { readFile } from "node:fs/promises"
import type { AssetTransformer } from "./asset-builder.js"
const renderer = new MarkdownIt({
html: true,
linkify: true,
}).use(prism)
export const transformMarkdown: AssetTransformer = {
async transform(inputFile) {
if (!/\.md$/.test(inputFile)) return
const { data, content } = grayMatter(await readFile(inputFile, "utf8"))
const html = renderer.render(content)
return {
content: html,
type: "text/html",
}
},
}

View File

@@ -1,28 +0,0 @@
import autoprefixer from "autoprefixer"
import cssnano from "cssnano"
import { readFile } from "node:fs/promises"
import type { AcceptedPlugin } from "postcss"
import postcss from "postcss"
import tailwindcss from "tailwindcss"
import type { AssetTransformer } from "./asset-builder.js"
export const transformPostCss: AssetTransformer = {
async transform(inputFile) {
if (!/\.css$/.test(inputFile)) return
const plugins: AcceptedPlugin[] = [tailwindcss, autoprefixer]
if (process.env.NODE_ENV === "production") {
plugins.push(cssnano)
}
const result = await postcss(plugins).process(await readFile(inputFile), {
from: inputFile,
})
return {
content: result.css,
type: "text/css",
}
},
}

View File

@@ -1,6 +1,8 @@
import clsx from "clsx" import clsx from "clsx"
import React from "react" import React from "react"
import { LocalFileAsset } from "../asset-builder/asset.js" import { LocalFileAsset } from "../asset-builder/asset.js"
import { markdownTransformer } from "../asset-builder/markdown-transformer.js"
import { Html } from "../html.js"
import { AppLink } from "../navigation/app-link" import { AppLink } from "../navigation/app-link"
import { guideLinks } from "../navigation/guide-links" import { guideLinks } from "../navigation/guide-links"
import { MainNavigation } from "../navigation/main-navigation" import { MainNavigation } from "../navigation/main-navigation"
@@ -8,12 +10,32 @@ import { docsProseClass, linkClass, maxWidthContainer } from "../ui/components"
export function GuidePage({ url }: { url: string }) { export function GuidePage({ url }: { url: string }) {
return ( return (
<> <LocalFileAsset
from={new URL(`${url}.md`, import.meta.url)}
using={markdownTransformer}
>
{(asset) => (
<Html title={asset.data.title} description={asset.data.description}>
<Header />
<Body content={asset.content} />
</Html>
)}
</LocalFileAsset>
)
}
function Header() {
return (
<header className="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex"> <header className="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex">
<div className={maxWidthContainer}> <div className={maxWidthContainer}>
<MainNavigation /> <MainNavigation />
</div> </div>
</header> </header>
)
}
function Body({ content }: { content: { __html: string } }) {
return (
<main className={clsx(maxWidthContainer, "mt-8 flex items-start gap-4")}> <main className={clsx(maxWidthContainer, "mt-8 flex items-start gap-4")}>
<nav className="w-48 sticky top-24 hidden md:block"> <nav className="w-48 sticky top-24 hidden md:block">
<h2 className="text-2xl">Guides</h2> <h2 className="text-2xl">Guides</h2>
@@ -25,15 +47,10 @@ export function GuidePage({ url }: { url: string }) {
))} ))}
</ul> </ul>
</nav> </nav>
<LocalFileAsset from={new URL(`${url}.md`, import.meta.url)}>
{(asset) => (
<section <section
className={clsx(docsProseClass, "pb-8 flex-1 min-w-0")} className={clsx(docsProseClass, "pb-8 flex-1 min-w-0")}
dangerouslySetInnerHTML={{ __html: asset.content }} dangerouslySetInnerHTML={content}
/> />
)}
</LocalFileAsset>
</main> </main>
</>
) )
} }

View File

@@ -2,16 +2,19 @@ import packageJson from "reacord/package.json"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import React from "react" import React from "react"
import { LocalFileAsset, ModuleAsset } from "./asset-builder/asset.js" import { LocalFileAsset, ModuleAsset } from "./asset-builder/asset.js"
import { scriptTransformer } from "./asset-builder/script-transformer.js"
import { stylesheetTransformer } from "./asset-builder/stylesheet-transformer.js"
export function Html({ export function Html({
title = "Reacord", title: titleProp,
description = packageJson.description, description = packageJson.description,
children, children,
}: { }: {
title?: string title: string
description?: string description?: string
children: ReactNode children: ReactNode
}) { }) {
const title = [titleProp, "Reacord"].filter(Boolean).join(" | ")
return ( return (
<html lang="en" className="bg-slate-900 text-slate-100"> <html lang="en" className="bg-slate-900 text-slate-100">
<head> <head>
@@ -31,17 +34,27 @@ 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" 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"
/> />
<ModuleAsset from="tailwindcss/tailwind.css"> <ModuleAsset
from="tailwindcss/tailwind.css"
using={stylesheetTransformer}
>
{(asset) => <link rel="stylesheet" href={asset.url} />} {(asset) => <link rel="stylesheet" href={asset.url} />}
</ModuleAsset> </ModuleAsset>
<LocalFileAsset from={new URL("ui/prism-theme.css", import.meta.url)}> <LocalFileAsset
from={new URL("ui/prism-theme.css", import.meta.url)}
using={stylesheetTransformer}
>
{(asset) => <link rel="stylesheet" href={asset.url} />} {(asset) => <link rel="stylesheet" href={asset.url} />}
</LocalFileAsset> </LocalFileAsset>
<title>{title}</title> <title>{title}</title>
<ModuleAsset from="alpinejs/dist/cdn.js" as="alpine"> <ModuleAsset
from="alpinejs/dist/cdn.js"
as="alpine"
using={scriptTransformer}
>
{(asset) => <script defer src={asset.url} />} {(asset) => <script defer src={asset.url} />}
</ModuleAsset> </ModuleAsset>
</head> </head>

View File

@@ -1,11 +1,14 @@
import packageJson from "reacord/package.json" import packageJson from "reacord/package.json"
import React from "react" import React from "react"
import { LocalFileAsset } from "../asset-builder/asset.js" import { LocalFileAsset } from "../asset-builder/asset.js"
import { markdownTransformer } from "../asset-builder/markdown-transformer.js"
import { Html } from "../html.js"
import { MainNavigation } from "../navigation/main-navigation" import { MainNavigation } from "../navigation/main-navigation"
import { maxWidthContainer } from "../ui/components" import { maxWidthContainer } from "../ui/components"
export function Landing() { export function Landing() {
return ( return (
<Html>
<div className="flex flex-col min-w-0 min-h-screen text-center"> <div className="flex flex-col min-w-0 min-h-screen text-center">
<header className={maxWidthContainer}> <header className={maxWidthContainer}>
<MainNavigation /> <MainNavigation />
@@ -13,11 +16,14 @@ export function Landing() {
<div className="px-4 pb-8 flex flex-1"> <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"> <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> <h1 className="text-6xl font-light">reacord</h1>
<LocalFileAsset from={new URL("landing-example.md", import.meta.url)}> <LocalFileAsset
from={new URL("landing-example.md", import.meta.url)}
using={markdownTransformer}
>
{(asset) => ( {(asset) => (
<section <section
className="mx-auto text-sm sm:text-base" className="mx-auto text-sm sm:text-base"
dangerouslySetInnerHTML={{ __html: asset.content }} dangerouslySetInnerHTML={asset.content}
/> />
)} )}
</LocalFileAsset> </LocalFileAsset>
@@ -31,5 +37,6 @@ export function Landing() {
</main> </main>
</div> </div>
</div> </div>
</Html>
) )
} }

View File

@@ -1,72 +1,38 @@
import compression from "compression" import compression from "compression"
import type { ErrorRequestHandler, Request, Response } from "express" import type { ErrorRequestHandler, Request } from "express"
import express from "express" import express from "express"
import Router from "express-promise-router" import PromiseRouter from "express-promise-router"
import httpTerminator from "http-terminator" import httpTerminator from "http-terminator"
import pino from "pino" import pino from "pino"
import pinoHttp from "pino-http" import pinoHttp from "pino-http"
import * as React from "react" import * as React from "react"
import { renderToStaticMarkup } from "react-dom/server.js"
import ssrPrepass from "react-ssr-prepass"
import { AssetBuilderProvider } from "./asset-builder/asset-builder-context.js"
import { AssetBuilder } from "./asset-builder/asset-builder.js" import { AssetBuilder } from "./asset-builder/asset-builder.js"
import { transformEsbuild } from "./asset-builder/transform-esbuild.js"
import { transformMarkdown } from "./asset-builder/transform-markdown.js"
import { transformPostCss } from "./asset-builder/transform-postcss.js"
import { fromProjectRoot } from "./constants" import { fromProjectRoot } from "./constants"
import { GuidePage } from "./guides/guide-page" import { GuidePage } from "./guides/guide-page"
import { renderMarkdownFile } from "./helpers/markdown"
import { Html } from "./html.js"
import { Landing } from "./landing/landing" import { Landing } from "./landing/landing"
const logger = pino()
const port = process.env.PORT || 3000 const port = process.env.PORT || 3000
const builder = await AssetBuilder.create(fromProjectRoot(".asset-cache"))
const assets = await AssetBuilder.create(fromProjectRoot(".asset-cache"), [ const logger = pino()
transformEsbuild,
transformPostCss,
transformMarkdown,
])
async function render(res: Response, element: React.ReactElement) {
element = <React.Suspense fallback={<></>}>{element}</React.Suspense>
await ssrPrepass(element)
res.type("html").send(`<!DOCTYPE html>\n${renderToStaticMarkup(element)}`)
}
const errorHandler: ErrorRequestHandler = (error, request, response, next) => { const errorHandler: ErrorRequestHandler = (error, request, response, next) => {
response.status(500).send(error.message) response.status(500).send(error.message)
logger.error(error) logger.error(error)
} }
const router = Router() const router = PromiseRouter()
.use(pinoHttp({ logger })) .use(pinoHttp({ logger }))
.use(compression()) .use(compression())
.use(assets.middleware()) .use(builder.middleware())
.get("/guides/*", async (req: Request<{ 0: string }>, res) => { .get("/guides/*", async (req: Request<{ 0: string }>, res) => {
const { data } = await renderMarkdownFile( res
new URL(`guides/${req.params[0]}.md`, import.meta.url).pathname, .type("html")
) .send(await builder.render(<GuidePage url={req.params[0]} />))
await render(
res,
<AssetBuilderProvider value={assets}>
<Html title={`${data.title} | Reacord`} description={data.description}>
<GuidePage url={req.params[0]} />
</Html>
</AssetBuilderProvider>,
)
}) })
.get("/", async (req, res) => { .get("/", async (req, res) => {
await render( res.type("html").send(await builder.render(<Landing />))
res,
<AssetBuilderProvider value={assets}>
<Html>
<Landing />
</Html>
</AssetBuilderProvider>,
)
}) })
.use(errorHandler) .use(errorHandler)