canopy-webpack-config 4.4.5 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,24 @@
1
1
  # canopy-webpack-config
2
+
2
3
  Some defaults for webpack configs at Canopy.
3
4
 
4
5
  ## Goals
6
+
5
7
  - Ensure webpack externals for common dependencies are correct. This ensures smaller bundle sizes.
6
8
  - Provide some defaults for people implementing sofe services.
7
9
  - Still be 100% overrideable.
8
10
 
9
11
  ## Things we don't want
12
+
10
13
  - Hiding webpack configs at a level comparable to create-react-app or angular-cli. Nothing will need to be "ejected," you can always override anything you want.
11
14
  - Discouraging people from experimenting with their webpack config.
12
15
  - Discouraging people from knowing webpack.
13
16
  - Being overly opininated about how you do things like css (or other "autonomy" areas of building a sofe service).
14
17
 
15
18
  ## Usage
19
+
16
20
  First, install the library as a devDependency.
21
+
17
22
  ```bash
18
23
  yarn add --dev canopy-webpack-config @babel/runtime @babel/plugin-transform-runtime
19
24
  ```
@@ -24,17 +29,17 @@ file, call the `canopyWebpackConfig()` function to get some defaults and add/ove
24
29
  ```js
25
30
  // webpack.config.js
26
31
 
27
- const canopyWebpackConfig = require('canopy-webpack-config');
32
+ const canopyWebpackConfig = require("canopy-webpack-config");
28
33
 
29
- module.exports = canopyWebpackConfig('login-ui', {
34
+ module.exports = canopyWebpackConfig("login-ui", {
30
35
  // Override or add anything you want to your webpack config
31
36
  module: {
32
37
  rules: [
33
38
  // e.g. apply a css loader if using css-modules
34
- { loader: 'css-loader' },
39
+ { loader: "css-loader" },
35
40
  ],
36
41
  },
37
- })
42
+ });
38
43
  ```
39
44
 
40
45
  Finally, create the following `scripts` in your package.json:
@@ -55,6 +60,7 @@ Now add `yarn build` to your `.gitlab-ci.yml` file for the build step. You can r
55
60
  will start up a web server that is ready to go as a sofe override.
56
61
 
57
62
  ## API
63
+
58
64
  - `canopyWebpackConfig(name, config)`: The default export of the canopy-webpack-config npm library. This function requires both of its arguments.
59
65
  The first argument is a string name for the library you are exporting. The second is a webpack config that will be merged with the defaults that
60
66
  canopy-webpack-config provides. The library will put the bundled files into the "build" directory. Note that this project assumes that you use
@@ -67,10 +73,13 @@ will start up a web server that is ready to go as a sofe override.
67
73
  needing to do anything.
68
74
 
69
75
  ## Debugging
76
+
70
77
  To see your full webpack config, simply add a `--env.debug` to your webpack cli command.
71
78
 
72
79
  ## Features and assumptions
80
+
73
81
  canopy-webpack-config assumes a few things about your project and provides defaults for those things:
82
+
74
83
  - It automatically uses babel-loader to compile js files.
75
84
  - It compiles your library to AMD format
76
85
  - It compiles your code into the `build` directory relative to where you started the webpack process.
@@ -78,20 +87,49 @@ canopy-webpack-config assumes a few things about your project and provides defau
78
87
  - Requires webpack-dev-server >= 3.4.0
79
88
 
80
89
  ## Limitations
90
+
81
91
  the webpack config for canopy-webpack-config will always create the output bundle in the directory that the webpack process was started in. This
82
92
  is different than how webpack configs normally work -- they usually create the output bundle in relation to the directory in which the webpack config
83
93
  file is placed.
84
94
 
85
95
  ## Optional modifiers
96
+
86
97
  We support a few config options for builds, including typescript and an externals submodule.
98
+
87
99
  ```
88
100
  module.exports = canopyWebpackConfig('login-ui', {}, {
89
101
  typescript: true,
90
102
  externals: true
91
103
  })
92
104
  ```
105
+
93
106
  ### Typescript `<bool>`
107
+
94
108
  Pass an options object with `{typescript: true}` when your service uss typescript.
95
109
 
96
110
  ### Externals `<bool>`
111
+
97
112
  Pass an options object with `{externals: true}` when you are looking to export a submodule from `/src/externals.{js|ts}`. This should be for exports that must be imported synchronously such as hooks or queries that do not have other internal dependencies (to avoid circular dependencies).
