diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 971c0ed5612..fa0458f85b0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "denoland.vscode-deno", - "sastan.twind-intellisense" + "sastan.twind-intellisense", + "antfu.unocss" ] } diff --git a/docs/latest/examples/using-twind-v1.md b/docs/latest/examples/using-twind-v1.md index 0577bead8f6..9bbf8015cbd 100644 --- a/docs/latest/examples/using-twind-v1.md +++ b/docs/latest/examples/using-twind-v1.md @@ -58,7 +58,7 @@ export default { }; ``` -(Note: the `as Preset` cast is required to fix a typing issue with twind.) +Note: the `as Preset` cast is required to fix a typing issue with twind. To see what other presets exist, you can go to the [twind docs](https://twind.style/presets). diff --git a/docs/latest/examples/using-unocss.md b/docs/latest/examples/using-unocss.md new file mode 100644 index 00000000000..29dc1267168 --- /dev/null +++ b/docs/latest/examples/using-unocss.md @@ -0,0 +1,44 @@ +--- +description: | + One can use UnoCSS, an instant on-demand atomic CSS engine +--- + +The template generates a Twind v0 project by default. If you want to use UnoCSS +you can update the `main.ts` as follows: + +```ts +/// +/// +/// +/// +/// + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; + +import unocssPlugin from "$fresh/plugins/unocss.ts"; +import unocssConfig from "./uno.config.ts"; + +await start(manifest, { plugins: [unocssPlugin(unocssConfig)] }); +``` + +The unocss config object at `uno.config.ts` can be customized to your liking. +Refer to the [unocss docs](https://unocss.dev/guide/config-file) for more +information. If no config is provided, the default config is used, which +defaults to the following: + +```ts +import { defineConfig } from "$fresh/plugins/unocss.ts"; +import presetUno from "https://esm.sh/@unocss/preset-uno@0.55.1"; + +export default defineConfig({ + presets: [presetUno()], + selfURL: import.meta.url, +}); +``` + +Note: you could also inline the config object in `main.ts` instead of using a +separate `uno.config.ts` file. + +To see what other presets exist, you can go to the +[unocss docs](https://unocss.dev/presets/). diff --git a/docs/toc.ts b/docs/toc.ts index 65090aa5055..54dc313d5c3 100644 --- a/docs/toc.ts +++ b/docs/toc.ts @@ -79,6 +79,7 @@ const toc: RawTableOfContents = { "link:latest", ], ["using-twind-v1", "Using Twind v1", "link:latest"], + ["using-unocss", "Using UnoCSS", "link:latest"], ["init-the-server", "Initializing the server", "link:latest"], [ "using-fresh-canary-version", @@ -152,6 +153,7 @@ const toc: RawTableOfContents = { ["writing-tests", "Writing tests"], ["changing-the-src-dir", "Changing the source directory"], ["using-twind-v1", "Using Twind v1"], + ["using-unocss", "Using UnoCSS"], ["init-the-server", "Initializing the server"], ["using-fresh-canary-version", "Using Fresh canary version"], ["dealing-with-cors", "Dealing with CORS"], diff --git a/plugins/unocss.ts b/plugins/unocss.ts new file mode 100644 index 00000000000..46a3c88e899 --- /dev/null +++ b/plugins/unocss.ts @@ -0,0 +1,122 @@ +import { JSX, options as preactOptions, VNode } from "preact"; + +import { + UnoGenerator, + type UserConfig, +} from "https://esm.sh/@unocss/core@0.55.1"; +import type { Theme } from "https://esm.sh/@unocss/preset-uno@0.55.1"; + +import { Plugin } from "$fresh/server.ts"; +import { exists } from "$fresh/src/server/deps.ts"; + +type PreactOptions = typeof preactOptions & { __b?: (vnode: VNode) => void }; + +// inline reset from https://esm.sh/@unocss/reset@0.54.2/tailwind.css +const unoResetCSS = `/* reset */ +*,:before,:after{box-sizing:border-box;border:0 solid}html{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto} +`; + +type UnoCssPluginOptions = { + runtime?: boolean; + config?: UserConfig; +}; + +/** + * Helper function for typing of config objects + */ +export function defineConfig(config: UserConfig) { + return config; +} + +/** + * Installs a hook in Preact to extract classes during server-side renders + * @param classes - Set of class strings, which will be mutated by this function. + */ +export function installPreactHook(classes: Set) { + // Hook into options._b which is called whenever a new comparison + // starts in Preact. + const originalHook = (preactOptions as PreactOptions).__b; + (preactOptions as PreactOptions).__b = ( + // deno-lint-ignore no-explicit-any + vnode: VNode>, + ) => { + if (typeof vnode.type === "string" && typeof vnode.props === "object") { + const { props } = vnode; + if (props.class) { + props.class.split(" ").forEach((cls) => classes.add(cls)); + } + if (props.className) { + props.className.split(" ").forEach((cls) => classes.add(cls)); + } + } + + originalHook?.(vnode); + }; +} + +/** + * UnoCSS plugin - automatically generates CSS utility classes + * + * @param [opts] Plugin options + * @param [opts.runtime] By default the UnoCSS runtime will run in the browser. Set to `false` to disable this. + * @param [opts.config] Explicit UnoCSS config object. By default `uno.config.ts` file. Not supported with the browser runtime. + */ +export default function unocss( + opts: UnoCssPluginOptions = {}, +): Plugin { + // Include the browser runtime by default + const runtime = opts.runtime ?? true; + + // A uno.config.ts file is required in the project directory if a config object is not provided, + // or to use the browser runtime + const configURL = new URL("./uno.config.ts", Deno.mainModule); + + // Create a set that will be used to hold class names encountered during SSR + const classes = new Set(); + + // Hook into Preact to add to the set of classes on each server-side render + installPreactHook(classes); + + let uno: UnoGenerator; + if (opts.config !== undefined) { + uno = new UnoGenerator(opts.config); + } else { + import(configURL.toString()).then((mod) => { + uno = new UnoGenerator(mod.default); + }).catch((error) => { + exists(configURL, { isFile: true, isReadable: true }).then( + (configFileExists) => { + throw configFileExists ? error : new Error( + "uno.config.ts not found in the project directory! Please create it or pass a config object to the UnoCSS plugin", + ); + }, + ); + }); + } + + return { + name: "unocss", + entrypoints: runtime + ? { + "main": ` + data:application/javascript, + import config from "${configURL}"; + import init from "https://esm.sh/@unocss/runtime@0.55.1"; + export default function() { + window.__unocss = config; + init(); + }`, + } + : {}, + async renderAsync(ctx) { + classes.clear(); + await ctx.renderAsync(); + const { css } = await uno.generate(classes); + + return { + scripts: runtime ? [{ entrypoint: "main", state: {} }] : [], + styles: [{ cssText: `${unoResetCSS}\n${css}` }], + }; + }, + }; +} diff --git a/src/server/deps.ts b/src/server/deps.ts index 23e66404377..41a9a0a99f8 100644 --- a/src/server/deps.ts +++ b/src/server/deps.ts @@ -7,6 +7,7 @@ export { toFileUrl, } from "https://deno.land/std@0.193.0/path/mod.ts"; export { walk } from "https://deno.land/std@0.193.0/fs/walk.ts"; +export { exists } from "https://deno.land/std@0.193.0/fs/exists.ts"; export * as colors from "https://deno.land/std@0.193.0/fmt/colors.ts"; export { type Handler as ServeHandler, diff --git a/tests/fixture_twind_hydrate/islands/InsertCssrules.tsx b/tests/fixture_twind_hydrate/islands/InsertCssrules.tsx index beea977b0c5..845969adbe9 100644 --- a/tests/fixture_twind_hydrate/islands/InsertCssrules.tsx +++ b/tests/fixture_twind_hydrate/islands/InsertCssrules.tsx @@ -69,7 +69,7 @@ export default function InsertCssrules() { }} disabled={insertedStyles.value === "" ? false : true} > - Add `text-green-600` to Cureent Number Class + Add `text-green-600` to Current Number Class ); diff --git a/tests/fixture_unocss_hydrate/deno.json b/tests/fixture_unocss_hydrate/deno.json new file mode 100644 index 00000000000..226bf74767b --- /dev/null +++ b/tests/fixture_unocss_hydrate/deno.json @@ -0,0 +1,18 @@ +{ + "lock": false, + "tasks": { + "start": "deno run -A --watch=static/,routes/ dev.ts" + }, + "imports": { + "$fresh/": "../../", + "preact": "https://esm.sh/preact@10.11.0", + "preact/": "https://esm.sh/preact@10.11.0/", + "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.0", + "@preact/signals": "https://esm.sh/*@preact/signals@1.0.3", + "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.0.1" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + } +} diff --git a/tests/fixture_unocss_hydrate/dev.ts b/tests/fixture_unocss_hydrate/dev.ts new file mode 100755 index 00000000000..2d85d6c183c --- /dev/null +++ b/tests/fixture_unocss_hydrate/dev.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env -S deno run -A --watch=static/,routes/ + +import dev from "$fresh/dev.ts"; + +await dev(import.meta.url, "./main.ts"); diff --git a/tests/fixture_unocss_hydrate/fresh.gen.ts b/tests/fixture_unocss_hydrate/fresh.gen.ts new file mode 100644 index 00000000000..041757d712d --- /dev/null +++ b/tests/fixture_unocss_hydrate/fresh.gen.ts @@ -0,0 +1,26 @@ +// DO NOT EDIT. This file is generated by Fresh. +// This file SHOULD be checked into source version control. +// This file is automatically updated during development when running `dev.ts`. + +import * as $0 from "./routes/check-duplication.tsx"; +import * as $1 from "./routes/insert-cssrules.tsx"; +import * as $2 from "./routes/static.tsx"; +import * as $3 from "./routes/unused.tsx"; +import * as $$0 from "./islands/CheckDuplication.tsx"; +import * as $$1 from "./islands/InsertCssrules.tsx"; + +const manifest = { + routes: { + "./routes/check-duplication.tsx": $0, + "./routes/insert-cssrules.tsx": $1, + "./routes/static.tsx": $2, + "./routes/unused.tsx": $3, + }, + islands: { + "./islands/CheckDuplication.tsx": $$0, + "./islands/InsertCssrules.tsx": $$1, + }, + baseUrl: import.meta.url, +}; + +export default manifest; diff --git a/tests/fixture_unocss_hydrate/islands/CheckDuplication.tsx b/tests/fixture_unocss_hydrate/islands/CheckDuplication.tsx new file mode 100644 index 00000000000..419e56a5813 --- /dev/null +++ b/tests/fixture_unocss_hydrate/islands/CheckDuplication.tsx @@ -0,0 +1,59 @@ +// https://github.com/denoland/fresh/pull/1050 +import { useEffect } from "preact/hooks"; +import { cmpCssRules } from "../utils/utils.ts"; +import { useSignal } from "@preact/signals"; + +/** + * Returns a cssrulelist of styleElement matching the selector. + */ +function getCssrules(selector: string) { + const elem = document.querySelector(selector) as HTMLStyleElement; + return elem?.sheet?.cssRules; +} + +export default function CheckDuplication() { + const cssRulesFRSHUNOCSS = useSignal(undefined); + const cssRulesClaimed = useSignal(undefined); + + // Init + useEffect(() => { + // get