Skip to content

Commit

Permalink
feat: Incremental TailwindCSS generation (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlgimenes authored Jan 17, 2024
1 parent cf7307a commit e2cb1c0
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 132 deletions.
2 changes: 0 additions & 2 deletions live.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 5 additions & 7 deletions plugins/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ${
Expand All @@ -19,15 +19,13 @@ export const plugin = (): Plugin => {
),
);
return ({
...tailwindPlugin,
...tailwindPlugin(tailwind),
name: "deco-sites/std",
});
};

const plugins = <TManifest extends AppManifest = AppManifest>(
opts?: Options<TManifest>,
): Plugin[] => {
return [tailwindPlugin, decoPlugin(opts!)];
};
{ tailwind, ...opts }: Options<TManifest> & { tailwind?: Config },
): Plugin[] => [tailwindPlugin(tailwind), decoPlugin(opts)];

export default plugins;
66 changes: 35 additions & 31 deletions plugins/tailwind/bundler.ts
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: {},
};
Expand All @@ -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;
};
216 changes: 153 additions & 63 deletions plugins/tailwind/mod.ts
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);
},
],
};
};
Loading

0 comments on commit e2cb1c0

Please sign in to comment.