Skip to content

Commit

Permalink
Clean core API
Browse files Browse the repository at this point in the history
  • Loading branch information
ArnaudBarre committed Nov 5, 2023
1 parent dafcd8c commit c896716
Show file tree
Hide file tree
Showing 13 changed files with 120 additions and 68 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Remove dependency on LightningCSS

This was here mostly because I wanted to get CSS modules and Tailwind working with esbuild. Now that esbuild support CSS modules, I can remove this coupling and makes this repo easier to re-use in other bundlers. This also mean I'm dropping from this repo features related to build tools, like `downwind.transform`, `cssModuleToJS` & `convertTargets`.
This was here mostly because I wanted to get CSS modules and Tailwind working with esbuild. Now that esbuild support CSS modules, I can remove this coupling and makes this repo easier to re-use in other bundlers. This also mean I'm dropping from this repo features related to build tools, like `downwind.transform`, `cssModuleToJS` & `convertTargets`. The rest of the core downwind object API has also been updated to give more flexibility outside of built-in plugins.

For usage Vite, you can get back the same behaviour by using the builtin support for lightningCSS:

Expand Down
58 changes: 48 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,29 @@ await build({

Add `import "virtual:@downwind/base.css";` and `import "virtual:@downwind/utils.css";` to your code.

## Scanned extension
## Scanned extensions

For almost all UI application, the CSS classes are always located in the same file extension (`tsx`, `vue`, `svelte`).

By default, downwind will only scan the file with matches the `scannedExtension` (default to `tsx`).

It can be changed in both plugins:
By default, only `.tsx` files and `.ts` files containing `@downwind-scan` are scanned. This can be changed in both plugins:

```ts
// vite
plugins: [downwind({ scannedExtension: "vue" })];
plugins: [
downwind({
shouldScan: (id, code) =>
id.endsWith(".vue") ||
(id.endsWith(".ts") && code.includes("@downwind-scan")),
}),
];
// esbuild
plugins: [downwind({ scannedExtension: "vue", scanRegex: /\.(vue|ts)$/ })];
plugins: [
downwind({
filter: /\.(vue|ts)$/,
shouldScan: (path, code) =>
path.endsWith(".vue") || code.includes("@downwind-scan"),
}),
];
```

For cases where you need any other file to be scanned, include the `@downwind-scan` pragma in any comment of the file.

## Configuration

This is optional and can be used to customize the default theme, disable core rules, add new rules, shortcuts or a safelist.
Expand Down Expand Up @@ -236,3 +242,35 @@ To avoid parsing errors in WebStorm, double quotes are required. And because [th
- Letter spacing & font weight in fontSize theme
- Font feature & variation settings in fontFamily theme
- Regular expressions in safelist

## How it works

When loading the configuration, three maps are generated: one for all possible variants, one for all static rules and one for all prefix with possible arbitrary values.

Then an object with few methods is returned:

```ts
{
getBase: () => string;
preTransformCSS: (content: string) => {
invalidateUtils: boolean;
code: string;
};
scan: (code: string) => boolean /* hasNewUtils */;
generate: () => string;
}
```

- `getBase` returns the static preflight, identical to Tailwind. Init of CSS variables like `--tw-ring-inset` are included in the "utils", which remove the need for base to be processed with utils.
- `preTransformCSS` is used to replace `@apply`, `@screen` & `theme()` in CSS files. Some utils may depend on CSS variable injected in the header of utils, so `invalidateUtils` can be used during development to send an HMR update or refresh the page.
- `scan` is used to scan some source code. A regex is first use to match candidates and then these candidates are parsed roughly like this:
- Search for variants (repeat until not match):
- If the token start `[`, looks for next `]:` and add the content as arbitrary variant. If no `]:`, test if it's an arbitrary value (`[color:red]`).
- else search `:` and use the prefix to search in the variant map
- Test if the remaining token is part of the static rules
- Search for `-[.+]` and then for the prefix possible arbitrary values maps
- If the token ends roughly `/\d+` or `/[.+]`, parse the end as a modifier

If the token matches a rule and is new it's added to an internal map structure by media queries. `true` is returned and this can be used to invalidate utils in developments.

- `generate` is used to transform the recursive map into a CSS output. This is returned as the content of `virtual:@downwind/utils.css` in plugins.
11 changes: 5 additions & 6 deletions playground/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export const config: DownwindConfig = {};
);
writeFileSync(
"./input.ts",
`// @downwind-scan
console.log("p-4");
`const foo = "p-4";
`,
);
writeFileSync(
Expand All @@ -38,11 +37,11 @@ const downwind = initDownwindWithConfig({
// @ts-ignore (file exist locally but not on CI)
config: (await import("./config.ts")).config,
});
downwind.scan("./input.ts");
downwind.scan(readFileSync("./input.ts", "utf-8"));

const transform = downwind.preTransform(
const css = downwind.preTransformCSS(
readFileSync("./input.module.css", "utf-8"),
).content;
).code;
const utils = downwind.generate();

const warningsHeader = warnings.length
Expand All @@ -52,4 +51,4 @@ const logsHeader = logs.length
? `/* Logs:\n${logs.map((l) => l.join(", ")).join("\n")}\n*/\n`
: "";

writeFileSync("./output.css", warningsHeader + logsHeader + transform + utils);
writeFileSync("./output.css", warningsHeader + logsHeader + css + utils);
16 changes: 14 additions & 2 deletions src/esbuild.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { Plugin as ESBuildPlugin } from "esbuild";

export declare const downwind: (opts?: {
scannedExtension?: string;
scanRegex?: RegExp;
/**
* Pass to esbuild to reduce the number of file read from the disk
* @default: /\.tsx?$/
**/
filter?: RegExp;
/**
* Used to reduce the number of scanned files.
* @default (path, code) => path.endsWith("x") || code.includes("@downwind-scan")
*/
shouldScan?: (path: string, code: string) => boolean;
/**
* Number of millisecond without new scan to wait before generating utils
* @default 50
*/
intervalCheckMs?: number;
}) => ESBuildPlugin;
18 changes: 11 additions & 7 deletions src/esbuildPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { intervalCheck } from "./utils/intervalCheck.ts";
export { esbuildPlugin as downwind };

const esbuildPlugin: typeof declaration = ({
scannedExtension,
scanRegex = /\.[jt]sx?$/,
filter = /\.tsx?$/,
shouldScan = (path: string, code: string) =>
path.endsWith("x") || code.includes("@downwind-scan"),
intervalCheckMs = 50,
} = {}) => ({
name: "downwind",
setup: async (build) => {
const downwind = await initDownwind({ scannedExtension });
const downwind = await initDownwind();

let hasBase = false;
let hasUtils = false;
Expand Down Expand Up @@ -52,17 +53,20 @@ const esbuildPlugin: typeof declaration = ({
build.onLoad({ filter: /\.css$/ }, ({ path }) => {
utilsIntervalCheck.taskRunning();
return {
contents: downwind.preTransform(readFileSync(path, "utf-8")).content,
contents: downwind.preTransformCSS(readFileSync(path, "utf-8")).code,
loader: path.endsWith(".module.css") ? "local-css" : "css",
};
});

// Scanned files
build.onLoad({ filter: scanRegex }, ({ path }) => {
build.onLoad({ filter }, ({ path }) => {
// https://github.com/evanw/esbuild/issues/1222
if (path.includes("/node_modules/")) return;
utilsIntervalCheck.taskRunning();
downwind.scan(path);
const code = readFileSync(path, "utf-8");
if (shouldScan(path, code)) {
utilsIntervalCheck.taskRunning();
downwind.scan(code);
}
return null;
});

Expand Down
26 changes: 9 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { loadConfig } from "@arnaud-barre/config-loader";
import { getBase } from "./base/getBase.ts";
import { getDefaults } from "./getDefaults.ts";
Expand Down Expand Up @@ -51,25 +50,24 @@ type Match = {
);
type MatchMap = { matches: Match[]; medias: Map<string, MatchMap> };

export const initDownwind: typeof initDownwindDeclaration = async (opts) => {
export const initDownwind: typeof initDownwindDeclaration = async () => {
const loadedConfig = globalThis.TEST_CONFIG
? { config: globalThis.TEST_CONFIG, files: [] }
: await loadConfig<UserConfig>("downwind");
return initDownwindWithConfig({
config: loadedConfig?.config,
configFiles: loadedConfig?.files,
...opts,
});
};

/** @internal */
export const initDownwindWithConfig = ({
config: userConfig,
configFiles = [],
scannedExtension = "tsx",
}: {
config: UserConfig | undefined;
configFiles?: string[];
} & Parameters<typeof initDownwindDeclaration>[0]) => {
}) => {
const config = resolveConfig(userConfig);
const defaults = getDefaults(config);
const variantsMap = getVariants(config);
Expand Down Expand Up @@ -429,7 +427,7 @@ export const initDownwindWithConfig = ({

return {
getBase: () => getBase(config.theme),
preTransform: (content: string) => {
preTransformCSS: (content: string) => {
let invalidateUtils = false;
const hasApply = content.includes("@apply ");
if (hasApply) {
Expand Down Expand Up @@ -486,24 +484,18 @@ export const initDownwindWithConfig = ({
});
}

return { content, invalidateUtils };
return { code: content, invalidateUtils };
},
scan: (
path: string,
code = readFileSync(path, "utf-8"),
): boolean /* hasNew */ => {
const shouldScan =
path.endsWith(scannedExtension) || code.includes("@downwind-scan");
if (!shouldScan) return false;
scan: (code: string): boolean /* hasNewUtils */ => {
const tokens = code
.split(/[\s'"`;=]+/g)
.filter((t) => validSelectorRE.test(t) && !blockList.has(t));
let hasNew = false;
let hasNewUtils = false;
for (const token of tokens) {
const match = parse(token);
if (match && addMatch(match)) hasNew = true;
if (match && addMatch(match)) hasNewUtils = true;
}
return hasNew;
return hasNewUtils;
},
generate: () => {
let useContainer = false;
Expand Down
10 changes: 4 additions & 6 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,15 @@ export type DownwindConfig = DefineConfig<UserConfig>;
/**
* API
*/
export declare const initDownwind: (opts?: {
scannedExtension?: string;
}) => Promise<Downwind>;
export declare const initDownwind: () => Promise<Downwind>;

export type Downwind = {
getBase: () => string;
preTransform: (content: string) => {
preTransformCSS: (content: string) => {
invalidateUtils: boolean;
content: string;
code: string;
};
scan: (path: string, content?: string) => boolean /* hasNew */;
scan: (code: string) => boolean /* hasNewUtils */;
generate: () => string;
codegen: (opts: {
mode: "WITH_CONTENT" | "OMIT_CONTENT" | "DEVTOOLS";
Expand Down
12 changes: 11 additions & 1 deletion src/vite.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Plugin as VitePlugin } from "vite";

export declare const downwind: (opts?: {
scannedExtension?: string;
/**
* Used to reduce the number of scanned files.
* @default (id, code) =>
* id.endsWith(".tsx") ||
* (id.endsWith(".ts") && code.includes("@downwind-scan")),
*/
shouldScan?: (id: string, code: string) => boolean;
/**
* Number of millisecond without new scan to wait before generating utils
* @default 200
*/
buildIntervalCheckMs?: number;
}) => VitePlugin[];
25 changes: 12 additions & 13 deletions src/vitePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const cssRE = /\.css(\?.+)?$/;
export { vitePlugin as downwind };

const vitePlugin: typeof declaration = ({
scannedExtension,
shouldScan = (id: string, code: string) =>
id.endsWith(".tsx") ||
(id.endsWith(".ts") && code.includes("@downwind-scan")),
buildIntervalCheckMs = 200,
} = {}): Plugin[] => {
let downwind: Downwind;
Expand All @@ -20,7 +22,7 @@ const vitePlugin: typeof declaration = ({
const configResolved = async (config: ResolvedConfig) => {
const origin = config.server.origin ?? "";
devtoolsPostPath = `${origin}/@downwind-devtools-update`;
downwind = await initDownwind({ scannedExtension });
downwind = await initDownwind();
};

let hasBase = false;
Expand Down Expand Up @@ -96,10 +98,7 @@ const vitePlugin: typeof declaration = ({
}
getBodyJson<string[]>(res.req)
.then((classes) => {
const hasNew = downwind.scan(
"devtools-update",
`@downwind-scan ${classes.join(" ")}`,
);
const hasNew = downwind.scan(` ${classes.join(" ")} `);
if (hasNew) sendUpdate();
res.writeHead(200);
res.end();
Expand Down Expand Up @@ -140,12 +139,12 @@ const vitePlugin: typeof declaration = ({
},
transform(code, id) {
if (id.endsWith(".css")) {
const result = downwind.preTransform(code);
const result = downwind.preTransformCSS(code);
if (result.invalidateUtils && lastServed) sendUpdate();
return { code: result.content };
return { code: result.code };
}
if (!id.includes("/node_modules/")) {
const hasNew = downwind.scan(id, code);
if (!id.includes("/node_modules/") && shouldScan(id, code)) {
const hasNew = downwind.scan(code);
if (hasNew && lastServed) sendUpdate();
}
},
Expand Down Expand Up @@ -177,13 +176,13 @@ const vitePlugin: typeof declaration = ({
if (cssRE.test(id)) {
utilsIntervalCheck.taskRunning();
return {
code: downwind.preTransform(code).content,
code: downwind.preTransformCSS(code).code,
map: { mappings: "" },
};
}
if (!id.includes("/node_modules/")) {
if (!id.includes("/node_modules/") && shouldScan(id, code)) {
utilsIntervalCheck.taskRunning();
downwind.scan(id, code);
downwind.scan(code);
}
},
},
Expand Down
2 changes: 1 addition & 1 deletion tests/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ const snapshots = Object.fromEntries(
let newSnapshot = "";
for (const [name, content, config] of cases) {
const downwind = initDownwindWithConfig({ config });
downwind.scan(`${name}.tsx`, content);
downwind.scan(content);
const actual = `/* ${name}: ${content} */\n${downwind.generate()}\n`;
if (shouldUpdateSnapshots) newSnapshot += actual;
test(name, () => {
Expand Down
6 changes: 3 additions & 3 deletions tests/preTransform.test.ts → tests/preTransformCSS.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { snapshotTest } from "./test-utils.ts";

snapshotTest(
"preTransform",
"preTransformCSS",
(downwind) =>
downwind.preTransform(`
downwind.preTransformCSS(`
.class1 {
@apply m-4 px-4;
min-height: theme("spacing.2.5");
Expand All @@ -24,5 +24,5 @@ snapshotTest(
@apply top-4 last:space-y-10
}
}
`).content,
`).code,
);
2 changes: 1 addition & 1 deletion tests/run-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ declare global {
}

import("./generate.test.ts");
import("./preTransform.test.ts");
import("./preTransformCSS.test.ts");
import("./codegen.test.ts");
import("./esbuildPlugin.test.ts");
import("./vitePlugin.test.ts");
File renamed without changes.

0 comments on commit c896716

Please sign in to comment.