113
+
114
+ ### Tailwind `<object>`
115
+
116
+ Pass `{ tailwind: { prefix: '<service_prefix>' } }` and import the stylesheet in your app entry:
117
+
118
+ Use https://github.com/CanopyTax/frontend-docs/wiki/Repo-abbreviations to get the correct prefix for the service.
119
+
120
+ ```js
121
+ // webpack.config.js
122
+ module.exports = canopyWebpackConfig(
123
+ "<service_name>",
124
+ {},
125
+ {
126
+ typescript: true,
127
+ tailwind: { prefix: "ru" },
128
+ },
129
+ );
130
+ ```
131
+
132
+ ```js
133
+ // src/<service_name>.ts
134
+ import "canopy-webpack-config/tailwind.css";
135
+ ```
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "canopy-webpack-config",
3
- "version": "4.4.5",
3
+ "version": "4.5.0",
4
4
  "description": "Some defaults for webpack configs at canopy",
5
5
  "main": "src/canopy-webpack-config.js",
6
6
  "exports": {
7
7
  ".": "./src/canopy-webpack-config.js",
8
- "./esm": "./src/canopy-webpack-config-esm.mjs"
8
+ "./esm": "./src/canopy-webpack-config-esm.mjs",
9
+ "./tailwind.css": "./src/tailwind/base.css"
9
10
  },
10
11
  "repository": "git@github.com:CanopyTax/canopy-webpack-config.git",
11
12
  "license": "Apache-2.0",
@@ -17,7 +18,13 @@
17
18
  "prettier": "^3.6.2"
18
19
  },
