diff --git a/.vscode/import_map.json b/.vscode/import_map.json index dae2d6771a7..9a0bc7a0e0b 100644 --- a/.vscode/import_map.json +++ b/.vscode/import_map.json @@ -3,6 +3,7 @@ "THIS FILE EXISTS ONLY FOR VSCODE! IT IS NOT USED AT RUNTIME": {} }, "imports": { + "@vscode_787_hack/": "../tests/fixture_tailwind_remote_classes/", "$fresh/": "../", "twind": "https://esm.sh/twind@0.16.19", "twind/": "https://esm.sh/twind@0.16.19/", diff --git a/plugins/tailwind/compiler.ts b/plugins/tailwind/compiler.ts index f1c08e1dd1d..b2318491fb5 100644 --- a/plugins/tailwind/compiler.ts +++ b/plugins/tailwind/compiler.ts @@ -5,6 +5,12 @@ import cssnano from "npm:cssnano@6.0.3"; import autoprefixer from "npm:autoprefixer@10.4.17"; import * as path from "https://deno.land/std@0.216.0/path/mod.ts"; import { TailwindPluginOptions } from "./types.ts"; +import { + createGraph, + type ModuleGraphJson, +} from "https://deno.land/x/deno_graph@0.63.5/mod.ts"; +import { parseFromJson } from "https://deno.land/x/import_map@v0.18.3/mod.ts"; +import { parse as jsoncParse } from "https://deno.land/std@0.213.0/jsonc/mod.ts"; const CONFIG_EXTENSIONS = ["ts", "js", "mjs"]; @@ -62,6 +68,39 @@ export async function initTailwind( return pattern; }); + let importMap; + if (path.extname(config.denoJsonPath) === ".json") { + importMap = (await import(path.toFileUrl(config.denoJsonPath).href, { + with: { type: "json" }, + })).default; + } else if (path.extname(config.denoJsonPath) === ".jsonc") { + const fileContents = Deno.readTextFileSync(config.denoJsonPath); + importMap = jsoncParse(fileContents); + } else { + throw Error("deno config must be either .json or .jsonc"); + } + for (const plugin of config.plugins ?? []) { + if (plugin.location === undefined) continue; + // if the plugin is declared in a separate place than the project, the plugin developer should have specified a projectLocation + // otherwise, we assume the plugin is in the same directory as the project + const projectLocation = plugin.projectLocation ?? + path.dirname(plugin.location); + const resolvedImportMap = await parseFromJson( + path.toFileUrl(config.denoJsonPath), + importMap, + ); + + const moduleGraph = await createGraph(plugin.location, { + resolve: resolvedImportMap.resolve.bind(resolvedImportMap), + }); + + for (const file of extractSpecifiers(moduleGraph, projectLocation)) { + const response = await fetch(file); + const content = await response.text(); + tailwindConfig.content.push({ raw: content }); + } + } + // PostCSS types cause deep recursion const plugins = [ // deno-lint-ignore no-explicit-any @@ -76,3 +115,13 @@ export async function initTailwind( return postcss(plugins); } + +function extractSpecifiers(graph: ModuleGraphJson, projectLocation: string) { + return graph.modules + .filter((module) => + (module.specifier.endsWith(".tsx") || + module.specifier.endsWith(".jsx")) && + module.specifier.startsWith(projectLocation) + ) + .map((module) => module.specifier); +} diff --git a/src/server/config.ts b/src/server/config.ts index c45513d66a0..91d6da24d0f 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -94,6 +94,7 @@ export async function getInternalFreshState( router: config.router, server: config.server ?? {}, basePath, + denoJsonPath, }; if (config.cert) { diff --git a/src/server/types.ts b/src/server/types.ts index 492eaf87299..227ae8c993f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -119,6 +119,7 @@ export interface ResolvedFreshConfig { router?: RouterOptions; server: Partial; basePath: string; + denoJsonPath: string; } export interface RouterOptions { @@ -495,6 +496,19 @@ export interface Plugin> { middlewares?: PluginMiddleware[]; islands?: PluginIslands; + + /** + * This should always be set to `import.meta.url`. + * Required if you want tailwind to scan your routes for classes. + */ + location?: string; + + /** + * If the plugin is declared in a separate place than the project root, specify the root here. + * This is necessary if your plugin is declared in a `src/` folder. + * Required if you want tailwind to scan your routes for classes, and your plugin is not in the root. + */ + projectLocation?: string; } export interface PluginRenderContext { diff --git a/tests/fixture_tailwind_remote_classes/basicPlugin.ts b/tests/fixture_tailwind_remote_classes/basicPlugin.ts new file mode 100644 index 00000000000..416b264f64f --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/basicPlugin.ts @@ -0,0 +1,13 @@ +import type { Plugin } from "../../src/server/types.ts"; +import PluginComponent from "./components/PluginComponent.tsx"; + +export const basicPlugin = { + name: "basic plugin", + routes: [ + { + path: "routeFromPlugin", + component: PluginComponent, + }, + ], + location: import.meta.url, +} satisfies Plugin; diff --git a/tests/fixture_tailwind_remote_classes/components/AtPluginComponent.tsx b/tests/fixture_tailwind_remote_classes/components/AtPluginComponent.tsx new file mode 100644 index 00000000000..e954b6774b2 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/components/AtPluginComponent.tsx @@ -0,0 +1,3 @@ +export default function AtPluginComponent() { + return
AtPluginComponent
; +} diff --git a/tests/fixture_tailwind_remote_classes/components/JsxPluginComponent.jsx b/tests/fixture_tailwind_remote_classes/components/JsxPluginComponent.jsx new file mode 100644 index 00000000000..0152746f146 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/components/JsxPluginComponent.jsx @@ -0,0 +1,3 @@ +export default function JsxPluginComponent() { + return
JsxPluginComponent
; +} diff --git a/tests/fixture_tailwind_remote_classes/components/NestedPluginComponent.tsx b/tests/fixture_tailwind_remote_classes/components/NestedPluginComponent.tsx new file mode 100644 index 00000000000..2d22b070432 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/components/NestedPluginComponent.tsx @@ -0,0 +1,3 @@ +export default function NestedPluginComponent() { + return
NestedPluginComponent
; +} diff --git a/tests/fixture_tailwind_remote_classes/components/PluginComponent.tsx b/tests/fixture_tailwind_remote_classes/components/PluginComponent.tsx new file mode 100644 index 00000000000..a6767f79933 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/components/PluginComponent.tsx @@ -0,0 +1,3 @@ +export default function PluginComponent() { + return
PluginComponent
; +} diff --git a/tests/fixture_tailwind_remote_classes/components/VeryNestedPluginComponent.tsx b/tests/fixture_tailwind_remote_classes/components/VeryNestedPluginComponent.tsx new file mode 100644 index 00000000000..f3407e830ba --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/components/VeryNestedPluginComponent.tsx @@ -0,0 +1,3 @@ +export default function VeryNestedPluginComponent() { + return
VeryNestedPluginComponent
; +} diff --git a/tests/fixture_tailwind_remote_classes/deno.json b/tests/fixture_tailwind_remote_classes/deno.json new file mode 100644 index 00000000000..893c73e74f7 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/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.15.1", + "preact/": "https://esm.sh/preact@10.15.1/", + "tailwindcss": "npm:tailwindcss@3.3.5", + "$std/": "https://deno.land/std@0.216.0/", + "@vscode_787_hack/": "./" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + } +} diff --git a/tests/fixture_tailwind_remote_classes/dev.ts b/tests/fixture_tailwind_remote_classes/dev.ts new file mode 100755 index 00000000000..1fe3e340282 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/dev.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env -S deno run -A --watch=static/,routes/ + +import dev from "$fresh/dev.ts"; +import config from "./fresh.config.ts"; + +await dev(import.meta.url, "./main.ts", config); diff --git a/tests/fixture_tailwind_remote_classes/fresh.config.ts b/tests/fixture_tailwind_remote_classes/fresh.config.ts new file mode 100644 index 00000000000..53f9f1da0ff --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/fresh.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, Plugin } from "$fresh/server.ts"; +import tailwind from "$fresh/plugins/tailwind.ts"; +import { basicPlugin } from "./basicPlugin.ts"; +import { nestedPlugin } from "./nestedPlugins/nestedPlugin.ts"; +import { hackyRemotePlugin } from "https://raw.githubusercontent.com/denoland/fresh/727d0e729e154323b99b319c04f9805f382949f0/tests/fixture_tailwind_remote_classes/hackyRemotePlugin.tsx"; +import { veryNestedPlugin } from "./nestedPlugins/nested/nested/nested/veryNestedPlugin.ts"; +import { jsxPlugin } from "./jsxPlugin.ts"; + +export default defineConfig({ + plugins: [ + tailwind(), + basicPlugin, + nestedPlugin, + hackyRemotePlugin as Plugin, + veryNestedPlugin, + jsxPlugin, + ], +}); diff --git a/tests/fixture_tailwind_remote_classes/fresh.gen.ts b/tests/fixture_tailwind_remote_classes/fresh.gen.ts new file mode 100644 index 00000000000..2e895a65907 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/fresh.gen.ts @@ -0,0 +1,21 @@ +// 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 $_app from "./routes/_app.tsx"; +import * as $_middleware from "./routes/_middleware.ts"; +import * as $index from "./routes/index.tsx"; + +import { type Manifest } from "$fresh/server.ts"; + +const manifest = { + routes: { + "./routes/_app.tsx": $_app, + "./routes/_middleware.ts": $_middleware, + "./routes/index.tsx": $index, + }, + islands: {}, + baseUrl: import.meta.url, +} satisfies Manifest; + +export default manifest; diff --git a/tests/fixture_tailwind_remote_classes/jsxPlugin.ts b/tests/fixture_tailwind_remote_classes/jsxPlugin.ts new file mode 100644 index 00000000000..95fd2a02643 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/jsxPlugin.ts @@ -0,0 +1,13 @@ +import type { Plugin } from "../../src/server/types.ts"; +import JsxPluginComponent from "./components/JsxPluginComponent.jsx"; + +export const jsxPlugin = { + name: "jsx plugin", + routes: [ + { + path: "routeFromJsxPlugin", + component: JsxPluginComponent, + }, + ], + location: import.meta.url, +} satisfies Plugin; diff --git a/tests/fixture_tailwind_remote_classes/main.ts b/tests/fixture_tailwind_remote_classes/main.ts new file mode 100644 index 00000000000..fc9359215e3 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/main.ts @@ -0,0 +1,11 @@ +/// +/// +/// +/// +/// + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +import config from "./fresh.config.ts"; + +await start(manifest, config); diff --git a/tests/fixture_tailwind_remote_classes/nestedPlugins/nested/nested/nested/veryNestedPlugin.ts b/tests/fixture_tailwind_remote_classes/nestedPlugins/nested/nested/nested/veryNestedPlugin.ts new file mode 100644 index 00000000000..fdf10606bb7 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/nestedPlugins/nested/nested/nested/veryNestedPlugin.ts @@ -0,0 +1,13 @@ +import { normalize } from "$std/url/normalize.ts"; +import type { Plugin } from "../../../../../../src/server/types.ts"; +import VeryNestedPluginComponent from "../../../../components/VeryNestedPluginComponent.tsx"; + +export const veryNestedPlugin = { + name: "very nested plugin", + location: import.meta.url, + projectLocation: normalize(import.meta.url + "../../../../../../").href, + routes: [{ + path: "routeFromVeryNestedPlugin", + component: VeryNestedPluginComponent, + }], +} satisfies Plugin; diff --git a/tests/fixture_tailwind_remote_classes/nestedPlugins/nestedPlugin.ts b/tests/fixture_tailwind_remote_classes/nestedPlugins/nestedPlugin.ts new file mode 100644 index 00000000000..1c87312cbd6 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/nestedPlugins/nestedPlugin.ts @@ -0,0 +1,17 @@ +import { normalize } from "$std/url/normalize.ts"; +import type { Plugin } from "../../../src/server/types.ts"; +import NestedPluginComponent from "../components/NestedPluginComponent.tsx"; +import AtPluginComponent from "@vscode_787_hack/components/AtPluginComponent.tsx"; + +export const nestedPlugin = { + name: "nested plugin", + location: import.meta.url, + projectLocation: normalize(import.meta.url + "../../../").href, + routes: [{ + path: "routeFromNestedPlugin", + component: NestedPluginComponent, + }, { + path: "atRouteFromNestedPlugin", + component: AtPluginComponent, + }], +} satisfies Plugin; diff --git a/tests/fixture_tailwind_remote_classes/routes/_app.tsx b/tests/fixture_tailwind_remote_classes/routes/_app.tsx new file mode 100644 index 00000000000..2e225e497f4 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/routes/_app.tsx @@ -0,0 +1,17 @@ +import { PageProps } from "$fresh/server.ts"; + +export default function App({ Component }: PageProps) { + return ( + + + + + My Fresh app + + + + + + + ); +} diff --git a/tests/fixture_tailwind_remote_classes/routes/_middleware.ts b/tests/fixture_tailwind_remote_classes/routes/_middleware.ts new file mode 100644 index 00000000000..362a96be86b --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/routes/_middleware.ts @@ -0,0 +1,15 @@ +import { FreshContext } from "$fresh/server.ts"; + +export async function handler( + _req: Request, + ctx: FreshContext, +) { + if (ctx.url.pathname === "/middleware-only.css") { + return new Response(".foo-bar { color: red }", { + headers: { + "Content-Type": "text/css", + }, + }); + } + return await ctx.next(); +} diff --git a/tests/fixture_tailwind_remote_classes/routes/index.tsx b/tests/fixture_tailwind_remote_classes/routes/index.tsx new file mode 100644 index 00000000000..ee3f8f3c9bd --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/routes/index.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

