happy-css-modules 0.2.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.
Files changed (154) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +124 -0
  3. package/bin/hcm.js +9 -0
  4. package/dist/cli.d.ts +6 -0
  5. package/dist/cli.js +69 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/cli.test.d.ts +1 -0
  8. package/dist/cli.test.js +34 -0
  9. package/dist/cli.test.js.map +1 -0
  10. package/dist/emitter/dts.d.ts +14 -0
  11. package/dist/emitter/dts.js +106 -0
  12. package/dist/emitter/dts.js.map +1 -0
  13. package/dist/emitter/dts.test.d.ts +1 -0
  14. package/dist/emitter/dts.test.js +205 -0
  15. package/dist/emitter/dts.test.js.map +1 -0
  16. package/dist/emitter/file-system.d.ts +6 -0
  17. package/dist/emitter/file-system.js +26 -0
  18. package/dist/emitter/file-system.js.map +1 -0
  19. package/dist/emitter/file-system.test.d.ts +1 -0
  20. package/dist/emitter/file-system.test.js +34 -0
  21. package/dist/emitter/file-system.test.js.map +1 -0
  22. package/dist/emitter/index.d.ts +34 -0
  23. package/dist/emitter/index.js +42 -0
  24. package/dist/emitter/index.js.map +1 -0
  25. package/dist/emitter/index.test.d.ts +1 -0
  26. package/dist/emitter/index.test.js +118 -0
  27. package/dist/emitter/index.test.js.map +1 -0
  28. package/dist/emitter/source-map.d.ts +9 -0
  29. package/dist/emitter/source-map.js +16 -0
  30. package/dist/emitter/source-map.js.map +1 -0
  31. package/dist/emitter/source-map.test.d.ts +1 -0
  32. package/dist/emitter/source-map.test.js +13 -0
  33. package/dist/emitter/source-map.test.js.map +1 -0
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/integration-test/go-to-definition.test.d.ts +1 -0
  38. package/dist/integration-test/go-to-definition.test.js +367 -0
  39. package/dist/integration-test/go-to-definition.test.js.map +1 -0
  40. package/dist/library/source-map/index.d.ts +8 -0
  41. package/dist/library/source-map/index.js +5 -0
  42. package/dist/library/source-map/index.js.map +1 -0
  43. package/dist/loader/index.d.ts +42 -0
  44. package/dist/loader/index.js +145 -0
  45. package/dist/loader/index.js.map +1 -0
  46. package/dist/loader/index.test.d.ts +1 -0
  47. package/dist/loader/index.test.js +290 -0
  48. package/dist/loader/index.test.js.map +1 -0
  49. package/dist/loader/postcss.d.ts +60 -0
  50. package/dist/loader/postcss.js +209 -0
  51. package/dist/loader/postcss.js.map +1 -0
  52. package/dist/loader/postcss.test.d.ts +1 -0
  53. package/dist/loader/postcss.test.js +236 -0
  54. package/dist/loader/postcss.test.js.map +1 -0
  55. package/dist/resolver/index.d.ts +20 -0
  56. package/dist/resolver/index.js +39 -0
  57. package/dist/resolver/index.js.map +1 -0
  58. package/dist/resolver/index.test.d.ts +1 -0
  59. package/dist/resolver/index.test.js +16 -0
  60. package/dist/resolver/index.test.js.map +1 -0
  61. package/dist/resolver/node-resolver.d.ts +2 -0
  62. package/dist/resolver/node-resolver.js +6 -0
  63. package/dist/resolver/node-resolver.js.map +1 -0
  64. package/dist/resolver/node-resolver.test.d.ts +1 -0
  65. package/dist/resolver/node-resolver.test.js +25 -0
  66. package/dist/resolver/node-resolver.test.js.map +1 -0
  67. package/dist/resolver/relative-resolver.d.ts +2 -0
  68. package/dist/resolver/relative-resolver.js +5 -0
  69. package/dist/resolver/relative-resolver.js.map +1 -0
  70. package/dist/resolver/relative-resolver.test.d.ts +1 -0
  71. package/dist/resolver/relative-resolver.test.js +12 -0
  72. package/dist/resolver/relative-resolver.test.js.map +1 -0
  73. package/dist/resolver/webpack-resolver.d.ts +2 -0
  74. package/dist/resolver/webpack-resolver.js +69 -0
  75. package/dist/resolver/webpack-resolver.js.map +1 -0
  76. package/dist/resolver/webpack-resolver.test.d.ts +1 -0
  77. package/dist/resolver/webpack-resolver.test.js +26 -0
  78. package/dist/resolver/webpack-resolver.test.js.map +1 -0
  79. package/dist/runner.d.ts +33 -0
  80. package/dist/runner.js +75 -0
  81. package/dist/runner.js.map +1 -0
  82. package/dist/runner.test.d.ts +1 -0
  83. package/dist/runner.test.js +113 -0
  84. package/dist/runner.test.js.map +1 -0
  85. package/dist/test/jest/resolver.cjs +30 -0
  86. package/dist/test/jest/resolver.cjs.map +1 -0
  87. package/dist/test/jest/resolver.d.cts +30 -0
  88. package/dist/test/tsserver.d.ts +27 -0
  89. package/dist/test/tsserver.js +104 -0
  90. package/dist/test/tsserver.js.map +1 -0
  91. package/dist/test/util.d.ts +29 -0
  92. package/dist/test/util.js +78 -0
  93. package/dist/test/util.js.map +1 -0
  94. package/dist/transformer/index.d.ts +23 -0
  95. package/dist/transformer/index.js +19 -0
  96. package/dist/transformer/index.js.map +1 -0
  97. package/dist/transformer/less-transformer.d.ts +2 -0
  98. package/dist/transformer/less-transformer.js +43 -0
  99. package/dist/transformer/less-transformer.js.map +1 -0
  100. package/dist/transformer/less-transformer.test.d.ts +1 -0
  101. package/dist/transformer/less-transformer.test.js +126 -0
  102. package/dist/transformer/less-transformer.test.js.map +1 -0
  103. package/dist/transformer/scss-transformer.d.ts +2 -0
  104. package/dist/transformer/scss-transformer.js +84 -0
  105. package/dist/transformer/scss-transformer.js.map +1 -0
  106. package/dist/transformer/scss-transformer.test.d.ts +1 -0
  107. package/dist/transformer/scss-transformer.test.js +132 -0
  108. package/dist/transformer/scss-transformer.test.js.map +1 -0
  109. package/dist/util.d.ts +19 -0
  110. package/dist/util.js +52 -0
  111. package/dist/util.js.map +1 -0
  112. package/dist/util.test.d.ts +1 -0
  113. package/dist/util.test.js +75 -0
  114. package/dist/util.test.js.map +1 -0
  115. package/package.json +106 -0
  116. package/src/__snapshots__/runner.test.ts.snap +34 -0
  117. package/src/cli.test.ts +38 -0
  118. package/src/cli.ts +70 -0
  119. package/src/emitter/dts.test.ts +266 -0
  120. package/src/emitter/dts.ts +134 -0
  121. package/src/emitter/file-system.test.ts +36 -0
  122. package/src/emitter/file-system.ts +24 -0
  123. package/src/emitter/index.test.ts +130 -0
  124. package/src/emitter/index.ts +92 -0
  125. package/src/emitter/source-map.test.ts +20 -0
  126. package/src/emitter/source-map.ts +17 -0
  127. package/src/index.ts +3 -0
  128. package/src/integration-test/go-to-definition.test.ts +371 -0
  129. package/src/library/README.md +3 -0
  130. package/src/library/source-map/index.ts +26 -0
  131. package/src/loader/index.test.ts +306 -0
  132. package/src/loader/index.ts +199 -0
  133. package/src/loader/postcss.test.ts +336 -0
  134. package/src/loader/postcss.ts +239 -0
  135. package/src/resolver/index.test.ts +17 -0
  136. package/src/resolver/index.ts +48 -0
  137. package/src/resolver/node-resolver.test.ts +26 -0
  138. package/src/resolver/node-resolver.ts +7 -0
  139. package/src/resolver/relative-resolver.test.ts +13 -0
  140. package/src/resolver/relative-resolver.ts +6 -0
  141. package/src/resolver/webpack-resolver.test.ts +33 -0
  142. package/src/resolver/webpack-resolver.ts +71 -0
  143. package/src/runner.test.ts +122 -0
  144. package/src/runner.ts +105 -0
  145. package/src/test/jest/resolver.cjs +30 -0
  146. package/src/test/tsserver.ts +176 -0
  147. package/src/test/util.ts +100 -0
  148. package/src/transformer/index.ts +44 -0
  149. package/src/transformer/less-transformer.test.ts +135 -0
  150. package/src/transformer/less-transformer.ts +55 -0
  151. package/src/transformer/scss-transformer.test.ts +142 -0
  152. package/src/transformer/scss-transformer.ts +94 -0
  153. package/src/util.test.ts +89 -0
  154. package/src/util.ts +67 -0
