From e2cb1c0d503aac3d9bcb83844dbf4f0b04a22d2b Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Wed, 17 Jan 2024 16:02:20 -0300 Subject: [PATCH] feat: Incremental TailwindCSS generation (#231) --- live.gen.ts | 2 - plugins/mod.ts | 12 +- plugins/tailwind/bundler.ts | 66 +++++------ plugins/tailwind/mod.ts | 216 +++++++++++++++++++++++++----------- routes/styles.css.ts | 29 ----- 5 files changed, 193 insertions(+), 132 deletions(-) delete mode 100644 routes/styles.css.ts diff --git a/live.gen.ts b/live.gen.ts index b3951cee..f0fd2a9b 100644 --- a/live.gen.ts +++ b/live.gen.ts @@ -68,7 +68,6 @@ import * as $$$28 from "./loaders/x/redirectsFromCsv.ts"; import * as $$$29 from "./loaders/x/redirects.ts"; import * as $$$30 from "./loaders/x/font.ts"; import * as $$$$0 from "./routes/404.tsx"; -import * as $$$$1 from "./routes/styles.css.ts"; import * as $$$$2 from "./routes/_app.tsx"; import * as $$$$$$0 from "./handlers/vtex/sitemap.ts"; import * as $$$$$$1 from "./handlers/sitemap.ts"; @@ -183,7 +182,6 @@ const manifest = { "routes": { "./routes/_app.tsx": $$$$2, "./routes/404.tsx": $$$$0, - "./routes/styles.css.ts": $$$$1, }, "handlers": { "deco-sites/std/handlers/sitemap.ts": $$$$$$1, diff --git a/plugins/mod.ts b/plugins/mod.ts index 75dee74c..fbd60c2a 100644 --- a/plugins/mod.ts +++ b/plugins/mod.ts @@ -2,9 +2,9 @@ import type { Plugin } from "$fresh/server.ts"; import { AppManifest } from "deco/mod.ts"; import decoPlugin, { Options } from "deco/plugins/deco.ts"; import * as colors from "std/fmt/colors.ts"; -import { plugin as tailwindPlugin } from "./tailwind/mod.ts"; +import { type Config, plugin as tailwindPlugin } from "./tailwind/mod.ts"; -export const plugin = (): Plugin => { +export const plugin = ({ tailwind }: { tailwind: Config }): Plugin => { console.warn( colors.brightYellow( `deco-sites/std plugin has been deprecated, use default export instead. You must change your dev.ts and main.ts, check out the following examples\ndev.ts: ${ @@ -19,15 +19,13 @@ export const plugin = (): Plugin => { ), ); return ({ - ...tailwindPlugin, + ...tailwindPlugin(tailwind), name: "deco-sites/std", }); }; const plugins = ( - opts?: Options, -): Plugin[] => { - return [tailwindPlugin, decoPlugin(opts!)]; -}; + { tailwind, ...opts }: Options & { tailwind?: Config }, +): Plugin[] => [tailwindPlugin(tailwind), decoPlugin(opts)]; export default plugins; diff --git a/plugins/tailwind/bundler.ts b/plugins/tailwind/bundler.ts index b186a8f3..fbf9ed9a 100644 --- a/plugins/tailwind/bundler.ts +++ b/plugins/tailwind/bundler.ts @@ -1,12 +1,15 @@ import autoprefixer from "npm:autoprefixer@10.4.14"; import cssnano from "npm:cssnano@6.0.1"; -import postcss from "npm:postcss@8.4.27"; -import tailwindcss from "npm:tailwindcss@3.4.1"; +import postcss, { type AcceptedPlugin } from "npm:postcss@8.4.27"; +import tailwindcss, { type Config } from "npm:tailwindcss@3.4.1"; import { cyan } from "std/fmt/colors.ts"; -import { ensureFile } from "std/fs/mod.ts"; -import { join, toFileUrl } from "std/path/mod.ts"; +import { walk } from "std/fs/walk.ts"; +import { globToRegExp, normalizeGlob } from "std/path/glob.ts"; +import { extname, join, toFileUrl } from "std/path/mod.ts"; -const DEFAULT_OPTIONS = { +export { type Config } from "npm:tailwindcss@3.4.1"; + +const DEFAULT_CONFIG: Config = { content: ["./**/*.tsx"], theme: {}, }; @@ -17,43 +20,44 @@ const DEFAULT_TAILWIND_CSS = ` @tailwind utilities; `; +// Try to recover config from default file, a.k.a tailwind.config.ts +export const loadTailwindConfig = (root: string): Promise => + import(toFileUrl(join(root, "tailwind.config.ts")).href) + .then((mod) => mod.default) + .catch(() => DEFAULT_CONFIG); + export const bundle = async ( - { to, from, release }: { to: string; from: string; release: string }, + { from, mode, config }: { + from: string; + mode: "dev" | "prod"; + config: Config; + }, ) => { const start = performance.now(); - // Try to recover config from default file, a.k.a tailwind.config.ts - const config = await import( - toFileUrl(join(Deno.cwd(), "tailwind.config.ts")).href - ) - .then((mod) => mod.default) - .catch(() => DEFAULT_OPTIONS); - - if (Array.isArray(config.content)) { - config.content.push({ - raw: release, - extension: "json", - }); - } else { - console.warn("TailwindCSS generation from decofile disabled"); - } - - const processor = postcss([ - // deno-lint-ignore no-explicit-any - (tailwindcss as any)(config), + const plugins: AcceptedPlugin[] = [ + tailwindcss(config), autoprefixer(), - cssnano({ preset: ["default", { cssDeclarationSorter: false }] }), - ]); + ]; - const css = await Deno.readTextFile(from).catch((_) => DEFAULT_TAILWIND_CSS); - const content = await processor.process(css, { from, to }); + if (mode === "prod") { + plugins.push( + cssnano({ preset: ["default", { cssDeclarationSorter: false }] }), + ); + } - await ensureFile(to); - await Deno.writeTextFile(to, content.css, { create: true }); + const processor = postcss(plugins); + + const content = await processor.process( + await Deno.readTextFile(from).catch((_) => DEFAULT_TAILWIND_CSS), + { from: undefined }, + ); console.info( ` 🎨 Tailwind css ready in ${ cyan(`${((performance.now() - start) / 1e3).toFixed(1)}s`) }`, ); + + return content.css; }; diff --git a/plugins/tailwind/mod.ts b/plugins/tailwind/mod.ts index 4d5df6bf..cac0e97f 100644 --- a/plugins/tailwind/mod.ts +++ b/plugins/tailwind/mod.ts @@ -1,78 +1,168 @@ -import type { Handlers, Plugin } from "$fresh/server.ts"; -import { Context, context } from "deco/deco.ts"; -import { createWorker } from "../../utils/worker.ts"; +import type { Plugin } from "$fresh/server.ts"; +import { Context } from "deco/deco.ts"; +import { join } from "std/path/mod.ts"; +import { bundle, Config, loadTailwindConfig } from "./bundler.ts"; -export const TO = "./static/tailwind.css"; -export const FROM = "./tailwind.css"; +export type { Config } from "./bundler.ts"; -let current: string | undefined = ""; +const root: string = Deno.cwd(); -const generate = async () => { - const active = Context.active(); - const revision = await active.release?.revision(); +const FROM = "./tailwind.css"; +const TO = join("static", FROM); - if (revision === current) { - return; - } +const safe = (cb: () => Promise) => async () => { + try { + return await cb(); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response(null, { status: 404 }); + } - /** - * Here be capybaras! 🐁🐁🐁 - * - * Tailwind uses a dependency called picocolors. Somehow, this line breaks when running on deno - * https://github.com/alexeyraspopov/picocolors/blob/6b43e8e83bcfe69ad1391a2bb07239bf11a13bc4/picocolors.js#L4 - * - * Setting this envvar makes this line not to be run, and thus, solves the issue. - * - * TODO: Remove this env var once this issue is fixed - */ - Deno.env.set("NO_COLOR", "true"); - - const worker = await createWorker(new URL("./bundler.ts", import.meta.url), { - type: "module", - }); - - await worker.bundle({ - to: TO, - from: FROM, - release: JSON.stringify(await active.release?.state()), - }); - - worker.dispose(); - current = revision; + return new Response(Deno.inspect(error, { colors: false, depth: 100 }), { + status: 500, + }); + } }; -const bundle = context.isDeploy ? () => Promise.resolve() : generate; - -export const handler: Handlers = { - GET: async () => { - await bundle(); +// Magical LRU implementation using JavaScript internals +const LRU = (size: number) => { + const cache = new Map(); - try { - const [stats, file] = await Promise.all([Deno.lstat(TO), Deno.open(TO)]); + return { + get: (key: string): string | undefined => { + const value = cache.get(key); - return new Response(file.readable, { - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - "Content-Type": "text/css; charset=utf-8", - "Content-Length": `${stats.size}`, - }, - }); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - return new Response(null, { status: 404 }); + // Update LRU index + if (value) { + cache.set(key, value); } - return new Response(null, { status: 500 }); - } - }, + return value; + }, + set: (key: string, value: string) => { + // Housekeep index + if (cache.size >= size && !cache.has(key)) { + cache.delete(cache.keys().next().value); + } + cache.set(key, value); + }, + }; }; -export const plugin: Plugin = { - name: "tailwind", - routes: [ - { - path: "/styles.css", - handler, +const lru = LRU(10); + +const migration = ` +🚀 Upgrade to the Latest TailwindCSS Plugin Version + +You're currently using the compatibility mode of the TailwindCSS plugin, follow these steps to seamlessly migrate to the new version: + +1. Open your "fresh.config.ts" file and replace its content with the following: +// fresh.config.ts +import { defineConfig } from "$fresh/server.ts"; +import plugins from "../std/plugins/mod.ts"; +import manifest from "./manifest.gen.ts"; +import tailwind from "./tailwind.config.ts"; + +export default defineConfig({ + plugins: plugins({ + manifest, + // deno-lint-ignore no-explicit-any + tailwind: tailwind as any, + }), +}); + + +2. Remove the existing 'tailwind.css' file from the 'static' directory using the command: +rm static/tailwind.css + +👏 That's it! You've successfully migrated to the new version. Thank you for keeping your project up-to-date! +`; + +/** + * Since Deno Deploy does not allow dynamic import, importing the config file + * automatically is not yet possible. + * + * Pass the config directly to use the new dynamic features. Pass undefined + * if you wish to have the old behavior + */ +export const plugin = (config?: Config): Plugin => { + const routes: Plugin["routes"] = []; + + if (!config) { + console.warn(migration); + } + + return { + name: "tailwind", + routes, + configResolved: async (fresh) => { + const mode = fresh.dev ? "dev" : "prod"; + const ctx = Context.active(); + + const withReleaseContent = async (config: Config) => { + const state = await ctx.release?.state({ forceFresh: true }); + + return { + ...config, + content: Array.isArray(config.content) + ? [...config.content, { + raw: JSON.stringify(state), + extension: "json", + }] + : config.content, + }; + }; + + const css = + // We have built on CI + (await Deno.readTextFile(TO).catch(() => null)) || + // We are on localhost + (await bundle({ + from: FROM, + mode, + config: config + ? await withReleaseContent(config) + : await loadTailwindConfig(root), + }).catch(() => "")); + + // Set the default revision CSS so we don't have to rebuild what CI has built + lru.set(await ctx.release?.revision() || "", css); + + routes.push({ + path: "/styles.css", + handler: safe(async () => { + const revision = await ctx.release?.revision() || ""; + + let css = lru.get(revision); + + // Generate styles dynamically + if (!css && config) { + css = await bundle({ + from: FROM, + mode, + config: await withReleaseContent(config), + }); + + lru.set(revision, css); + } + + return new Response(css, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": "text/css; charset=utf-8", + }, + }); + }), + }); + }, + // Compatibility mode. Only runs when config is not set directly + buildStart: async () => { + const css = await bundle({ + from: FROM, + mode: "prod", + config: config || await loadTailwindConfig(root), + }); + await Deno.writeTextFile(TO, css); }, - ], + }; }; diff --git a/routes/styles.css.ts b/routes/styles.css.ts deleted file mode 100644 index ae851887..00000000 --- a/routes/styles.css.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Handlers } from "$fresh/server.ts"; -import { yellow } from "std/fmt/colors.ts"; -import { handler as tailwindHandler } from "../plugins/tailwind/mod.ts"; - -const msg = ` -${yellow("WARNING!")} -Tailwind setup has changed and we now offer it via a Fresh Plugin! This file will be removed in future releases. To migrate - -1. Remove routes/styles.css.ts from your routes folder -2. Change main.ts to: - - import tailwindPlugin from "deco-sites/std/plugins/tailwind/mod.ts"; - - await start($live(manifest, site), { - plugins: [ - tailwindPlugin, - ], - }); - -That's it! Thanks for migrating 🎉 -`; - -export const handler: Handlers = { - GET: (req, ctx) => { - console.warn(msg); - - return tailwindHandler.GET!(req, ctx); - }, -};