foo

; +} diff --git a/tests/fixture_tailwind_remote_classes/static/styles.css b/tests/fixture_tailwind_remote_classes/static/styles.css new file mode 100644 index 00000000000..b5c61c95671 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/static/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/tests/fixture_tailwind_remote_classes/tailwind.config.ts b/tests/fixture_tailwind_remote_classes/tailwind.config.ts new file mode 100644 index 00000000000..431b1c75e81 --- /dev/null +++ b/tests/fixture_tailwind_remote_classes/tailwind.config.ts @@ -0,0 +1,7 @@ +import { Config } from "tailwindcss"; + +export default { + content: [ + "{routes,islands}/**/*.{ts,tsx}", // deliberately ignore components + ], +} satisfies Config; diff --git a/tests/main_test.ts b/tests/main_test.ts index 259b8669859..8d07a81029a 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -1142,6 +1142,7 @@ Deno.test("Expose config in ctx", async () => { "safari15", ], }, + denoJsonPath: join(Deno.cwd(), "tests", "fixture", "deno.json"), dev: false, plugins: [], render: "Function", @@ -1191,6 +1192,7 @@ Deno.test("Expose config in ctx", async () => { "safari15", ], }, + denoJsonPath: join(Deno.cwd(), "tests", "fixture", "deno.json"), dev: false, plugins: [], render: "Function", diff --git a/tests/server_components_test.ts b/tests/server_components_test.ts index a32cac6539a..9a8ead2fc75 100644 --- a/tests/server_components_test.ts +++ b/tests/server_components_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "./deps.ts"; +import { assertEquals, join } from "./deps.ts"; import { assertSelector, assertTextMany, @@ -93,6 +93,12 @@ Deno.test("passes context to server component", async () => { "safari15", ], }, + denoJsonPath: join( + Deno.cwd(), + "tests", + "fixture_server_components", + "deno.json", + ), dev: false, plugins: [ { entrypoints: {}, name: "twind", renderAsync: "AsyncFunction" }, diff --git a/tests/tailwind_test.ts b/tests/tailwind_test.ts index ad7b785102a..33b1012260e 100644 --- a/tests/tailwind_test.ts +++ b/tests/tailwind_test.ts @@ -122,3 +122,64 @@ Deno.test("TailwindCSS - missing snapshot on Deno Deploy", async () => { }, ); }); + +Deno.test("TailwindCSS remote classes - dev mode", async () => { + await withFakeServe( + "./tests/fixture_tailwind_remote_classes/dev.ts", + async (server) => { + const res = await server.get("/styles.css"); + const content = await res.text(); + assertStringIncludes(content, ".text-purple-500"); //PluginComponent + assertStringIncludes(content, ".text-amber-500"); //NestedPluginComponent + assertStringIncludes(content, ".text-slate-500"); //AtPluginComponent + assertStringIncludes(content, ".text-lime-500"); //HackyPluginComponent + assertStringIncludes(content, ".text-cyan-500"); //VeryNestedPluginComponent + assertStringIncludes(content, ".text-emerald-500"); //JsxComponent + }, + { loadConfig: true }, + ); +}); + +Deno.test("TailwindCSS remote classes - dev mode jsonc", async () => { + const originalPath = "./tests/fixture_tailwind_remote_classes/deno.json"; + const newPath = "./tests/fixture_tailwind_remote_classes/deno.jsonc"; + + await Deno.rename(originalPath, newPath); + + try { + await withFakeServe( + "./tests/fixture_tailwind_remote_classes/dev.ts", + async (server) => { + const res = await server.get("/styles.css"); + const content = await res.text(); + assertStringIncludes(content, ".text-purple-500"); // PluginComponent + assertStringIncludes(content, ".text-amber-500"); // NestedPluginComponent + assertStringIncludes(content, ".text-slate-500"); // AtPluginComponent + assertStringIncludes(content, ".text-lime-500"); // HackyPluginComponent + assertStringIncludes(content, ".text-cyan-500"); // VeryNestedPluginComponent + assertStringIncludes(content, ".text-emerald-500"); // JsxComponent + }, + { loadConfig: true }, + ); + } finally { + await Deno.rename(newPath, originalPath); + } +}); + +Deno.test("TailwindCSS remote classes - build mode", async () => { + await runBuild("./tests/fixture_tailwind_remote_classes/dev.ts"); + await withFakeServe( + "./tests/fixture_tailwind_remote_classes/main.ts", + async (server) => { + const res = await server.get("/styles.css"); + const content = await res.text(); + assertStringIncludes(content, ".text-purple-500"); //PluginComponent + assertStringIncludes(content, ".text-amber-500"); //NestedPluginComponent + assertStringIncludes(content, ".text-slate-500"); //AtPluginComponent + assertStringIncludes(content, ".text-lime-500"); //HackyPluginComponent + assertStringIncludes(content, ".text-cyan-500"); //VeryNestedPluginComponent + assertStringIncludes(content, ".text-emerald-500"); //JsxComponent + }, + { loadConfig: true }, + ); +});