generated from deco-sites/start
-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Incremental TailwindCSS generation (#231)
- Loading branch information
Showing
5 changed files
with
193 additions
and
132 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,15 @@ | ||
import autoprefixer from "npm:[email protected]"; | ||
import cssnano from "npm:[email protected]"; | ||
import postcss from "npm:[email protected]"; | ||
import tailwindcss from "npm:[email protected]"; | ||
import postcss, { type AcceptedPlugin } from "npm:[email protected]"; | ||
import tailwindcss, { type Config } from "npm:[email protected]"; | ||
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:[email protected]"; | ||
|
||
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<Config> => | ||
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Response>) => 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<string, string>(); | ||
|
||
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); | ||
}, | ||
], | ||
}; | ||
}; |
Oops, something went wrong.