19
20
  "dependencies": {
21
+ "@tailwindcss/postcss": "4.3.3",
20
22
  "clean-webpack-plugin": "^4.0.0",
23
+ "css-loader": "^7.1.2",
24
+ "postcss": "^8.4.49",
25
+ "postcss-loader": "^8.1.1",
26
+ "style-loader": "^4.0.0",
27
+ "tailwindcss": "4.3.3",
21
28
  "url": "^0.11.0",
22
29
  "webpack-bundle-analyzer": "^4.7.0",
23
30
  "webpack-merge": "^4.2.1"
@@ -4,6 +4,7 @@ import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
4
4
  import merge from "webpack-merge";
5
5
  import fs from "fs";
6
6
  import os from "os";
7
+ import { tailwindSupport } from "./tailwind/index.js";
7
8
 
8
9
  const homedir = os.homedir();
9
10
 
@@ -49,7 +50,7 @@ const externalPatterns = [
49
50
  ];
50
51
 
51
52
  export default function (name, overridesConfig = {}, options = {}) {
52
- const { externals: hasExternals } = options;
53
+ const { externals: hasExternals, tailwind } = options;
53
54
 
54
55
  if (typeof name !== "string") {
55
56
  throw new Error(
@@ -66,6 +67,12 @@ export default function (name, overridesConfig = {}, options = {}) {
66
67
  );
67
68
  }
68
69
 
70
+ const {
71
+ rules: tailwindRules,
72
+ plugins: tailwindPlugins,
73
+ alias: tailwindAlias,
74
+ } = tailwindSupport(tailwind);
75
+
69
76
  return function (env = {}) {
70
77
  const defaultCanopyConfig = {
71
78
  mode: env.dev || isDevServer ? "development" : "production",
@@ -122,6 +129,7 @@ export default function (name, overridesConfig = {}, options = {}) {
122
129
  fullySpecified: false,
123
130
  extensions: [".tsx", ".ts", ".js", ".jsx", ".json"],
124
131
  modules: [process.cwd(), "node_modules"],
132
+ alias: tailwindAlias,
125
133
  },
126
134
 
127
135
  module: {
@@ -131,6 +139,7 @@ export default function (name, overridesConfig = {}, options = {}) {
131
139
  exclude: /node_modules/,
132
140
  use: "babel-loader",
133
141
  },
142
+ ...tailwindRules,
134
143
  ],
135
144
  },
136
145
 
@@ -138,6 +147,7 @@ export default function (name, overridesConfig = {}, options = {}) {
138
147
  new CleanWebpackPlugin({
139
148
  cleanOnceBeforeBuildPatterns: ["**/*", "!.gitkeep"],
140
149
  }),
150
+ ...tailwindPlugins,
141
151
  env.analyze === "server" &&
142
152
  new BundleAnalyzerPlugin({ analyzerMode: "server" }),
143
153
  env.analyze === "static" &&
@@ -5,6 +5,7 @@ const BundleAnalyzerPlugin =
5
5
  const merge = require("webpack-merge");
6
6
  const fs = require("fs");
7
7
  const homedir = require("os").homedir();
8
+ const { tailwindSupport } = require("./tailwind/index.js");
8
9
 
9
10
  let isDevServer = false;
10
11
 
@@ -25,7 +26,7 @@ const port =
25
26
  : "8080";
26
27
 
27
28
  module.exports = function (name, overridesConfig = {}, options = {}) {
28
- const { typescript, externals } = options;
29
+ const { typescript, externals, tailwind } = options;
29
30
  if (typeof name !== "string") {
30
31
  throw new Error(
31
32
  "canopy-webpack-config expects a string name as the first argument",
@@ -42,6 +43,12 @@ module.exports = function (name, overridesConfig = {}, options = {}) {
42
43
  );
43
44
  }
44
45
 
46
+ const {
47
+ rules: tailwindRules,
48
+ plugins: tailwindPlugins,
49
+ alias: tailwindAlias,
50
+ } = tailwindSupport(tailwind);
51
+
45
52
  return function (env) {
46
53
  if (!env) {
47
54
  env = {};
@@ -81,6 +88,7 @@ module.exports = function (name, overridesConfig = {}, options = {}) {
81
88
  ],
82
89
  },
83
90
  },
91
+ ...tailwindRules,
84
92
  ],
85
93
  },
86
94
  resolve: {
@@ -90,6 +98,7 @@ module.exports = function (name, overridesConfig = {}, options = {}) {
90
98
  }
91
99
  : {}),
92
100
  modules: [process.cwd(), "node_modules"],
101
+ alias: tailwindAlias,
93
102
  fallback: {
94
103
  url: require.resolve("url/"),
95
104
  "react/jsx-runtime": "react/jsx-runtime.js",
@@ -101,6 +110,7 @@ module.exports = function (name, overridesConfig = {}, options = {}) {
101
110
  new BundleAnalyzerPlugin({
102
111
  analyzerMode: env.analyze || "disabled",
103
112
  }),
113
+ ...tailwindPlugins,
104
114
  ],
105
115
  devtool: "source-map",
106
116
  externals: [
@@ -0,0 +1,18 @@
1
+ /*
2
+ * The shared half of every Canopy app's Tailwind entry.
3
+ *
4
+ * canopy-webpack-config copies this file into a generated per-repo entry, underneath the
5
+ * two prefixed Tailwind `@import` lines (theme + utilities) that only the generator can write.
6
+ *
7
+ * Use only bare package specifiers here. The generated entry lives in the consuming repo's
8
+ * node_modules/.cache, and these imports resolve from there (e.g. @canopytax/understory).
9
+ */
10
+
11
+ /* Shared Canopy design system tokens */
12
+ @import "@canopytax/understory/tailwind/theme.css";
13
+
14
+ /* 10px base font rem recalculation */
15
+ @plugin "@canopytax/understory/tailwind/base-font-size";
16
+
17
+ /* Dark mode variant (same selector as understory) */
18
+ @custom-variant dark (&:is(:root.dark *, [data-mode="dark"] *));
@@ -0,0 +1,250 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const PLUGIN_NAME = "CanopyTailwindCandidatesPlugin";
5
+ const CACHE_DIR = "node_modules/.cache/canopy-tailwind";
6
+ // Deliberately not `.css`. Every repo has a `{ test: /\.css$/, include: [node_modules] }`
7
+ // rule, and webpack applies every matching rule, so a generated `.css` in here would pick
8
+ // up a second loader chain alongside ours and ship unprocessed Tailwind directives.
9
+ const ENTRY_FILE = "entry.tailwind";
10
+ const CANDIDATES_FILE = "candidates";
11
+ const BASE_CSS = path.resolve(__dirname, "base.css");
12
+
13
+ // tailwindcss is our dependency, not the consuming repo's, so the entry cannot reach it
14
+ // with a bare specifier — the entry resolves from the repo's node_modules, where it may
15
+ // not be hoisted. An absolute path produces byte-identical output and always resolves.
16
+ const TAILWIND_CSS_DIR = path.dirname(
17
+ require.resolve("tailwindcss/package.json"),
18
+ );
19
+
20
+ // What a consumer writes in their app entry. Aliased to the generated file below.
21
+ const PUBLIC_SPECIFIER = "canopy-webpack-config/tailwind.css";
22
+
23
+ const DEFAULT_SOURCES = ["src/**/*.{js,jsx,ts,tsx}"];
24
+
25
+ // Tailwind's built-in prefix only generates a utility when the prefixed candidate
26
+ // appears literally in a scanned file. Our tw() helper composes the prefix at runtime,
27
+ // so the scanner never sees it. We run Tailwind's own extractor ourselves, prepend the
28
+ // prefix to everything it finds, and hand the result back through @source.
29
+ //
30
+ // oxide is a native binary pinned to an exact version by @tailwindcss/postcss, and the
31
+ // extraction has to match the Tailwind that actually builds the CSS. Both are our
32
+ // dependencies now, so resolving from here is what guarantees they agree.
33
+ function loadScanner() {
34
+ try {
35
+ return require("@tailwindcss/oxide").Scanner;
36
+ } catch (err) {
37
+ throw new Error(
38
+ `${PLUGIN_NAME}: could not load @tailwindcss/oxide. It ships with our own ` +
39
+ `@tailwindcss/postcss, so this is a broken canopy-webpack-config install — on ` +
40
+ `CI more often a missing platform binary than a missing package; see the cause.`,
41
+ { cause: err },
42
+ );
43
+ }
44
+ }
45
+
46
+ function buildEntry(prefix, extendPath) {
47
+ let base;
48
+ try {
49
+ base = fs.readFileSync(BASE_CSS, "utf8");
50
+ } catch (err) {
51
+ throw new Error(
52
+ `${PLUGIN_NAME}: could not read the shared Tailwind base at ${BASE_CSS}. This is ` +
53
+ `a broken canopy-webpack-config install, not a problem with your repo.`,
54
+ { cause: err },
55
+ );
56
+ }
57
+
58
+ return [
59
+ `/* Generated by canopy-webpack-config on every build — edits here are lost.`,
60
+ ` * Change the \`tailwind\` option in your webpack config, or src/tailwind/base.css`,
61
+ ` * in canopy-webpack-config for boilerplate every app shares. */`,
62
+ ``,
63
+ `@import "${TAILWIND_CSS_DIR}/theme.css" prefix(${prefix});`,
64
+ `@import "${TAILWIND_CSS_DIR}/utilities.css" prefix(${prefix});`,
65
+ ``,
66
+ base.trim(),
67
+ ``,
68
+ // Absolute so it does not depend on where the repo-specific file sits.
69
+ ...(extendPath ? [`@import "${extendPath}";`, ``] : []),
70
+ `@source "./${CANDIDATES_FILE}";`,
71
+ ``,
72
+ ].join("\n");
73
+ }
74
+
75
+ class TailwindCandidatesPlugin {
76
+ constructor(options = {}) {
77
+ const {
78
+ prefix,
79
+ context = process.cwd(),
80
+ sources = DEFAULT_SOURCES,
81
+ extend,
82
+ } = options;
83
+
84
+ if (typeof prefix !== "string" || !prefix) {
85
+ throw new Error(
86
+ `${PLUGIN_NAME}: \`prefix\` is required, e.g. { prefix: "ru" }`,
87
+ );
88
+ }
89
+
90
+ this.prefix = prefix;
91
+ this.context = context;
92
+ this.sources = sources;
93
+ this.extendPath = extend ? path.resolve(context, extend) : null;
94
+ this.cacheDir = path.resolve(context, CACHE_DIR);
95
+ this.entryPath = path.join(this.cacheDir, ENTRY_FILE);
96
+ this.candidatesPath = path.join(this.cacheDir, CANDIDATES_FILE);
97
+ }
98
+
99
+ // Rewriting an unchanged file would invalidate the Tailwind entry on every compile and
100
+ // loop the dev server.
101
+ writeIfChanged(filePath, contents) {
102
+ let previous;
103
+ try {
104
+ previous = fs.readFileSync(filePath, "utf8");
105
+ } catch (err) {
106
+ if (err && err.code === "ENOENT") {
107
+ previous = null;
108
+ } else {
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ if (previous === contents) return false;
114
+
115
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
116
+ fs.writeFileSync(filePath, contents);
117
+ return true;
118
+ }
119
+
120
+ generate() {
121
+ if (this.extendPath && !fs.existsSync(this.extendPath)) {
122
+ throw new Error(
123
+ `${PLUGIN_NAME}: \`tailwind.extend\` points at ${this.extendPath}, which does ` +
124
+ `not exist.`,
125
+ );
126
+ }
127
+
128
+ const Scanner = loadScanner();
129
+ const scanner = new Scanner({
130
+ sources: this.sources.map((pattern) => ({
131
+ base: this.context,
132
+ pattern,
133
+ negated: false,
134
+ })),
135
+ });
136
+
137
+ const candidates = scanner.scan();
138
+
139
+ if (candidates.length === 0) {
140
+ throw new Error(
141
+ `${PLUGIN_NAME}: found no Tailwind candidates under ${this.sources.join(", ")}. ` +
142
+ `Building it would produce an empty stylesheet.`,
143
+ );
144
+ }
145
+
146
+ // Everything the extractor found gets the prefix
147
+ const contents =
148
+ candidates.map((c) => `${this.prefix}:${c}`).join("\n") + "\n";
149
+
150
+ const changed = this.writeIfChanged(this.candidatesPath, contents);
151
+ this.writeIfChanged(
152
+ this.entryPath,
153
+ buildEntry(this.prefix, this.extendPath),
154
+ );
155
+
156
+ return { count: candidates.length, files: scanner.files, changed };
157
+ }
158
+
159
+ apply(compiler) {
160
+ // beforeCompile lands before any module is resolved or built, so both generated
161
+ // files are on disk by the time webpack resolves the alias and postcss-loader reads the entry
162
+ compiler.hooks.beforeCompile.tap(PLUGIN_NAME, () => {
163
+ this.lastError = null;
164
+ try {
165
+ this.lastRun = this.generate();
166
+ } catch (err) {
167
+ this.lastError = err;
168
+ if (!compiler.watchMode) throw err;
169
+ }
170
+ });
171
+
172
+ compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
173
+ if (this.lastError) {
174
+ compilation.errors.push(this.lastError);
175
+ return;
176
+ }
177
+
178
+ const run = this.lastRun;
179
+ if (!run) return;
180
+
181
+ // @tailwindcss/postcss reports the candidates file as a dependency and
182
+ // postcss-loader forwards it, so edits to a file already in the graph rebuild on
183
+ // their own. Registering the scanned files and their roots covers additions and
184
+ // deletions.
185
+ for (const file of run.files) {
186
+ compilation.fileDependencies.add(file);
187
+ }
188
+ for (const pattern of this.sources) {
189
+ compilation.contextDependencies.add(
190
+ path.resolve(this.context, pattern.split("*")[0]),
191
+ );
192
+ }
193
+ if (this.extendPath) {
194
+ compilation.fileDependencies.add(this.extendPath);
195
+ }
196
+
197
+ compilation
198
+ .getLogger(PLUGIN_NAME)
199
+ .info(
200
+ `${run.changed ? "wrote" : "reused"} ${run.count} prefixed candidates ` +
201
+ `(${this.prefix}:) from ${run.files.length} files`,
202
+ );
203
+ });
204
+ }
205
+ }
206
+
207
+ function tailwindRule(entryPath) {
208
+ return {
209
+ test: (resource) => resource === entryPath,
210
+ use: [
211
+ { loader: require.resolve("style-loader") },
212
+ { loader: require.resolve("css-loader") },
213
+ {
214
+ loader: require.resolve("postcss-loader"),
215
+ options: {
216
+ postcssOptions: { plugins: [require("@tailwindcss/postcss")] },
217
+ },
218
+ },
219
+ ],
220
+ };
221
+ }
222
+
223
+ // Returns the rule, plugin and alias a consuming config needs, or empty ones when the
224
+ // repo has not opted in.
225
+ function tailwindSupport(tailwind) {
226
+ if (!tailwind) {
227
+ return { rules: [], plugins: [], alias: {} };
228
+ }
229
+
230
+ const plugin = new TailwindCandidatesPlugin(tailwind);
231
+
232
+ return {
233
+ rules: [tailwindRule(plugin.entryPath)],
234
+ plugins: [plugin],
235
+ // `$` makes this an exact match, so it only ever catches the stylesheet import.
236
+ alias: { [`${PUBLIC_SPECIFIER}$`]: plugin.entryPath },
237
+ };
238
+ }
239
+
240
+ module.exports = {
241
+ TailwindCandidatesPlugin,
242
+ tailwindRule,
243
+ tailwindSupport,
244
+ buildEntry,
245
+ BASE_CSS,
246
+ CACHE_DIR,
247
+ ENTRY_FILE,
248
+ CANDIDATES_FILE,
249
+ PUBLIC_SPECIFIER,
250
+ };