@@ -0,0 +1,306 @@
1
+ import fs, { readFile, writeFile } from 'fs/promises';
2
+ import { jest } from '@jest/globals';
3
+ import dedent from 'dedent';
4
+ import { createFixtures, FIXTURE_DIR_PATH, getFixturePath } from '../test/util.js';
5
+ import { sleepSync } from '../util.js';
6
+
7
+ const readFileSpy = jest.spyOn(fs, 'readFile');
8
+ // In ESM, for some reason, we need to explicitly mock module
9
+ jest.unstable_mockModule('fs/promises', () => ({
10
+ ...fs, // Inherit native functions (e.g., fs.stat)
11
+ readFile: readFileSpy,
12
+ }));
13
+
14
+ // After the mock of fs/promises is complete, . /index.js after the mock of fs/promises is complete.
15
+ // ref: https://www.coolcomputerclub.com/posts/jest-hoist-await/
16
+ const { Loader } = await import('./index.js');
17
+ // NOTE: ../test/util.js depends on . /index.js, so it must also be imported dynamically...
18
+
19
+ const loader = new Loader();
20
+
21
+ afterEach(() => {
22
+ readFileSpy.mockClear();
23
+ });
24
+
25
+ test('basic', async () => {
26
+ createFixtures({
27
+ '/test/1.css': dedent`
28
+ .a {}
29
+ .b {}
30
+ `,
31
+ });
32
+ const result = await loader.load(getFixturePath('/test/1.css'));
33
+ expect(result).toMatchInlineSnapshot(`
34
+ {
35
+ dependencies: [],
36
+ tokens: [
37
+ {
38
+ name: "a",
39
+ originalLocations: [
40
+ { filePath: "<fixtures>/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
41
+ ],
42
+ },
43
+ {
44
+ name: "b",
45
+ originalLocations: [
46
+ { filePath: "<fixtures>/test/1.css", start: { line: 2, column: 1 }, end: { line: 2, column: 2 } },
47
+ ],
48
+ },
49
+ ],
50
+ }
51
+ `);
52
+ });
53
+
54
+ test('tracks other files when `@import` is present', async () => {
55
+ createFixtures({
56
+ '/test/1.css': dedent`
57
+ @import './2.css';
58
+ @import '3.css';
59
+ @import '${getFixturePath('/test/4.css')}';
60
+ `,
61
+ '/test/2.css': dedent`
62
+ .a {}
63
+ `,
64
+ '/test/3.css': dedent`
65
+ .b {}
66
+ `,
67
+ '/test/4.css': dedent`
68
+ .c {}
69
+ `,
70
+ });
71
+ const result = await loader.load(getFixturePath('/test/1.css'));
72
+ expect(result).toMatchInlineSnapshot(`
73
+ {
74
+ dependencies: ["<fixtures>/test/2.css", "<fixtures>/test/3.css", "<fixtures>/test/4.css"],
75
+ tokens: [
76
+ {
77
+ name: "a",
78
+ originalLocations: [
79
+ { filePath: "<fixtures>/test/2.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
80
+ ],
81
+ },
82
+ {
83
+ name: "b",
84
+ originalLocations: [
85
+ { filePath: "<fixtures>/test/3.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
86
+ ],
87
+ },
88
+ {
89
+ name: "c",
90
+ originalLocations: [
91
+ { filePath: "<fixtures>/test/4.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
92
+ ],
93
+ },
94
+ ],
95
+ }
96
+ `);
97
+ });
98
+
99
+ test('tracks other files when `composes` is present', async () => {
100
+ createFixtures({
101
+ '/test/1.css': dedent`
102
+ .a {
103
+ composes: b from './2.css';
104
+ composes: c d from './3.css';
105
+ composes: e from '${getFixturePath('/test/4.css')}';
106
+ }
107
+ `,
108
+ '/test/2.css': dedent`
109
+ .b {}
110
+ `,
111
+ '/test/3.css': dedent`
112
+ .c {}
113
+ .d {}
114
+ `,
115
+ '/test/4.css': dedent`
116
+ .e {}
117
+ `,
118
+ });
119
+ const result = await loader.load(getFixturePath('/test/1.css'));
120
+ expect(result).toMatchInlineSnapshot(`
121
+ {
122
+ dependencies: ["<fixtures>/test/2.css", "<fixtures>/test/3.css", "<fixtures>/test/4.css"],
123
+ tokens: [
124
+ {
125
+ name: "a",
126
+ originalLocations: [
127
+ { filePath: "<fixtures>/test/1.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
128
+ ],
129
+ },
130
+ {
131
+ name: "b",
132
+ originalLocations: [
133
+ { filePath: "<fixtures>/test/2.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
134
+ ],
135
+ },
136
+ {
137
+ name: "c",
138
+ originalLocations: [
139
+ { filePath: "<fixtures>/test/3.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
140
+ ],
141
+ },
142
+ {
143
+ name: "d",
144
+ originalLocations: [
145
+ { filePath: "<fixtures>/test/3.css", start: { line: 2, column: 1 }, end: { line: 2, column: 2 } },
146
+ ],
147
+ },
148
+ {
149
+ name: "e",
150
+ originalLocations: [
151
+ { filePath: "<fixtures>/test/4.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
152
+ ],
153
+ },
154
+ ],
155
+ }
156
+ `);
157
+ });
158
+
159
+ test('normalizes tokens', async () => {
160
+ createFixtures({
161
+ '/test/1.css': dedent`
162
+ /* duplicate import */
163
+ @import './2.css';
164
+ @import '2.css';
165
+ .a {
166
+ /* duplicate composes */
167
+ composes: c from './3.css';
168
+ composes: c from '3.css';
169
+ composes: c c from './3.css';
170
+ /* duplicate import and composes */
171
+ composes: b from './2.css';
172
+ }
173
+ .a {} /* duplicate class selector */
174
+ `,
175
+ '/test/2.css': dedent`
176
+ .a {} /* class selector that duplicates the import source */
177
+ .b {}
178
+ `,
179
+ '/test/3.css': dedent`
180
+ .c {}
181
+ `,
182
+ });
183
+ const result = await loader.load(getFixturePath('/test/1.css'));
184
+ expect(result).toMatchInlineSnapshot(`
185
+ {
186
+ dependencies: ["<fixtures>/test/2.css", "<fixtures>/test/3.css"],
187
+ tokens: [
188
+ {
189
+ name: "a",
190
+ originalLocations: [
191
+ { filePath: "<fixtures>/test/2.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
192
+ { filePath: "<fixtures>/test/1.css", start: { line: 4, column: 1 }, end: { line: 4, column: 2 } },
193
+ { filePath: "<fixtures>/test/1.css", start: { line: 12, column: 1 }, end: { line: 12, column: 2 } },
194
+ ],
195
+ },
196
+ {
197
+ name: "b",
198
+ originalLocations: [
199
+ { filePath: "<fixtures>/test/2.css", start: { line: 2, column: 1 }, end: { line: 2, column: 2 } },
200
+ ],
201
+ },
202
+ {
203
+ name: "c",
204
+ originalLocations: [
205
+ { filePath: "<fixtures>/test/3.css", start: { line: 1, column: 1 }, end: { line: 1, column: 2 } },
206
+ ],
207
+ },
208
+ ],
209
+ }
210
+ `);
211
+ });
212
+
213
+ test.failing('returns the result from the cache when the file has not been modified', async () => {
214
+ createFixtures({
215
+ '/test/1.css': dedent`
216
+ @import './2.css';
217
+ @import './2.css';
218
+ .a {
219
+ composes: b from './2.css';
220
+ composes: c from './3.css';
221
+ composes: d from './3.css';
222
+ }
223
+ `,
224
+ '/test/2.css': dedent`
225
+ .b {}
226
+ `,
227
+ '/test/3.css': dedent`
228
+ .c {}
229
+ .d {}
230
+ `,
231
+ });
232
+ await loader.load(getFixturePath('/test/1.css'));
233
+ expect(readFileSpy).toHaveBeenCalledTimes(3);
234
+ expect(readFileSpy).toHaveBeenNthCalledWith(1, '/test/1.css', 'utf-8');
235
+ expect(readFileSpy).toHaveBeenNthCalledWith(2, '/test/2.css', 'utf-8');
236
+ expect(readFileSpy).toHaveBeenNthCalledWith(3, '/test/3.css', 'utf-8');
237
+ readFileSpy.mockClear();
238
+
239
+ // update `/test/2.css`
240
+ sleepSync(1); // wait for the file system to update the mtime
241
+ await writeFile(getFixturePath('/test/2.css'), await readFile(getFixturePath('/test/2.css'), 'utf-8'));
242
+
243
+ // `3.css` is not updated, so the cache is used. Therefore, `readFile` is not called.
244
+ await loader.load(getFixturePath('/test/3.css'));
245
+ expect(readFileSpy).toHaveBeenCalledTimes(0);
246
+
247
+ // `1.css` is not updated, but dependencies are updated, so the cache is used. Therefore, `readFile` is called.
248
+ await loader.load(getFixturePath('/test/1.css'));
249
+ expect(readFileSpy).toHaveBeenCalledTimes(2);
250
+ expect(readFileSpy).toHaveBeenNthCalledWith(1, '/test/1.css', 'utf-8');
251
+ expect(readFileSpy).toHaveBeenNthCalledWith(2, '/test/2.css', 'utf-8');
252
+
253
+ // ``2.css` is updated, but the cache is already available because it was updated in the previous step. Therefore, `readFile` is not called.
254
+ await loader.load(getFixturePath('/test/2.css'));
255
+ expect(readFileSpy).toHaveBeenCalledTimes(2);
256
+ });
257
+
258
+ test('ignores the composition of non-existent tokens', async () => {
259
+ // In css-loader and postcss-modules, compositions of non-existent tokens are simply ignored.
260
+ // Therefore, happy-css-modules follows suit.
261
+ // It may be preferable to warn rather than ignore, but for now, we will focus on compatibility.
262
+ // ref: https://github.com/css-modules/css-modules/issues/356
263
+ createFixtures({
264
+ '/test/1.css': dedent`
265
+ .a {
266
+ composes: b c from './2.css';
267
+ }
268
+ `,
269
+ '/test/2.css': dedent`
270
+ .b {}
271
+ `,
272
+ });
273
+ const result = await loader.load(getFixturePath('/test/1.css'));
274
+ expect(result.tokens.map((t) => t.name)).toStrictEqual(['a', 'b']);
275
+ });
276
+
277
+ test('throws error the composition of non-existent file', async () => {
278
+ // In postcss-modules, compositions of non-existent file are causes an error.
279
+ // Therefore, happy-css-modules follows suit.
280
+ createFixtures({
281
+ '/test/1.css': dedent`
282
+ .a {
283
+ composes: a from './2.css';
284
+ }
285
+ `,
286
+ });
287
+ await expect(async () => {
288
+ await loader.load(getFixturePath('/test/1.css')).catch((e) => {
289
+ e.message = e.message.replace(FIXTURE_DIR_PATH, '<fixtures>');
290
+ throw e;
291
+ });
292
+ }).rejects.toThrowError(`Could not resolve './2.css' in '<fixtures>/test/1.css'`);
293
+ });
294
+
295
+ test.todo('supports sourcemap file and inline sourcemap');
296
+
297
+ test('ignores http(s) protocol file', async () => {
298
+ createFixtures({
299
+ '/test/1.css': dedent`
300
+ @import 'http://example.com/path/1.css';
301
+ @import 'https://example.com/path/1.css';
302
+ `,
303
+ });
304
+ const result = await loader.load(getFixturePath('/test/1.css'));
305
+ expect(result.dependencies).toStrictEqual([]);
306
+ });
@@ -0,0 +1,199 @@
1
+ import { readFile, stat } from 'fs/promises';
2
+ import postcss from 'postcss';
3
+ import type { Resolver } from '../resolver/index.js';
4
+ import { createDefaultResolver } from '../resolver/index.js';
5
+ import { createDefaultTransformer, type Transformer } from '../transformer/index.js';
6
+ import { unique, uniqueBy } from '../util.js';
7
+ import {
8
+ getOriginalLocation,
9
+ generateLocalTokenNames,
10
+ parseAtImport,
11
+ type Location,
12
+ parseComposesDeclarationWithFromUrl,
13
+ collectNodes,
14
+ } from './postcss.js';
15
+
16
+ export { collectNodes, type Location } from './postcss.js';
17
+
18
+ /**
19
+ * Whether the specifier should be ignored.
20
+ * For example, specifiers starting with `http://` or `https://` should be ignored.
21
+ */
22
+ function isIgnoredSpecifier(specifier: string): boolean {
23
+ return specifier.startsWith('http://') || specifier.startsWith('https://');
24
+ }
25
+
26
+ /** The exported token. */
27
+ export type Token = {
28
+ /** The token name. */
29
+ name: string;
30
+ /** The original locations of the token in the source file. */
31
+ originalLocations: Location[];
32
+ };
33
+
34
+ type CacheEntry = {
35
+ mtime: number; // TODO: `--cache-strategy` option will allow you to switch between `content` and `metadata` modes.
36
+ result: LoadResult;
37
+ };
38
+
39
+ /** The result of `Loader#load`. */
40
+ export type LoadResult = {
41
+ /** The path of the file imported from the source file with `@import` or `composes`. */
42
+ dependencies: string[];
43
+ /** The tokens exported by the source file. */
44
+ tokens: Token[];
45
+ };
46
+
47
+ function normalizeTokens(tokens: Token[]): Token[] {
48
+ const tokenNameToOriginalLocations = new Map<string, Location[]>();
49
+ for (const token of tokens) {
50
+ tokenNameToOriginalLocations.set(
51
+ token.name,
52
+ uniqueBy([...(tokenNameToOriginalLocations.get(token.name) ?? []), ...token.originalLocations], (location) =>
53
+ JSON.stringify(location),
54
+ ),
55
+ );
56
+ }
57
+ return Array.from(tokenNameToOriginalLocations.entries()).map(([name, originalLocations]) => ({
58
+ name,
59
+ originalLocations,
60
+ }));
61
+ }
62
+
63
+ export type LoaderOptions = {
64
+ /** The function to transform source code. */
65
+ transformer?: Transformer | undefined;
66
+ /** The function to resolve the path of the imported file. */
67
+ resolver?: Resolver | undefined;
68
+ };
69
+
70
+ /** The resolver that throws an exception if resolving fails. */
71
+ export type StrictlyResolver = (...args: Parameters<Resolver>) => Promise<string>;
72
+
73
+ /** This class collects information on tokens exported from CSS Modules files. */
74
+ export class Loader {
75
+ private readonly cache: Map<string, CacheEntry> = new Map();
76
+ private readonly transformer: Transformer | undefined;
77
+ private readonly resolver: StrictlyResolver;
78
+
79
+ constructor(options?: LoaderOptions) {
80
+ this.transformer = options?.transformer ?? createDefaultTransformer();
81
+ this.resolver = async (specifier, resolverOptions) => {
82
+ const resolver = options?.resolver ?? createDefaultResolver();
83
+ const resolved = await resolver(specifier, resolverOptions);
84
+ if (resolved === false) throw new Error(`Could not resolve '${specifier}' in '${resolverOptions.request}'.`);
85
+ return resolved;
86
+ };
87
+ }
88
+
89
+ /** Returns `true` if the cache is outdated. */
90
+ private async isCacheOutdated(filePath: string): Promise<boolean> {
91
+ const entry = this.cache.get(filePath);
92
+ if (!entry) return true;
93
+ const mtime = (await stat(filePath)).mtime.getTime();
94
+ if (entry.mtime !== mtime) return true;
95
+
96
+ const { dependencies } = entry.result;
97
+ for (const dependency of dependencies) {
98
+ const entry = this.cache.get(dependency);
99
+ if (!entry) return true;
100
+ const mtime = (await stat(dependency)).mtime.getTime();
101
+ if (entry.mtime !== mtime) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Reads the source file and returns the code.
108
+ * If transformer is specified, the code is transformed before returning.
109
+ */
110
+ private async readCSS(
111
+ filePath: string,
112
+ ): Promise<
113
+ | { css: string; map: undefined; dependencies: string[] }
114
+ | { css: string; map: string | object | undefined; dependencies: string[] }
115
+ > {
116
+ const css = await readFile(filePath, 'utf-8');
117
+ if (!this.transformer) return { css, map: undefined, dependencies: [] };
118
+ const result = await this.transformer(css, { from: filePath, resolver: this.resolver });
119
+ if (result === false) return { css, map: undefined, dependencies: [] };
120
+ return {
121
+ css: result.css,
122
+ map: result.map,
123
+ dependencies: result.dependencies.map((dep) => {
124
+ if (typeof dep === 'string') return dep;
125
+ if (dep.protocol !== 'file:') throw new Error('Unsupported protocol: ' + dep.protocol);
126
+ return dep.pathname;
127
+ }),
128
+ };
129
+ }
130
+
131
+ /** Returns information about the tokens exported from the CSS Modules file. */
132
+ async load(filePath: string): Promise<LoadResult> {
133
+ // NOTE: Loader does not support concurrent calls.
134
+ // TODO: Throw an error if called concurrently.
135
+ if (!(await this.isCacheOutdated(filePath))) {
136
+ const cacheEntry = this.cache.get(filePath)!;
137
+ return cacheEntry.result;
138
+ }
139
+
140
+ const mtime = (await stat(filePath)).mtime.getTime();
141
+
142
+ const { css, map, dependencies } = await this.readCSS(filePath);
143
+
144
+ const ast = postcss.parse(css, { from: filePath, map: map ? { inline: false, prev: map } : { inline: false } });
145
+
146
+ // Get the local tokens exported by the source file.
147
+ // The tokens are fetched using `postcss-modules` plugin.
148
+ const localTokenNames = await generateLocalTokenNames(ast);
149
+
150
+ const tokens: Token[] = [];
151
+
152
+ const { atImports, classSelectors, composesDeclarations } = collectNodes(ast);
153
+
154
+ // Load imported sheets recursively.
155
+ for (const atImport of atImports) {
156
+ const importedSheetPath = parseAtImport(atImport);
157
+ if (!importedSheetPath) continue;
158
+ if (isIgnoredSpecifier(importedSheetPath)) continue;
159
+ const from = await this.resolver(importedSheetPath, { request: filePath });
160
+ const result = await this.load(from);
161
+ const externalTokens = result.tokens;
162
+ dependencies.push(from);
163
+ tokens.push(...externalTokens);
164
+ }
165
+
166
+ // Traverse the source file to find a class selector that matches the local token.
167
+ for (const { rule, classSelector } of classSelectors) {
168
+ // Consider a class selector to be the origin of a token if it matches a token fetched by postcss-modules.
169
+ // NOTE: This method has false positives. However, it works as expected in many cases.
170
+ if (!localTokenNames.includes(classSelector.value)) continue;
171
+
172
+ const originalLocation = getOriginalLocation(rule, classSelector);
173
+
174
+ tokens.push({
175
+ name: classSelector.value,
176
+ originalLocations: [originalLocation],
177
+ });
178
+ }
179
+
180
+ // Load imported tokens by the names recursively.
181
+ for (const composesDeclaration of composesDeclarations) {
182
+ const declarationDetail = parseComposesDeclarationWithFromUrl(composesDeclaration);
183
+ if (!declarationDetail) continue;
184
+ if (isIgnoredSpecifier(declarationDetail.from)) continue;
185
+ const from = await this.resolver(declarationDetail.from, { request: filePath });
186
+ const result = await this.load(from);
187
+ const externalTokens = result.tokens.filter((token) => declarationDetail.tokenNames.includes(token.name));
188
+ dependencies.push(from);
189
+ tokens.push(...externalTokens);
190
+ }
191
+
192
+ const result: LoadResult = {
193
+ dependencies: unique(dependencies).filter((dep) => dep !== filePath),
194
+ tokens: normalizeTokens(tokens),
195
+ };
196
+ this.cache.set(filePath, { mtime, result });
197
+ return result;
198
+ }
199
+ }