diff --git a/src/jit/lib/rebootWatcher.js b/src/jit/lib/rebootWatcher.js deleted file mode 100644 index 06bc5b246..000000000 --- a/src/jit/lib/rebootWatcher.js +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-check - -import chokidar from 'chokidar' -import crypto from 'crypto' -import fs from 'fs' -import os from 'os' -import path from 'path' -import log from '../../util/log' -import { env } from './sharedState' - -// Earmarks a directory for our touch files. -// If the directory already exists we delete any existing touch files, -// invalidating any caches associated with them. -let touchDir = - env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch') - -if (!env.TAILWIND_DISABLE_TOUCH) { - if (fs.existsSync(touchDir)) { - for (let file of fs.readdirSync(touchDir)) { - try { - fs.unlinkSync(path.join(touchDir, file)) - } catch (_err) {} - } - } else { - fs.mkdirSync(touchDir, { recursive: true }) - } -} - -// This is used to trigger rebuilds. Just updating the timestamp -// is significantly faster than actually writing to the file (10x). - -function touch(filename) { - let time = new Date() - - try { - fs.utimesSync(filename, time, time) - } catch (err) { - fs.closeSync(fs.openSync(filename, 'w')) - } -} - -export function rebootWatcher(context) { - if (context.touchFile === null) { - context.touchFile = generateTouchFileName() - touch(context.touchFile) - } - - if (env.TAILWIND_MODE === 'build') { - return - } - - if ( - env.TAILWIND_MODE === 'watch' || - (env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development') - ) { - Promise.resolve(context.watcher ? context.watcher.close() : null).then(() => { - log.info([ - 'Tailwind CSS is watching for changes...', - 'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds', - ]) - - context.watcher = chokidar.watch([...context.candidateFiles, ...context.configDependencies], { - ignoreInitial: true, - }) - - context.watcher.on('add', (file) => { - let changedFile = path.resolve('.', file) - let content = fs.readFileSync(changedFile, 'utf8') - let extension = path.extname(changedFile).slice(1) - context.changedContent.push({ content, extension }) - touch(context.touchFile) - }) - - context.watcher.on('change', (file) => { - // If it was a config dependency, touch the config file to trigger a new context. - // This is not really that clean of a solution but it's the fastest, because we - // can do a very quick check on each build to see if the config has changed instead - // of having to get all of the module dependencies and check every timestamp each - // time. - if (context.configDependencies.has(file)) { - for (let dependency of context.configDependencies) { - delete require.cache[require.resolve(dependency)] - } - touch(context.configPath) - } else { - let changedFile = path.resolve('.', file) - let content = fs.readFileSync(changedFile, 'utf8') - let extension = path.extname(changedFile).slice(1) - context.changedContent.push({ content, extension }) - touch(context.touchFile) - } - }) - - context.watcher.on('unlink', (file) => { - // Touch the config file if any of the dependencies are deleted. - if (context.configDependencies.has(file)) { - for (let dependency of context.configDependencies) { - delete require.cache[require.resolve(dependency)] - } - touch(context.configPath) - } - }) - }) - } -} - -function generateTouchFileName() { - let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' - let randomChars = '' - let randomCharsLength = 12 - let bytes = null - - try { - bytes = crypto.randomBytes(randomCharsLength) - } catch (_error) { - bytes = crypto.pseudoRandomBytes(randomCharsLength) - } - - for (let i = 0; i < randomCharsLength; i++) { - randomChars += chars[bytes[i] % chars.length] - } - - return path.join(touchDir, `touch-${process.pid}-${randomChars}`) -} diff --git a/src/jit/lib/setupContextUtils.js b/src/jit/lib/setupContextUtils.js index a6e0e2abb..bdc148161 100644 --- a/src/jit/lib/setupContextUtils.js +++ b/src/jit/lib/setupContextUtils.js @@ -1,10 +1,8 @@ import fs from 'fs' import url from 'url' -import path from 'path' import postcss from 'postcss' import dlv from 'dlv' import selectorParser from 'postcss-selector-parser' -import normalizePath from 'normalize-path' import transformThemeValue from '../../util/transformThemeValue' import parseObjectStyles from '../../util/parseObjectStyles' @@ -304,7 +302,15 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs } } -function trackModified(files, context) { +let fileModifiedMapCache = new WeakMap() +export function getFileModifiedMap(context) { + if (!fileModifiedMapCache.has(context)) { + fileModifiedMapCache.set(context, new Map()) + } + return fileModifiedMapCache.get(context) +} + +function trackModified(files, fileModifiedMap) { let changed = false for (let file of files) { @@ -314,11 +320,11 @@ function trackModified(files, context) { let pathname = parsed.href.replace(parsed.hash, '').replace(parsed.search, '') let newModified = fs.statSync(decodeURIComponent(pathname)).mtimeMs - if (!context.fileModifiedMap.has(file) || newModified > context.fileModifiedMap.get(file)) { + if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) { changed = true } - context.fileModifiedMap.set(file, newModified) + fileModifiedMap.set(file, newModified) } return changed @@ -477,48 +483,18 @@ let contextMap = sharedState.contextMap let configContextMap = sharedState.configContextMap let contextSourcesMap = sharedState.contextSourcesMap -function cleanupContext(context) { - if (context.watcher) { - context.watcher.close() - } -} - export function getContext( - configOrPath, tailwindDirectives, - registerDependency, root, result, - getTailwindConfig + tailwindConfig, + userConfigPath, + tailwindConfigHash, + contextDependencies ) { let sourcePath = result.opts.from - let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = - getTailwindConfig(configOrPath) let isConfigFile = userConfigPath !== null - let contextDependencies = new Set(configDependencies) - - // If there are no @tailwind rules, we don't consider this CSS file or it's dependencies - // to be dependencies of the context. Can reuse the context even if they change. - // We may want to think about `@layer` being part of this trigger too, but it's tough - // because it's impossible for a layer in one file to end up in the actual @tailwind rule - // in another file since independent sources are effectively isolated. - if (tailwindDirectives.size > 0) { - // Add current css file as a context dependencies. - contextDependencies.add(sourcePath) - - // Add all css @import dependencies as context dependencies. - for (let message of result.messages) { - if (message.type === 'dependency') { - contextDependencies.add(message.file) - } - } - } - - for (let file of configDependencies) { - registerDependency(file) - } - env.DEBUG && console.log('Source path:', sourcePath) let existingContext @@ -536,7 +512,10 @@ export function getContext( // If there's already a context in the cache and we don't need to // reset the context, return the cached context. if (existingContext) { - let contextDependenciesChanged = trackModified([...contextDependencies], existingContext) + let contextDependenciesChanged = trackModified( + [...contextDependencies], + getFileModifiedMap(existingContext) + ) if (!contextDependenciesChanged) { return [existingContext, false] } @@ -553,49 +532,30 @@ export function getContext( contextSourcesMap.get(oldContext).delete(sourcePath) if (contextSourcesMap.get(oldContext).size === 0) { contextSourcesMap.delete(oldContext) - cleanupContext(oldContext) + for (let disposable of oldContext.disposables.splice(0)) { + disposable(oldContext) + } } } } env.DEBUG && console.log('Setting up new context...') - let purgeContent = Array.isArray(tailwindConfig.purge) - ? tailwindConfig.purge - : tailwindConfig.purge.content - let context = { - watcher: null, - touchFile: null, - configPath: userConfigPath, - configDependencies: new Set(), - candidateFiles: purgeContent - .filter((item) => typeof item === 'string') - .map((purgePath) => - normalizePath( - path.resolve( - userConfigPath === null ? process.cwd() : path.dirname(userConfigPath), - purgePath - ) - ) - ), - fileModifiedMap: new Map(), - // --- - ruleCache: new Set(), // Hit - classCache: new Map(), // Hit - applyClassCache: new Map(), // Hit - notClassCache: new Set(), // Hit - postCssNodeCache: new Map(), // Hit - candidateRuleMap: new Map(), // Hit - tailwindConfig: tailwindConfig, // Hit - changedContent: purgeContent // Hit - .filter((item) => typeof item.raw === 'string') - .map(({ raw, extension }) => ({ content: raw, extension })), - variantMap: new Map(), // Hit - stylesheetCache: null, // Hit + disposables: [], + ruleCache: new Set(), + classCache: new Map(), + applyClassCache: new Map(), + notClassCache: new Set(), + postCssNodeCache: new Map(), + candidateRuleMap: new Map(), + tailwindConfig, + changedContent: [], + variantMap: new Map(), + stylesheetCache: null, } - trackModified([...contextDependencies], context) + trackModified([...contextDependencies], getFileModifiedMap(context)) // --- diff --git a/src/jit/lib/setupTrackingContext.js b/src/jit/lib/setupTrackingContext.js index 9e0d0434c..0c1330307 100644 --- a/src/jit/lib/setupTrackingContext.js +++ b/src/jit/lib/setupTrackingContext.js @@ -5,6 +5,7 @@ import fastGlob from 'fast-glob' import isGlob from 'is-glob' import globParent from 'glob-parent' import LRU from 'quick-lru' +import normalizePath from 'normalize-path' import hash from '../../util/hashConfig' import getModuleDependencies from '../../lib/getModuleDependencies' @@ -15,10 +16,29 @@ import resolveConfigPath from '../../util/resolveConfigPath' import { env } from './sharedState' -import { getContext } from './setupContextUtils' +import { getContext, getFileModifiedMap } from './setupContextUtils' let configPathCache = new LRU({ maxSize: 100 }) +let candidateFilesCache = new WeakMap() + +function getCandidateFiles(context, userConfigPath, tailwindConfig) { + if (candidateFilesCache.has(context)) { + return candidateFilesCache.get(context) + } + + let purgeContent = Array.isArray(tailwindConfig.purge) + ? tailwindConfig.purge + : tailwindConfig.purge.content + + let basePath = userConfigPath === null ? process.cwd() : path.dirname(userConfigPath) + let candidateFiles = purgeContent + .filter((item) => typeof item === 'string') + .map((purgePath) => normalizePath(path.resolve(basePath, purgePath))) + + return candidateFilesCache.set(context, candidateFiles).get(context) +} + // Get the config object based on a path function getTailwindConfig(configOrPath) { let userConfigPath = resolveConfigPath(configOrPath) @@ -62,19 +82,34 @@ function getTailwindConfig(configOrPath) { return [newConfig, null, hash(newConfig), []] } -function resolveChangedFiles(context) { +function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { + let changedContent = ( + Array.isArray(context.tailwindConfig.purge) + ? context.tailwindConfig.purge + : context.tailwindConfig.purge.content + ) + .filter((item) => typeof item.raw === 'string') + .map(({ raw, extension }) => ({ content: raw, extension })) + + for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) { + let content = fs.readFileSync(changedFile, 'utf8') + let extension = path.extname(changedFile).slice(1) + changedContent.push({ content, extension }) + } + return changedContent +} + +function resolveChangedFiles(candidateFiles, fileModifiedMap) { let changedFiles = new Set() env.DEBUG && console.time('Finding changed files') - let files = fastGlob.sync(context.candidateFiles) + let files = fastGlob.sync(candidateFiles) for (let file of files) { - let prevModified = context.fileModifiedMap.has(file) - ? context.fileModifiedMap.get(file) - : -Infinity + let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity let modified = fs.statSync(file).mtimeMs if (modified > prevModified) { changedFiles.add(file) - context.fileModifiedMap.set(file, modified) + fileModifiedMap.set(file, modified) } } env.DEBUG && console.timeEnd('Finding changed files') @@ -88,14 +123,10 @@ function resolveChangedFiles(context) { // plugins) then return it export default function setupTrackingContext(configOrPath, tailwindDirectives, registerDependency) { return (result, root) => { - let [context] = getContext( - configOrPath, - tailwindDirectives, - registerDependency, - root, - result, - getTailwindConfig - ) + let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = + getTailwindConfig(configOrPath) + + let contextDependencies = new Set(configDependencies) // If there are no @tailwind rules, we don't consider this CSS file or it's dependencies // to be dependencies of the context. Can reuse the context even if they change. @@ -103,8 +134,39 @@ export default function setupTrackingContext(configOrPath, tailwindDirectives, r // because it's impossible for a layer in one file to end up in the actual @tailwind rule // in another file since independent sources are effectively isolated. if (tailwindDirectives.size > 0) { + // Add current css file as a context dependencies. + contextDependencies.add(result.opts.from) + + // Add all css @import dependencies as context dependencies. + for (let message of result.messages) { + if (message.type === 'dependency') { + contextDependencies.add(message.file) + } + } + } + + let [context] = getContext( + tailwindDirectives, + root, + result, + tailwindConfig, + userConfigPath, + tailwindConfigHash, + contextDependencies + ) + + let candidateFiles = getCandidateFiles(context, userConfigPath, tailwindConfig) + + // If there are no @tailwind rules, we don't consider this CSS file or it's dependencies + // to be dependencies of the context. Can reuse the context even if they change. + // We may want to think about `@layer` being part of this trigger too, but it's tough + // because it's impossible for a layer in one file to end up in the actual @tailwind rule + // in another file since independent sources are effectively isolated. + if (tailwindDirectives.size > 0) { + let fileModifiedMap = getFileModifiedMap(context) + // Add template paths as postcss dependencies. - for (let maybeGlob of context.candidateFiles) { + for (let maybeGlob of candidateFiles) { if (isGlob(maybeGlob)) { // rollup-plugin-postcss does not support dir-dependency messages // but directories can be watched in the same way as files @@ -117,13 +179,15 @@ export default function setupTrackingContext(configOrPath, tailwindDirectives, r } } - for (let changedFile of resolveChangedFiles(context)) { - let content = fs.readFileSync(changedFile, 'utf8') - let extension = path.extname(changedFile).slice(1) - context.changedContent.push({ content, extension }) + for (let changedContent of resolvedChangedContent(context, candidateFiles, fileModifiedMap)) { + context.changedContent.push(changedContent) } } + for (let file of configDependencies) { + registerDependency(file) + } + return context } } diff --git a/src/jit/lib/setupWatchingContext.js b/src/jit/lib/setupWatchingContext.js index fab618dab..23566decc 100644 --- a/src/jit/lib/setupWatchingContext.js +++ b/src/jit/lib/setupWatchingContext.js @@ -1,21 +1,212 @@ import fs from 'fs' import path from 'path' +import crypto from 'crypto' +import os from 'os' +import chokidar from 'chokidar' import fastGlob from 'fast-glob' import LRU from 'quick-lru' +import normalizePath from 'normalize-path' import hash from '../../util/hashConfig' +import log from '../../util/log' import getModuleDependencies from '../../lib/getModuleDependencies' - import resolveConfig from '../../../resolveConfig' - import resolveConfigPath from '../../util/resolveConfigPath' - -import { rebootWatcher } from './rebootWatcher' import { getContext } from './setupContextUtils' +import { env } from './sharedState' + +// Earmarks a directory for our touch files. +// If the directory already exists we delete any existing touch files, +// invalidating any caches associated with them. +let touchDir = + env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch') + +if (!env.TAILWIND_DISABLE_TOUCH) { + if (fs.existsSync(touchDir)) { + for (let file of fs.readdirSync(touchDir)) { + try { + fs.unlinkSync(path.join(touchDir, file)) + } catch (_err) {} + } + } else { + fs.mkdirSync(touchDir, { recursive: true }) + } +} + +// This is used to trigger rebuilds. Just updating the timestamp +// is significantly faster than actually writing to the file (10x). + +function touch(filename) { + let time = new Date() + + try { + fs.utimesSync(filename, time, time) + } catch (err) { + fs.closeSync(fs.openSync(filename, 'w')) + } +} + +let watchers = new WeakMap() + +function getWatcher(context) { + if (watchers.has(context)) { + return watchers.get(context) + } + + return null +} + +function setWatcher(context, watcher) { + return watchers.set(context, watcher) +} + +let touchFiles = new WeakMap() + +function getTouchFile(context) { + if (touchFiles.has(context)) { + return touchFiles.get(context) + } + + return null +} + +function setTouchFile(context, touchFile) { + return touchFiles.set(context, touchFile) +} + +let configPaths = new WeakMap() + +function getConfigPath(context, configOrPath) { + if (!configPaths.has(context)) { + configPaths.set(context, resolveConfigPath(configOrPath)) + } + + return configPaths.get(context) +} + +function rebootWatcher(context, configPath, configDependencies, candidateFiles) { + let touchFile = getTouchFile(context) + + if (touchFile === null) { + touchFile = generateTouchFileName() + setTouchFile(context, touchFile) + touch(touchFile) + } + + if (env.TAILWIND_MODE === 'build') { + return + } + + if ( + env.TAILWIND_MODE === 'watch' || + (env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development') + ) { + let watcher = getWatcher(context) + + Promise.resolve(watcher ? watcher.close() : null).then(() => { + log.info([ + 'Tailwind CSS is watching for changes...', + 'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds', + ]) + + watcher = chokidar.watch([...candidateFiles, ...configDependencies], { + ignoreInitial: true, + }) + + setWatcher(context, watcher) + + watcher.on('add', (file) => { + let changedFile = path.resolve('.', file) + let content = fs.readFileSync(changedFile, 'utf8') + let extension = path.extname(changedFile).slice(1) + context.changedContent.push({ content, extension }) + touch(touchFile) + }) + + watcher.on('change', (file) => { + // If it was a config dependency, touch the config file to trigger a new context. + // This is not really that clean of a solution but it's the fastest, because we + // can do a very quick check on each build to see if the config has changed instead + // of having to get all of the module dependencies and check every timestamp each + // time. + if (configDependencies.has(file)) { + for (let dependency of configDependencies) { + delete require.cache[require.resolve(dependency)] + } + touch(configPath) + } else { + let changedFile = path.resolve('.', file) + let content = fs.readFileSync(changedFile, 'utf8') + let extension = path.extname(changedFile).slice(1) + context.changedContent.push({ content, extension }) + touch(touchFile) + } + }) + + watcher.on('unlink', (file) => { + // Touch the config file if any of the dependencies are deleted. + if (configDependencies.has(file)) { + for (let dependency of configDependencies) { + delete require.cache[require.resolve(dependency)] + } + touch(configPath) + } + }) + }) + } +} + +function generateTouchFileName() { + let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + let randomChars = '' + let randomCharsLength = 12 + let bytes = null + + try { + bytes = crypto.randomBytes(randomCharsLength) + } catch (_error) { + bytes = crypto.pseudoRandomBytes(randomCharsLength) + } + + for (let i = 0; i < randomCharsLength; i++) { + randomChars += chars[bytes[i] % chars.length] + } + + return path.join(touchDir, `touch-${process.pid}-${randomChars}`) +} let configPathCache = new LRU({ maxSize: 100 }) +let configDependenciesCache = new WeakMap() + +function getConfigDependencies(context) { + if (!configDependenciesCache.has(context)) { + configDependenciesCache.set(context, new Set()) + } + + return configDependenciesCache.get(context) +} + +let candidateFilesCache = new WeakMap() + +function getCandidateFiles(context, userConfigPath, tailwindConfig) { + if (candidateFilesCache.has(context)) { + return candidateFilesCache.get(context) + } + + let purgeContent = Array.isArray(tailwindConfig.purge) + ? tailwindConfig.purge + : tailwindConfig.purge.content + + let basePath = userConfigPath === null ? process.cwd() : path.dirname(userConfigPath) + let candidateFiles = purgeContent + .filter((item) => typeof item === 'string') + .map((purgePath) => normalizePath(path.resolve(basePath, purgePath))) + + return candidateFilesCache.set(context, candidateFiles).get(context) +} + // Get the config object based on a path function getTailwindConfig(configOrPath) { let userConfigPath = resolveConfigPath(configOrPath) @@ -46,18 +237,37 @@ function getTailwindConfig(configOrPath) { return [newConfig, null, hash(newConfig), [userConfigPath]] } -function resolveChangedFiles(context) { +function resolvedChangedContent(context, candidateFiles) { + let changedContent = ( + Array.isArray(context.tailwindConfig.purge) + ? context.tailwindConfig.purge + : context.tailwindConfig.purge.content + ) + .filter((item) => typeof item.raw === 'string') + .map(({ raw, extension }) => ({ content: raw, extension })) + + for (let changedFile of resolveChangedFiles(context, candidateFiles)) { + let content = fs.readFileSync(changedFile, 'utf8') + let extension = path.extname(changedFile).slice(1) + changedContent.push({ content, extension }) + } + return changedContent +} + +let scannedContentCache = new WeakMap() + +function resolveChangedFiles(context, candidateFiles) { let changedFiles = new Set() // If we're not set up and watching files ourselves, we need to do // the work of grabbing all of the template files for candidate // detection. - if (!context.scannedContent) { - let files = fastGlob.sync(context.candidateFiles) + if (!scannedContentCache.has(context)) { + let files = fastGlob.sync(candidateFiles) for (let file of files) { changedFiles.add(file) } - context.scannedContent = true + scannedContentCache.set(context, true) } return changedFiles @@ -70,40 +280,78 @@ function resolveChangedFiles(context) { // plugins) then return it export default function setupWatchingContext(configOrPath, tailwindDirectives, registerDependency) { return (result, root) => { - let [context, newContext] = getContext( - configOrPath, - tailwindDirectives, - registerDependency, - root, - result, - getTailwindConfig - ) + let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = + getTailwindConfig(configOrPath) - if (context.configPath !== null) { - for (let dependency of getModuleDependencies(context.configPath)) { - if (dependency.file === context.configPath) { - continue + let contextDependencies = new Set(configDependencies) + + // If there are no @tailwind rules, we don't consider this CSS file or it's dependencies + // to be dependencies of the context. Can reuse the context even if they change. + // We may want to think about `@layer` being part of this trigger too, but it's tough + // because it's impossible for a layer in one file to end up in the actual @tailwind rule + // in another file since independent sources are effectively isolated. + if (tailwindDirectives.size > 0) { + // Add current css file as a context dependencies. + contextDependencies.add(result.opts.from) + + // Add all css @import dependencies as context dependencies. + for (let message of result.messages) { + if (message.type === 'dependency') { + contextDependencies.add(message.file) } - - context.configDependencies.add(dependency.file) } } - if (newContext) { - rebootWatcher(context) + let [context, isNewContext] = getContext( + tailwindDirectives, + root, + result, + tailwindConfig, + userConfigPath, + tailwindConfigHash, + contextDependencies + ) + + let candidateFiles = getCandidateFiles(context, userConfigPath, tailwindConfig) + let contextConfigDependencies = getConfigDependencies(context) + + for (let file of configDependencies) { + registerDependency(file) + } + + context.disposables.push((oldContext) => { + let watcher = getWatcher(oldContext) + if (watcher !== null) { + watcher.close() + } + }) + + let configPath = getConfigPath(context, configOrPath) + + if (configPath !== null) { + for (let dependency of getModuleDependencies(configPath)) { + if (dependency.file === configPath) { + continue + } + + contextConfigDependencies.add(dependency.file) + } + } + + if (isNewContext) { + rebootWatcher(context, configPath, contextConfigDependencies, candidateFiles) } // Register our temp file as a dependency — we write to this file // to trigger rebuilds. - if (context.touchFile) { - registerDependency(context.touchFile) + let touchFile = getTouchFile(context) + if (touchFile) { + registerDependency(touchFile) } if (tailwindDirectives.size > 0) { - for (let changedFile of resolveChangedFiles(context)) { - let content = fs.readFileSync(changedFile, 'utf8') - let extension = path.extname(changedFile).slice(1) - context.changedContent.push({ content, extension }) + for (let changedContent of resolvedChangedContent(context, candidateFiles)) { + context.changedContent.push(changedContent) } }