From b94d565eb6f61635babd59873cda40e845217ffb Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 23 Feb 2022 11:24:54 -0500 Subject: [PATCH] Preserve source maps for generated CSS (#7588) * Preserve source maps for `@apply` * Overwrite the source for all cloned descendants * Preserve source maps when expanding defaults * Verify that source maps are correctly generated * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 3 +- package.json | 3 +- src/lib/expandApplyAtRules.js | 10 +- src/lib/resolveDefaultsAtRules.js | 8 +- src/util/cloneNodes.js | 6 + tests/source-maps.test.js | 409 ++++++++++++++++++++++++++++++ tests/util/run.js | 19 ++ tests/util/source-maps.js | 79 ++++++ 9 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 tests/source-maps.test.js create mode 100644 tests/util/source-maps.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0056754119..95d367f31c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent nesting plugin from breaking other plugins ([#7563](https://github.com/tailwindlabs/tailwindcss/pull/7563)) - Recursively collapse adjacent rules ([#7565](https://github.com/tailwindlabs/tailwindcss/pull/7565)) - Allow default ring color to be a function ([#7587](https://github.com/tailwindlabs/tailwindcss/pull/7587)) +- Preserve source maps for generated CSS ([#7588](https://github.com/tailwindlabs/tailwindcss/pull/7588)) ## [3.0.23] - 2022-02-16 diff --git a/package-lock.json b/package-lock.json index 12ec9edf60aa..5328db0c8081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,8 @@ "jest-diff": "^27.5.1", "prettier": "^2.5.1", "prettier-plugin-tailwindcss": "^0.1.7", - "rimraf": "^3.0.0" + "rimraf": "^3.0.0", + "source-map-js": "^1.0.2" }, "engines": { "node": ">=12.13.0" diff --git a/package.json b/package.json index 53efdd76e28a..669d8e282678 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "jest-diff": "^27.5.1", "prettier": "^2.5.1", "prettier-plugin-tailwindcss": "^0.1.7", - "rimraf": "^3.0.0" + "rimraf": "^3.0.0", + "source-map-js": "^1.0.2" }, "peerDependencies": { "autoprefixer": "^10.0.2", diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js index 1bc7b8a76f35..52ee1f96a5d7 100644 --- a/src/lib/expandApplyAtRules.js +++ b/src/lib/expandApplyAtRules.js @@ -140,7 +140,7 @@ function processApply(root, context) { for (let apply of applies) { let candidates = perParentApplies.get(apply.parent) || [] - perParentApplies.set(apply.parent, candidates) + perParentApplies.set(apply.parent, [candidates, apply.source]) let [applyCandidates, important] = extractApplyCandidates(apply.params) @@ -178,7 +178,7 @@ function processApply(root, context) { } } - for (const [parent, candidates] of perParentApplies) { + for (const [parent, [candidates, atApplySource]] of perParentApplies) { let siblings = [] for (let [applyCandidate, important, rules] of candidates) { @@ -220,6 +220,12 @@ function processApply(root, context) { } let root = postcss.root({ nodes: [node.clone()] }) + + // Make sure every node in the entire tree points back at the @apply rule that generated it + root.walk((node) => { + node.source = atApplySource + }) + let canRewriteSelector = node.type !== 'atrule' || (node.type === 'atrule' && node.name !== 'keyframes') diff --git a/src/lib/resolveDefaultsAtRules.js b/src/lib/resolveDefaultsAtRules.js index 48a8378badb7..3b172f3531dc 100644 --- a/src/lib/resolveDefaultsAtRules.js +++ b/src/lib/resolveDefaultsAtRules.js @@ -120,7 +120,9 @@ export default function resolveDefaultsAtRules({ tailwindConfig }) { } for (let [, selectors] of selectorGroups) { - let universalRule = postcss.rule() + let universalRule = postcss.rule({ + source: universal.source, + }) universalRule.selectors = [...selectors] @@ -128,7 +130,9 @@ export default function resolveDefaultsAtRules({ tailwindConfig }) { universal.before(universalRule) } } else { - let universalRule = postcss.rule() + let universalRule = postcss.rule({ + source: universal.source, + }) universalRule.selectors = ['*', '::before', '::after'] diff --git a/src/util/cloneNodes.js b/src/util/cloneNodes.js index d6d40d3cd9ea..5a51bbf374b2 100644 --- a/src/util/cloneNodes.js +++ b/src/util/cloneNodes.js @@ -4,6 +4,12 @@ export default function cloneNodes(nodes, source) { if (source !== undefined) { cloned.source = source + + if ('walk' in cloned) { + cloned.walk((child) => { + child.source = source + }) + } } return cloned diff --git a/tests/source-maps.test.js b/tests/source-maps.test.js new file mode 100644 index 000000000000..c39ecda299b9 --- /dev/null +++ b/tests/source-maps.test.js @@ -0,0 +1,409 @@ +import { runWithSourceMaps as run, html, css } from './util/run' +import { parseSourceMaps } from './util/source-maps' + +it('apply generates source maps', async () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + .with-declaration { + background-color: red; + @apply h-4 w-4 bg-green-500; + } + + .with-comment { + /* sourcemap will work here too */ + @apply h-4 w-4 bg-red-500; + } + + .just-apply { + @apply h-4 w-4 bg-black; + } + ` + + let result = await run(input, config) + let { sources, annotations } = parseSourceMaps(result) + + // All CSS generated by Tailwind CSS should be annotated with source maps + // And always be able to point to the original source file + expect(sources).not.toContain('') + expect(sources.length).toBe(1) + + // It would make the tests nicer to read and write + expect(annotations).toStrictEqual([ + '2:4 -> 2:4', + '3:6-27 -> 3:6-27', + '4:6-33 -> 4:6-18', + '4:6-33 -> 5:6-17', + '4:6-33 -> 6:6-24', + '4:6-33 -> 7:6-61', + '5:4 -> 8:4', + '7:4 -> 10:4', + '8:6-39 -> 11:6-39', + '9:6-31 -> 12:6-18', + '9:6-31 -> 13:6-17', + '9:6-31 -> 14:6-24', + '9:6-31 -> 15:6-61', + '10:4 -> 16:4', + '13:6 -> 18:4', + '13:6-29 -> 19:6-18', + '13:6-29 -> 20:6-17', + '13:6-29 -> 21:6-24', + '13:6 -> 22:6', + '13:29 -> 23:0', + ]) +}) + +it('preflight + base have source maps', async () => { + let config = { + content: [], + } + + let input = css` + @tailwind base; + ` + + let result = await run(input, config) + let { sources, annotations } = parseSourceMaps(result) + + // All CSS generated by Tailwind CSS should be annotated with source maps + // And always be able to point to the original source file + expect(sources).not.toContain('') + expect(sources.length).toBe(1) + + // It would make the tests nicer to read and write + expect(annotations).toStrictEqual([ + '2:4 -> 1:0', + '2:18-4 -> 3:1-2', + '2:18 -> 6:1', + '2:4 -> 8:0', + '2:4-18 -> 11:2-32', + '2:4-18 -> 12:2-25', + '2:4-18 -> 13:2-29', + '2:4-18 -> 14:2-31', + '2:18 -> 15:0', + '2:4 -> 17:0', + '2:4-18 -> 19:2-18', + '2:18 -> 20:0', + '2:4 -> 22:0', + '2:18 -> 27:1', + '2:4 -> 29:0', + '2:4-18 -> 30:2-26', + '2:4-18 -> 31:2-40', + '2:4-18 -> 32:2-26', + '2:4-18 -> 33:2-21', + '2:4-18 -> 34:2-230', + '2:18 -> 35:0', + '2:4 -> 37:0', + '2:18 -> 40:1', + '2:4 -> 42:0', + '2:4-18 -> 43:2-19', + '2:4-18 -> 44:2-30', + '2:18 -> 45:0', + '2:4 -> 47:0', + '2:18 -> 51:1', + '2:4 -> 53:0', + '2:4-18 -> 54:2-19', + '2:4-18 -> 55:2-24', + '2:4-18 -> 56:2-31', + '2:18 -> 57:0', + '2:4 -> 59:0', + '2:18 -> 61:1', + '2:4 -> 63:0', + '2:4-18 -> 64:2-35', + '2:18 -> 65:0', + '2:4 -> 67:0', + '2:18 -> 69:1', + '2:4 -> 71:0', + '2:4-18 -> 77:2-20', + '2:4-18 -> 78:2-22', + '2:18 -> 79:0', + '2:4 -> 81:0', + '2:18 -> 83:1', + '2:4 -> 85:0', + '2:4-18 -> 86:2-16', + '2:4-18 -> 87:2-26', + '2:18 -> 88:0', + '2:4 -> 90:0', + '2:18 -> 92:1', + '2:4 -> 94:0', + '2:4-18 -> 96:2-21', + '2:18 -> 97:0', + '2:4 -> 99:0', + '2:18 -> 102:1', + '2:4 -> 104:0', + '2:4-18 -> 108:2-121', + '2:4-18 -> 109:2-24', + '2:18 -> 110:0', + '2:4 -> 112:0', + '2:18 -> 114:1', + '2:4 -> 116:0', + '2:4-18 -> 117:2-16', + '2:18 -> 118:0', + '2:4 -> 120:0', + '2:18 -> 122:1', + '2:4 -> 124:0', + '2:4-18 -> 126:2-16', + '2:4-18 -> 127:2-16', + '2:4-18 -> 128:2-20', + '2:4-18 -> 129:2-26', + '2:18 -> 130:0', + '2:4 -> 132:0', + '2:4-18 -> 133:2-17', + '2:18 -> 134:0', + '2:4 -> 136:0', + '2:4-18 -> 137:2-13', + '2:18 -> 138:0', + '2:4 -> 140:0', + '2:18 -> 144:1', + '2:4 -> 146:0', + '2:4-18 -> 147:2-24', + '2:4-18 -> 148:2-31', + '2:4-18 -> 149:2-35', + '2:18 -> 150:0', + '2:4 -> 152:0', + '2:18 -> 156:1', + '2:4 -> 158:0', + '2:4-18 -> 163:2-30', + '2:4-18 -> 164:2-25', + '2:4-18 -> 165:2-30', + '2:4-18 -> 166:2-24', + '2:4-18 -> 167:2-19', + '2:4-18 -> 168:2-20', + '2:18 -> 169:0', + '2:4 -> 171:0', + '2:18 -> 173:1', + '2:4 -> 175:0', + '2:4-18 -> 177:2-22', + '2:18 -> 178:0', + '2:4 -> 180:0', + '2:18 -> 183:1', + '2:4 -> 185:0', + '2:4-18 -> 189:2-36', + '2:4-18 -> 190:2-39', + '2:4-18 -> 191:2-32', + '2:18 -> 192:0', + '2:4 -> 194:0', + '2:18 -> 196:1', + '2:4 -> 198:0', + '2:4-18 -> 199:2-15', + '2:18 -> 200:0', + '2:4 -> 202:0', + '2:18 -> 204:1', + '2:4 -> 206:0', + '2:4-18 -> 207:2-18', + '2:18 -> 208:0', + '2:4 -> 210:0', + '2:18 -> 212:1', + '2:4 -> 214:0', + '2:4-18 -> 215:2-26', + '2:18 -> 216:0', + '2:4 -> 218:0', + '2:18 -> 220:1', + '2:4 -> 222:0', + '2:4-18 -> 224:2-14', + '2:18 -> 225:0', + '2:4 -> 227:0', + '2:18 -> 230:1', + '2:4 -> 232:0', + '2:4-18 -> 233:2-39', + '2:4-18 -> 234:2-30', + '2:18 -> 235:0', + '2:4 -> 237:0', + '2:18 -> 239:1', + '2:4 -> 241:0', + '2:4-18 -> 242:2-26', + '2:18 -> 243:0', + '2:4 -> 245:0', + '2:18 -> 248:1', + '2:4 -> 250:0', + '2:4-18 -> 251:2-36', + '2:4-18 -> 252:2-23', + '2:18 -> 253:0', + '2:4 -> 255:0', + '2:18 -> 257:1', + '2:4 -> 259:0', + '2:4-18 -> 260:2-20', + '2:18 -> 261:0', + '2:4 -> 263:0', + '2:18 -> 265:1', + '2:4 -> 267:0', + '2:4-18 -> 280:2-11', + '2:18 -> 281:0', + '2:4 -> 283:0', + '2:4-18 -> 284:2-11', + '2:4-18 -> 285:2-12', + '2:18 -> 286:0', + '2:4 -> 288:0', + '2:4-18 -> 289:2-12', + '2:18 -> 290:0', + '2:4 -> 292:0', + '2:4-18 -> 295:2-18', + '2:4-18 -> 296:2-11', + '2:4-18 -> 297:2-12', + '2:18 -> 298:0', + '2:4 -> 300:0', + '2:18 -> 302:1', + '2:4 -> 304:0', + '2:4-18 -> 305:2-18', + '2:18 -> 306:0', + '2:4 -> 308:0', + '2:18 -> 311:1', + '2:4 -> 313:0', + '2:4-18 -> 315:2-20', + '2:4-18 -> 316:2-24', + '2:18 -> 317:0', + '2:4 -> 319:0', + '2:18 -> 321:1', + '2:4 -> 323:0', + '2:4-18 -> 325:2-17', + '2:18 -> 326:0', + '2:4 -> 328:0', + '2:18 -> 330:1', + '2:4 -> 331:0', + '2:4-18 -> 332:2-17', + '2:18 -> 333:0', + '2:4 -> 335:0', + '2:18 -> 339:1', + '2:4 -> 341:0', + '2:4-18 -> 349:2-24', + '2:4-18 -> 350:2-32', + '2:18 -> 351:0', + '2:4 -> 353:0', + '2:18 -> 355:1', + '2:4 -> 357:0', + '2:4-18 -> 359:2-17', + '2:4-18 -> 360:2-14', + '2:18 -> 361:0', + '2:4 -> 363:0', + '2:18 -> 365:1', + '2:4 -> 367:0', + '2:4-18 -> 368:2-15', + '2:18 -> 369:0', + '2:4 -> 371:0', + '2:4-18 -> 372:2-21', + '2:4-18 -> 373:2-21', + '2:4-18 -> 374:2-16', + '2:4-18 -> 375:2-16', + '2:4-18 -> 376:2-16', + '2:4-18 -> 377:2-17', + '2:4-18 -> 378:2-17', + '2:4-18 -> 379:2-15', + '2:4-18 -> 380:2-15', + '2:4-18 -> 381:2-20', + '2:4-18 -> 382:2-40', + '2:4-18 -> 383:2-17', + '2:4-18 -> 384:2-22', + '2:4-18 -> 385:2-24', + '2:4-18 -> 386:2-25', + '2:4-18 -> 387:2-26', + '2:4-18 -> 388:2-20', + '2:4-18 -> 389:2-29', + '2:4-18 -> 390:2-30', + '2:4-18 -> 391:2-40', + '2:4-18 -> 392:2-36', + '2:4-18 -> 393:2-29', + '2:4-18 -> 394:2-24', + '2:4-18 -> 395:2-32', + '2:4-18 -> 396:2-14', + '2:4-18 -> 397:2-20', + '2:4-18 -> 398:2-18', + '2:4-18 -> 399:2-19', + '2:4-18 -> 400:2-20', + '2:4-18 -> 401:2-16', + '2:4-18 -> 402:2-18', + '2:4-18 -> 403:2-15', + '2:4-18 -> 404:2-21', + '2:4-18 -> 405:2-23', + '2:4-18 -> 406:2-29', + '2:4-18 -> 407:2-27', + '2:4-18 -> 408:2-28', + '2:4-18 -> 409:2-29', + '2:4-18 -> 410:2-25', + '2:4-18 -> 411:2-26', + '2:4-18 -> 412:2-27', + '2:4 -> 413:2', + '2:18 -> 414:0', + ]) +}) + +it('utilities have source maps', async () => { + let config = { + content: [{ raw: `text-red-500` }], + } + + let input = css` + @tailwind utilities; + ` + + let result = await run(input, config) + let { sources, annotations } = parseSourceMaps(result) + + // All CSS generated by Tailwind CSS should be annotated with source maps + // And always be able to point to the original source file + expect(sources).not.toContain('') + expect(sources.length).toBe(1) + + // It would make the tests nicer to read and write + expect(annotations).toStrictEqual(['2:4 -> 1:0', '2:4-23 -> 2:4-24', '2:4 -> 3:4', '2:23 -> 4:0']) +}) + +it('components have source maps', async () => { + let config = { + content: [{ raw: `container` }], + } + + let input = css` + @tailwind components; + ` + + let result = await run(input, config) + let { sources, annotations } = parseSourceMaps(result) + + // All CSS generated by Tailwind CSS should be annotated with source maps + // And always be able to point to the original source file + expect(sources).not.toContain('') + expect(sources.length).toBe(1) + + // It would make the tests nicer to read and write + expect(annotations).toStrictEqual([ + '2:4 -> 1:0', + '2:4 -> 2:4', + '2:24 -> 3:0', + '2:4 -> 4:0', + '2:4 -> 5:4', + '2:4 -> 6:8', + '2:24 -> 7:4', + '2:24 -> 8:0', + '2:4 -> 9:0', + '2:4 -> 10:4', + '2:4 -> 11:8', + '2:24 -> 12:4', + '2:24 -> 13:0', + '2:4 -> 14:0', + '2:4 -> 15:4', + '2:4 -> 16:8', + '2:24 -> 17:4', + '2:24 -> 18:0', + '2:4 -> 19:0', + '2:4 -> 20:4', + '2:4 -> 21:8', + '2:24 -> 22:4', + '2:24 -> 23:0', + '2:4 -> 24:0', + '2:4 -> 25:4', + '2:4 -> 26:8', + '2:24 -> 27:4', + '2:24 -> 28:0', + ]) +}) diff --git a/tests/util/run.js b/tests/util/run.js index 1fedc98bac92..d14b398af5b5 100644 --- a/tests/util/run.js +++ b/tests/util/run.js @@ -5,6 +5,14 @@ import tailwind from '../../src' export * from './strings' export * from './defaults' +let map = JSON.stringify({ + version: 3, + file: null, + sources: [], + names: [], + mappings: '', +}) + export function run(input, config, plugin = tailwind) { let { currentTestName } = expect.getState() @@ -12,3 +20,14 @@ export function run(input, config, plugin = tailwind) { from: `${path.resolve(__filename)}?test=${currentTestName}`, }) } + +export function runWithSourceMaps(input, config, plugin = tailwind) { + let { currentTestName } = expect.getState() + + return postcss(plugin(config)).process(input, { + from: `${path.resolve(__filename)}?test=${currentTestName}`, + map: { + prev: map, + }, + }) +} diff --git a/tests/util/source-maps.js b/tests/util/source-maps.js new file mode 100644 index 000000000000..695ba91f6af7 --- /dev/null +++ b/tests/util/source-maps.js @@ -0,0 +1,79 @@ +import { SourceMapConsumer } from 'source-map-js' + +/** + * Parse the source maps from a PostCSS result + * + * @param {import('postcss').Result} result + */ +export function parseSourceMaps(result) { + const map = result.map.toJSON() + + return { + sources: map.sources, + annotations: annotatedMappings(map), + } +} + +/** + * An string annotation that represents a source map + * + * It's not meant to be exhaustive just enough to + * verify that the source map is working and that + * lines are mapped back to the original source + * + * Including when using @apply with multiple classes + * + * @param {import('source-map-js').RawSourceMap} map + */ +function annotatedMappings(map) { + const smc = new SourceMapConsumer(map) + const annotations = {} + + smc.eachMapping((mapping) => { + let annotation = (annotations[mapping.generatedLine] = annotations[mapping.generatedLine] || { + ...mapping, + + original: { + start: [mapping.originalLine, mapping.originalColumn], + end: [mapping.originalLine, mapping.originalColumn], + }, + + generated: { + start: [mapping.generatedLine, mapping.generatedColumn], + end: [mapping.generatedLine, mapping.generatedColumn], + }, + }) + + annotation.generated.end[0] = mapping.generatedLine + annotation.generated.end[1] = mapping.generatedColumn + + annotation.original.end[0] = mapping.originalLine + annotation.original.end[1] = mapping.originalColumn + }) + + return Object.values(annotations).map((annotation) => { + return `${formatRange(annotation.original)} -> ${formatRange(annotation.generated)}` + }) +} + +/** + * @param {object} range + * @param {[number, number]} range.start + * @param {[number, number]} range.end + */ +function formatRange(range) { + if (range.start[0] === range.end[0]) { + // This range is on the same line + // and the columns are the same + if (range.start[1] === range.end[1]) { + return `${range.start[0]}:${range.start[1]}` + } + + // This range is on the same line + // but the columns are different + return `${range.start[0]}:${range.start[1]}-${range.end[1]}` + } + + // This range spans multiple lines + return `${range.start[0]}:${range.start[1]}-${range.end[0]}:${range.end[1]}` +}