diff --git a/packages/docs/src/asset-builder/asset-builder.ts b/packages/docs/src/asset-builder/asset-builder.ts deleted file mode 100644 index b20ed74..0000000 --- a/packages/docs/src/asset-builder/asset-builder.ts +++ /dev/null @@ -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 -} - -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, name?: string): Promise { - 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}`) - } -} diff --git a/packages/docs/src/asset-builder/asset-builder.tsx b/packages/docs/src/asset-builder/asset-builder.tsx new file mode 100644 index 0000000..74801d0 --- /dev/null +++ b/packages/docs/src/asset-builder/asset-builder.tsx @@ -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 = { + transform: (context: AssetTransformContext) => Promise +} + +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( + input: Promisable, + transformer: AssetTransformer, + alias?: string, + ): Promise { + 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 = ( + + }>{element} + + ) + await ssrPrepass(element) + return `\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 } + } +} diff --git a/packages/docs/src/asset-builder/asset.tsx b/packages/docs/src/asset-builder/asset.tsx index 758b5ea..dd7f1e4 100644 --- a/packages/docs/src/asset-builder/asset.tsx +++ b/packages/docs/src/asset-builder/asset.tsx @@ -1,15 +1,15 @@ import React, { ReactNode } from "react" import { normalizeAsFilePath } from "../helpers/filesystem.js" import { useAssetBuilder } from "./asset-builder-context.js" -import { Asset, AssetBuilder } from "./asset-builder.js" +import { AssetBuilder, AssetTransformer } from "./asset-builder.js" type AssetState = | { status: "building"; promise: Promise } - | { status: "built"; asset: Asset } + | { status: "built"; asset: unknown } const cache = new Map() -function useAssetBuild( +function useAssetBuild( cacheKey: string, build: (builder: AssetBuilder) => Promise, ) { @@ -29,33 +29,37 @@ function useAssetBuild( throw state.promise } - return state.asset + return state.asset as Asset } -export function LocalFileAsset({ +export function LocalFileAsset({ from, - as: name, + using: transformer, + as: alias, children, }: { from: string | URL + using: AssetTransformer as?: string children: (url: Asset) => ReactNode }) { const inputFile = normalizeAsFilePath(from) const asset = useAssetBuild(inputFile, (builder) => { - return builder.build(inputFile, name) + return builder.build(inputFile, transformer, alias) }) return <>{children(asset)} } -export function ModuleAsset({ +export function ModuleAsset({ from, + using: transformer, as: name, children, }: { from: string + using: AssetTransformer as?: string children: (url: Asset) => ReactNode }) { @@ -63,7 +67,7 @@ export function ModuleAsset({ const asset = useAssetBuild(cacheKey, async (builder) => { const inputFile = await import.meta.resolve!(from) - return await builder.build(inputFile, name) + return await builder.build(inputFile, transformer, name) }) return <>{children(asset)} diff --git a/packages/docs/src/asset-builder/markdown-transformer.ts b/packages/docs/src/asset-builder/markdown-transformer.ts new file mode 100644 index 0000000..a235018 --- /dev/null +++ b/packages/docs/src/asset-builder/markdown-transformer.ts @@ -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 +} + +export const markdownTransformer: AssetTransformer = { + async transform(context) { + const { data, content } = grayMatter( + await readFile(context.inputFile, "utf8"), + ) + const html = renderer.render(content) + + return { + content: { __html: html }, + data, + } + }, +} diff --git a/packages/docs/src/asset-builder/script-transformer.ts b/packages/docs/src/asset-builder/script-transformer.ts new file mode 100644 index 0000000..13e8259 --- /dev/null +++ b/packages/docs/src/asset-builder/script-transformer.ts @@ -0,0 +1,26 @@ +import { build } from "esbuild" +import type { AssetTransformer } from "./asset-builder.jsx" + +type ScriptAsset = { + url: string +} + +export const scriptTransformer: AssetTransformer = { + 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, + } + }, +} diff --git a/packages/docs/src/asset-builder/stylesheet-transformer.ts b/packages/docs/src/asset-builder/stylesheet-transformer.ts new file mode 100644 index 0000000..33813ea --- /dev/null +++ b/packages/docs/src/asset-builder/stylesheet-transformer.ts @@ -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 = { + 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 } + }, +} diff --git a/packages/docs/src/asset-builder/transform-esbuild.ts b/packages/docs/src/asset-builder/transform-esbuild.ts deleted file mode 100644 index 037e4a9..0000000 --- a/packages/docs/src/asset-builder/transform-esbuild.ts +++ /dev/null @@ -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", - } - } - }, -} diff --git a/packages/docs/src/asset-builder/transform-markdown.ts b/packages/docs/src/asset-builder/transform-markdown.ts deleted file mode 100644 index 1877fbd..0000000 --- a/packages/docs/src/asset-builder/transform-markdown.ts +++ /dev/null @@ -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", - } - }, -} diff --git a/packages/docs/src/asset-builder/transform-postcss.ts b/packages/docs/src/asset-builder/transform-postcss.ts deleted file mode 100644 index 26277bd..0000000 --- a/packages/docs/src/asset-builder/transform-postcss.ts +++ /dev/null @@ -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", - } - }, -} diff --git a/packages/docs/src/guides/guide-page.tsx b/packages/docs/src/guides/guide-page.tsx index 482e066..ad06c39 100644 --- a/packages/docs/src/guides/guide-page.tsx +++ b/packages/docs/src/guides/guide-page.tsx @@ -1,6 +1,8 @@ import clsx from "clsx" import React from "react" 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 { guideLinks } from "../navigation/guide-links" import { MainNavigation } from "../navigation/main-navigation" @@ -8,32 +10,47 @@ import { docsProseClass, linkClass, maxWidthContainer } from "../ui/components" export function GuidePage({ url }: { url: string }) { return ( - <> -
-
- -
-
-
- - - {(asset) => ( -
- )} - -
- + + {(asset) => ( + +
+ + + )} + + ) +} + +function Header() { + return ( +
+
+ +
+
+ ) +} + +function Body({ content }: { content: { __html: string } }) { + return ( +
+ +
+
) } diff --git a/packages/docs/src/html.tsx b/packages/docs/src/html.tsx index e81fceb..45a488b 100644 --- a/packages/docs/src/html.tsx +++ b/packages/docs/src/html.tsx @@ -2,16 +2,19 @@ import packageJson from "reacord/package.json" import type { ReactNode } from "react" import React from "react" 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({ - title = "Reacord", + title: titleProp, description = packageJson.description, children, }: { - title?: string + title: string description?: string children: ReactNode }) { + const title = [titleProp, "Reacord"].filter(Boolean).join(" | ") return ( @@ -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" /> - + {(asset) => } - + {(asset) => } {title} - + {(asset) =>