@ecopages/postcss-processor 0.2.0-alpha.2 → 0.2.0-alpha.21

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/src/plugin.js CHANGED
@@ -2,8 +2,8 @@ import path from "node:path";
2
2
  import { fileSystem } from "@ecopages/file-system";
3
3
  import { Processor } from "@ecopages/core/plugins/processor";
4
4
  import { Logger } from "@ecopages/logger";
5
- import { PostCssProcessor } from "./postcss-processor";
6
- import { createCssLoaderPlugin } from "./runtime/css-loader-plugin";
5
+ import { PostCssProcessor } from "./postcss-processor.js";
6
+ import { createCssLoaderPlugin } from "./runtime/css-loader-plugin.js";
7
7
  const logger = new Logger("[@ecopages/postcss-processor]", {
8
8
  debug: process.env.ECOPAGES_LOGGER_DEBUG === "true"
9
9
  });
@@ -11,8 +11,17 @@ class PostCssProcessorPlugin extends Processor {
11
11
  static DEFAULT_OPTIONS = {
12
12
  filter: /\.css$/
13
13
  };
14
+ buildContributionsPrepared = false;
14
15
  postcssPlugins = [];
16
+ pluginFactories;
15
17
  runtimeCssCache = /* @__PURE__ */ new Map();
18
+ trackedCssFiles = /* @__PURE__ */ new Set();
19
+ watchQueue = Promise.resolve();
20
+ /**
21
+ * Maps an imported CSS file path → set of tracked CSS entry files that import it.
22
+ * Used to resolve which parent entry files need re-processing when a dependency changes.
23
+ */
24
+ cssDependencyMap = /* @__PURE__ */ new Map();
16
25
  getCssFilter() {
17
26
  return this.options?.filter ?? PostCssProcessorPlugin.DEFAULT_OPTIONS.filter;
18
27
  }
@@ -53,6 +62,7 @@ class PostCssProcessorPlugin extends Processor {
53
62
  if (!this.matchesFileFilter(filePath)) {
54
63
  continue;
55
64
  }
65
+ this.trackedCssFiles.add(filePath);
56
66
  const rawContents = await fileSystem.readFile(filePath);
57
67
  let transformedInput = rawContents;
58
68
  if (this.options?.transformInput) {
@@ -62,6 +72,69 @@ class PostCssProcessorPlugin extends Processor {
62
72
  this.runtimeCssCache.set(filePath, processed);
63
73
  await this.persistProcessedCss(filePath, processed);
64
74
  }
75
+ this.buildCssDependencyMap();
76
+ }
77
+ /**
78
+ * Regex to match CSS @import statements and extract the path.
79
+ * Handles: @import './foo.css'; @import "./foo.css"; @import url('./foo.css');
80
+ */
81
+ static CSS_IMPORT_REGEX = /@import\s+(?:url\(\s*)?['"]([^'"]+\.css)['"](?:\s*\))?\s*;/gm;
82
+ /**
83
+ * Builds the CSS dependency map by scanning tracked CSS files for @import directives.
84
+ * Maps each imported file to the set of tracked entry files that import it (directly or transitively).
85
+ */
86
+ buildCssDependencyMap() {
87
+ this.cssDependencyMap.clear();
88
+ for (const entryFile of this.trackedCssFiles) {
89
+ if (!fileSystem.exists(entryFile)) continue;
90
+ const rawContents = fileSystem.readFileAsBuffer(entryFile).toString("utf-8");
91
+ const imports = this.extractCssImports(rawContents, entryFile);
92
+ for (const importedFile of imports) {
93
+ if (!this.cssDependencyMap.has(importedFile)) {
94
+ this.cssDependencyMap.set(importedFile, /* @__PURE__ */ new Set());
95
+ }
96
+ this.cssDependencyMap.get(importedFile).add(entryFile);
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * Extracts resolved absolute paths of CSS files imported via @import in the given CSS content.
102
+ * Recursively follows imports to capture transitive dependencies.
103
+ * It skips bare module imports like @import 'tailwindcss'.
104
+ * It recursively follows imports to capture transitive dependencies.
105
+ */
106
+ extractCssImports(cssContent, fromFile, visited = /* @__PURE__ */ new Set()) {
107
+ const dir = path.dirname(fromFile);
108
+ const imports = [];
109
+ let match;
110
+ const regex = new RegExp(PostCssProcessorPlugin.CSS_IMPORT_REGEX.source, "gm");
111
+ while ((match = regex.exec(cssContent)) !== null) {
112
+ const importPath = match[1];
113
+ if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
114
+ continue;
115
+ }
116
+ const resolvedPath = path.resolve(dir, importPath);
117
+ if (visited.has(resolvedPath)) continue;
118
+ visited.add(resolvedPath);
119
+ imports.push(resolvedPath);
120
+ if (fileSystem.exists(resolvedPath)) {
121
+ const nestedContent = fileSystem.readFileAsBuffer(resolvedPath).toString("utf-8");
122
+ const nestedImports = this.extractCssImports(nestedContent, resolvedPath, visited);
123
+ imports.push(...nestedImports);
124
+ }
125
+ }
126
+ return imports;
127
+ }
128
+ /**
129
+ * Resolves a changed CSS file to its parent entry file(s) if it is an @import dependency.
130
+ * Returns an empty array if the file is not an import dependency (i.e., it's an entry file itself).
131
+ */
132
+ resolveEntryFiles(filePath) {
133
+ const entries = this.cssDependencyMap.get(filePath);
134
+ if (!entries || entries.size === 0) {
135
+ return [];
136
+ }
137
+ return Array.from(entries);
65
138
  }
66
139
  transformCssSync(input) {
67
140
  const cached = this.runtimeCssCache.get(input.filePath);
@@ -92,6 +165,51 @@ class PostCssProcessorPlugin extends Processor {
92
165
  const filter = this.options?.filter ?? PostCssProcessorPlugin.DEFAULT_OPTIONS.filter;
93
166
  return filter.test(filepath);
94
167
  }
168
+ materializePluginFactories(pluginFactories) {
169
+ return Object.values(pluginFactories).map((factory) => factory());
170
+ }
171
+ refreshConfiguredPlugins() {
172
+ if (!this.pluginFactories) {
173
+ return;
174
+ }
175
+ this.postcssPlugins = this.materializePluginFactories(this.pluginFactories);
176
+ }
177
+ enqueueWatchTask(task) {
178
+ const queuedTask = this.watchQueue.then(task, task);
179
+ this.watchQueue = queuedTask.catch(() => void 0);
180
+ return queuedTask;
181
+ }
182
+ getTrackedCssFiles() {
183
+ return Array.from(this.trackedCssFiles).filter(
184
+ (filePath) => this.matchesFileFilter(filePath) && fileSystem.exists(filePath)
185
+ );
186
+ }
187
+ getTrackedCssEntryFiles() {
188
+ const importedCssFiles = new Set(this.cssDependencyMap.keys());
189
+ return this.getTrackedCssFiles().filter((filePath) => !importedCssFiles.has(filePath));
190
+ }
191
+ getDependencyEntryFiles() {
192
+ const configuredEntryPaths = this.options?.dependencyEntryPaths;
193
+ if (!configuredEntryPaths || configuredEntryPaths.length === 0) {
194
+ return this.getTrackedCssEntryFiles();
195
+ }
196
+ return configuredEntryPaths.filter(
197
+ (filePath) => this.matchesFileFilter(filePath) && fileSystem.exists(filePath)
198
+ );
199
+ }
200
+ async handleDependencyChange(bridge) {
201
+ if (!this.context) {
202
+ return;
203
+ }
204
+ const cssFiles = this.getDependencyEntryFiles();
205
+ if (cssFiles.length === 0) {
206
+ return;
207
+ }
208
+ this.refreshConfiguredPlugins();
209
+ for (const cssFilePath of cssFiles) {
210
+ await this.handleCssChange(cssFilePath, bridge, false);
211
+ }
212
+ }
95
213
  constructor(config = {
96
214
  options: PostCssProcessorPlugin.DEFAULT_OPTIONS
97
215
  }) {
@@ -101,14 +219,56 @@ class PostCssProcessorPlugin extends Processor {
101
219
  capabilities: [
102
220
  {
103
221
  kind: "stylesheet",
104
- extensions: ["*.css"]
222
+ extensions: ["*.{css,scss,sass,less}"]
105
223
  }
106
224
  ],
107
225
  watch: {
108
226
  paths: [],
109
- extensions: [".css", ".scss", ".sass", ".less"],
227
+ extensions: [
228
+ ".css",
229
+ ".scss",
230
+ ".sass",
231
+ ".less",
232
+ ".tsx",
233
+ ".ts",
234
+ ".jsx",
235
+ ".js",
236
+ ".mdx",
237
+ ".html",
238
+ ".svelte",
239
+ ".vue"
240
+ ],
110
241
  onChange: async ({ path: path2, bridge }) => {
111
- await this.handleCssChange(path2, bridge);
242
+ await this.enqueueWatchTask(async () => {
243
+ if (this.matchesFileFilter(path2)) {
244
+ await this.handleCssChange(path2, bridge);
245
+ return;
246
+ }
247
+ await this.handleDependencyChange(bridge);
248
+ });
249
+ },
250
+ onCreate: async ({ path: path2, bridge }) => {
251
+ await this.enqueueWatchTask(async () => {
252
+ if (this.matchesFileFilter(path2)) {
253
+ this.trackedCssFiles.add(path2);
254
+ this.buildCssDependencyMap();
255
+ await this.handleCssChange(path2, bridge);
256
+ return;
257
+ }
258
+ await this.handleDependencyChange(bridge);
259
+ });
260
+ },
261
+ onDelete: async ({ path: path2, bridge }) => {
262
+ await this.enqueueWatchTask(async () => {
263
+ if (this.matchesFileFilter(path2)) {
264
+ this.runtimeCssCache.delete(path2);
265
+ this.trackedCssFiles.delete(path2);
266
+ this.cssDependencyMap.delete(path2);
267
+ this.buildCssDependencyMap();
268
+ return;
269
+ }
270
+ await this.handleDependencyChange(bridge);
271
+ });
112
272
  }
113
273
  },
114
274
  ...config
@@ -116,16 +276,44 @@ class PostCssProcessorPlugin extends Processor {
116
276
  }
117
277
  /**
118
278
  * Handles CSS file changes during development.
119
- * Processes the file and broadcasts a css-update event for hot reloading.
279
+ * If the file is an @import dependency, re-processes the parent entry file(s) instead.
280
+ * Broadcasts a css-update event for hot reloading.
281
+ */
282
+ async handleCssChange(filePath, bridge, refreshPlugins = true) {
283
+ if (!this.context) return;
284
+ if (!fileSystem.exists(filePath)) return;
285
+ const entryFiles = this.resolveEntryFiles(filePath);
286
+ if (entryFiles.length > 0) {
287
+ logger.debug(`CSS dependency changed: ${filePath}, re-processing ${entryFiles.length} parent(s)`);
288
+ for (const entryFile of entryFiles) {
289
+ this.runtimeCssCache.delete(entryFile);
290
+ await this.processAndBroadcast(entryFile, bridge, refreshPlugins);
291
+ }
292
+ return;
293
+ }
294
+ await this.processAndBroadcast(filePath, bridge, refreshPlugins);
295
+ }
296
+ /**
297
+ * Processes a CSS file and broadcasts a css-update event.
298
+ * Skips broadcast if the processed output hasn't changed.
120
299
  */
121
- async handleCssChange(filePath, bridge) {
300
+ async processAndBroadcast(filePath, bridge, refreshPlugins = true) {
122
301
  if (!this.context) return;
302
+ if (!fileSystem.exists(filePath)) return;
123
303
  try {
304
+ this.trackedCssFiles.add(filePath);
305
+ if (refreshPlugins) {
306
+ this.refreshConfiguredPlugins();
307
+ }
124
308
  let content = await fileSystem.readFile(filePath);
125
309
  if (this.options?.transformInput) {
126
310
  content = await this.options.transformInput(content, filePath);
127
311
  }
128
312
  const processed = await this.process(content, filePath);
313
+ const cached = this.runtimeCssCache.get(filePath);
314
+ if (cached === processed) {
315
+ return;
316
+ }
129
317
  this.runtimeCssCache.set(filePath, processed);
130
318
  await this.persistProcessedCss(filePath, processed);
131
319
  bridge.cssUpdate(filePath);
@@ -155,10 +343,24 @@ class PostCssProcessorPlugin extends Processor {
155
343
  ];
156
344
  }
157
345
  /**
158
- * Setup the PostCSS processor.
346
+ * Resolves the configured PostCSS plugin list before config build seals the
347
+ * app manifest.
348
+ *
349
+ * @remarks
350
+ * Runtime setup reuses this prepared list and only performs cache prewarming.
159
351
  */
160
- async setup() {
352
+ async prepareBuildContributions() {
353
+ if (this.buildContributionsPrepared) {
354
+ return;
355
+ }
161
356
  await this.collectPostcssPlugins();
357
+ this.buildContributionsPrepared = true;
358
+ }
359
+ /**
360
+ * Prepares build contributions if not already done and prewarms the runtime CSS cache.
361
+ */
362
+ async setup() {
363
+ await this.prepareBuildContributions();
162
364
  await this.prewarmRuntimeCssCache();
163
365
  }
164
366
  /**
@@ -172,6 +374,7 @@ class PostCssProcessorPlugin extends Processor {
172
374
  const configExtensions = ["js", "cjs", "mjs", "ts"];
173
375
  let foundConfigPath;
174
376
  let loadedPlugins;
377
+ let loadedPluginFactories;
175
378
  for (const ext of configExtensions) {
176
379
  const configPath = path.join(this.context.rootDir, `postcss.config.${ext}`);
177
380
  if (fileSystem.exists(configPath)) {
@@ -182,8 +385,14 @@ class PostCssProcessorPlugin extends Processor {
182
385
  if (foundConfigPath) {
183
386
  try {
184
387
  logger.debug(`Loading PostCSS config from: ${foundConfigPath}`);
185
- const postcssConfigModule = await import(foundConfigPath);
388
+ const postcssConfigModule = await import(
389
+ /* @vite-ignore */
390
+ foundConfigPath
391
+ );
186
392
  const postcssConfig = postcssConfigModule.default || postcssConfigModule;
393
+ if (postcssConfig && typeof postcssConfig.pluginFactories === "object" && postcssConfig.pluginFactories !== null) {
394
+ loadedPluginFactories = postcssConfig.pluginFactories;
395
+ }
187
396
  if (postcssConfig && typeof postcssConfig.plugins === "object" && postcssConfig.plugins !== null) {
188
397
  if (Array.isArray(postcssConfig.plugins)) {
189
398
  loadedPlugins = postcssConfig.plugins;
@@ -203,15 +412,26 @@ class PostCssProcessorPlugin extends Processor {
203
412
  } else {
204
413
  logger.debug("No PostCSS config file found in root directory.");
205
414
  }
206
- if (loadedPlugins) {
415
+ if (loadedPluginFactories) {
416
+ this.pluginFactories = loadedPluginFactories;
417
+ this.postcssPlugins = this.materializePluginFactories(loadedPluginFactories);
418
+ } else if (loadedPlugins) {
419
+ this.pluginFactories = void 0;
207
420
  this.postcssPlugins = loadedPlugins;
208
- } else if (this.options?.plugins) {
209
- logger.debug("Using PostCSS plugins provided in processor options.");
210
- this.postcssPlugins = Object.values(this.options.plugins);
421
+ } else if (this.options?.pluginFactories || this.options?.plugins) {
422
+ this.pluginFactories = this.options?.pluginFactories;
423
+ if (this.options?.plugins) {
424
+ logger.debug("Using PostCSS plugins provided in processor options.");
425
+ this.postcssPlugins = Object.values(this.options.plugins);
426
+ } else if (this.options?.pluginFactories) {
427
+ logger.debug("Using PostCSS plugin factories provided in processor options.");
428
+ this.postcssPlugins = this.materializePluginFactories(this.options.pluginFactories);
429
+ }
211
430
  } else {
212
431
  logger.warn(
213
432
  "No PostCSS plugins configured. Use a preset like tailwindV3Preset() or tailwindV4Preset(), provide plugins via options, or create a postcss.config file."
214
433
  );
434
+ this.pluginFactories = void 0;
215
435
  this.postcssPlugins = [];
216
436
  }
217
437
  if (!this.postcssPlugins || this.postcssPlugins.length === 0) {
@@ -226,14 +446,19 @@ class PostCssProcessorPlugin extends Processor {
226
446
  * @returns Processed CSS
227
447
  */
228
448
  async process(fileAsString, filePath) {
229
- return await PostCssProcessor.processStringOrBuffer(fileAsString, {
449
+ const input = this.options?.transformInput && filePath ? await this.options.transformInput(fileAsString, filePath) : fileAsString;
450
+ return await PostCssProcessor.processStringOrBuffer(input, {
230
451
  filePath,
231
452
  plugins: this.postcssPlugins,
232
453
  transformOutput: this.options?.transformOutput
233
454
  });
234
455
  }
235
456
  processSync(fileAsString, filePath) {
236
- return PostCssProcessor.processStringOrBufferSync(fileAsString, {
457
+ const input = this.options?.transformInput && filePath ? this.options.transformInput(fileAsString, filePath) : fileAsString;
458
+ if (input instanceof Promise) {
459
+ throw new Error("transformInput must be synchronous when used with processSync");
460
+ }
461
+ return PostCssProcessor.processStringOrBufferSync(input, {
237
462
  filePath,
238
463
  plugins: this.postcssPlugins,
239
464
  transformOutput: this.options?.transformOutput
@@ -2,5 +2,5 @@
2
2
  * PostCSS Processor Presets
3
3
  * @module @ecopages/postcss-processor/presets
4
4
  */
5
- export { tailwindV3Preset } from './tailwind-v3';
6
- export { tailwindV4Preset, type TailwindV4PresetOptions } from './tailwind-v4';
5
+ export { tailwindV3Preset } from './tailwind-v3.js';
6
+ export { tailwindV4Preset, type TailwindV4PresetOptions } from './tailwind-v4.js';
@@ -1,5 +1,5 @@
1
- import { tailwindV3Preset } from "./tailwind-v3";
2
- import { tailwindV4Preset } from "./tailwind-v4";
1
+ import { tailwindV3Preset } from "./tailwind-v3.js";
2
+ import { tailwindV4Preset } from "./tailwind-v4.js";
3
3
  export {
4
4
  tailwindV3Preset,
5
5
  tailwindV4Preset
@@ -12,6 +12,8 @@ import type { PostCssProcessorPluginConfig } from '../plugin.js';
12
12
  * Features:
13
13
  * - Uses classic Tailwind v3 plugin stack
14
14
  * - Includes postcss-import, tailwindcss/nesting, tailwindcss, autoprefixer, cssnano
15
+ * - Returns both `plugins` for immediate use and `pluginFactories` so Ecopages
16
+ * can recreate fresh Tailwind/PostCSS plugin instances on dependency-driven rebuilds
15
17
  *
16
18
  * @example
17
19
  * ```typescript
@@ -9,14 +9,17 @@ function tailwindV3Preset() {
9
9
  const autoprefixerOptions = browserslistConfig ? {} : {
10
10
  overrideBrowserslist: [">0.3%", "not ie 11", "not dead", "not op_mini all"]
11
11
  };
12
- const plugins = {
13
- "postcss-import": postcssImport(),
14
- "tailwindcss/nesting": tailwindcssNesting(),
15
- tailwindcss: tailwindcss(),
16
- autoprefixer: autoprefixer(autoprefixerOptions),
17
- cssnano: cssnano()
12
+ const pluginFactories = {
13
+ "postcss-import": () => postcssImport(),
14
+ "tailwindcss/nesting": () => tailwindcssNesting(),
15
+ tailwindcss: () => tailwindcss(),
16
+ autoprefixer: () => autoprefixer(autoprefixerOptions),
17
+ cssnano: () => cssnano()
18
18
  };
19
- return { plugins };
19
+ const plugins = Object.fromEntries(
20
+ Object.entries(pluginFactories).map(([name, factory]) => [name, factory()])
21
+ );
22
+ return { plugins, pluginFactories };
20
23
  }
21
24
  export {
22
25
  tailwindV3Preset
@@ -23,6 +23,8 @@ export interface TailwindV4PresetOptions {
23
23
  * - Uses `@tailwindcss/postcss` plugin (v4)
24
24
  * - Automatically injects `@reference` headers for `@apply` support
25
25
  * - Includes cssnano for CSS minification
26
+ * - Returns both `plugins` for immediate use and `pluginFactories` so Ecopages
27
+ * can recreate fresh Tailwind/PostCSS plugin instances on dependency-driven rebuilds
26
28
  *
27
29
  * @example
28
30
  * ```typescript
@@ -11,14 +11,24 @@ function tailwindV4Preset(options) {
11
11
  const autoprefixerOptions = browserslistConfig ? {} : {
12
12
  overrideBrowserslist: [">0.3%", "not ie 11", "not dead", "not op_mini all"]
13
13
  };
14
+ const createTailwindPlugin = () => {
15
+ return tailwindcss({ optimize: false });
16
+ };
17
+ const pluginFactories = {
18
+ "postcss-import": () => postcssImport(),
19
+ "postcss-nested": () => postcssNested(),
20
+ "@tailwindcss/postcss": createTailwindPlugin,
21
+ autoprefixer: () => autoprefixer(autoprefixerOptions),
22
+ cssnano: () => cssnano()
23
+ };
14
24
  return {
15
- plugins: {
16
- "postcss-import": postcssImport(),
17
- "postcss-nested": postcssNested(),
18
- "@tailwindcss/postcss": tailwindcss(),
19
- autoprefixer: autoprefixer(autoprefixerOptions),
20
- cssnano: cssnano()
21
- },
25
+ dependencyEntryPaths: [referencePath],
26
+ /**
27
+ * Instantiate the initial plugin list for the active processor instance.
28
+ * Fresh instances can later be recreated from `pluginFactories`.
29
+ */
30
+ plugins: Object.fromEntries(Object.entries(pluginFactories).map(([name, factory]) => [name, factory()])),
31
+ pluginFactories,
22
32
  transformInput: async (contents, filePath) => {
23
33
  const css = contents instanceof Buffer ? contents.toString("utf-8") : contents;
24
34
  const normalizedFilePath = path.resolve(filePath);
@@ -1,5 +1,5 @@
1
1
  import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
2
- import type { CssTransform } from './css-runtime-contract';
2
+ import type { CssTransform } from './css-runtime-contract.js';
3
3
  type CssLoaderOptions = {
4
4
  name: string;
5
5
  filter: RegExp;
@@ -1,4 +1,4 @@
1
- import { getFileAsBuffer } from "../postcss-processor";
1
+ import { getFileAsBuffer } from "../postcss-processor.js";
2
2
  const createCssLoaderPlugin = ({ name, filter, transform }) => ({
3
3
  name,
4
4
  setup(build) {
@@ -1,5 +1,5 @@
1
1
  import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
2
- import type { CssTransform } from './css-runtime-contract';
2
+ import type { CssTransform } from './css-runtime-contract.js';
3
3
  type BunCssLoaderOptions = {
4
4
  name: string;
5
5
  filter: RegExp;
@@ -1,4 +1,4 @@
1
- import { getFileAsBuffer } from "../postcss-processor";
1
+ import { getFileAsBuffer } from "../postcss-processor.js";
2
2
  const createBunCssLoaderPlugin = ({ name, filter, transform }) => ({
3
3
  name,
4
4
  setup(build) {
package/src/index.ts DELETED
@@ -1,2 +0,0 @@
1
- export * from './plugin';
2
- export * from './postcss-processor';