diff --git a/lib/compile.js b/lib/compile.js index 30f921a..34cb627 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -3,6 +3,7 @@ const url = require("url"); const sass = require("sass"); +const depMap = require("./dependency-map"); const sourceMapStore = require("./source-map-store"); const getLoadPathsFixedSassOptions = require("./get-load-paths-fixed-sass-options"); @@ -46,6 +47,17 @@ const compile = async function(inputContent, inputPath, sassOptions, config, pos this.addDependencies(inputPath, loadedUrls); + // `depMap` is used to determine which revision hashes should be invalidated when + // a Sass/SCSS file is updated. + // In the following code, `loadedPaths` includes the dependent Sass/SCSS file path + // (`dependant`) during the compilation of `inputContent`. + // By calling `depMap.update()` with `dependant` and `loadedPaths`, dependencies + // become self-referential. This means that `dependantsOf()` for an updated file + // returns not only files that depend on it but also the updated file itself. + let dependant = path.normalize(inputPath); + let loadedPaths = loadedUrls.map(loadedUrl => path.relative('.', url.fileURLToPath(loadedUrl))); + depMap.update(dependant, loadedPaths); + return css; }; diff --git a/lib/eleventy-sass.js b/lib/eleventy-sass.js index da0ffe7..f2081ea 100644 --- a/lib/eleventy-sass.js +++ b/lib/eleventy-sass.js @@ -7,6 +7,7 @@ try { } catch { } const parseOptions = require("./parse-options"); +const depMap = require("./dependency-map"); const compile = require("./compile"); const sourceMapStore = require("./source-map-store"); const hasPlugin = require("./has-plugin"); @@ -273,6 +274,23 @@ const eleventySass = function(eleventyConfig, userOptions = {}) { eleventyConfig.on("eleventy.before", () => { compileCache.clear(); }); + + if (rev) { + eleventyConfig.on("eleventy.beforeWatch", (queue) => { + for (let inputPath of queue) { + let last5Chars = inputPath.substring(inputPath.length - 5); + if (last5Chars !== ".sass" && last5Chars !== ".scss") + continue; + // `dependants` have `normalizedPath` as an element in the following code. + // See the comment for `depMap.update()` in `compile.js`. + let normalizedPath = path.normalize(inputPath); + let dependants = depMap.dependantsOf(normalizedPath); + dependants.forEach(dependant => pluginRev.deleteRevHash(dependant)); + debugDev("Invalidated the revision hashes for %O because of the update of %O", + dependants, normalizedPath); + } + }); + } } module.exports = eleventySass; diff --git a/test/integration/fixtures/eleventy-project-default/config-for-watcher-with-rev.js b/test/integration/fixtures/eleventy-project-default/config-for-watcher-with-rev.js new file mode 100644 index 0000000..16c5487 --- /dev/null +++ b/test/integration/fixtures/eleventy-project-default/config-for-watcher-with-rev.js @@ -0,0 +1,11 @@ +const sass = require("../../../.."); +const pluginRev = require("eleventy-plugin-rev"); + +console.log(`Eleventy PID: ${ process.pid }`); + +module.exports = function(eleventyConfig) { + eleventyConfig.addPlugin(pluginRev); + eleventyConfig.addPlugin(sass, { + rev: true + }); +}; diff --git a/test/integration/watcher-with-rev-dependency-file-update.js b/test/integration/watcher-with-rev-dependency-file-update.js new file mode 100644 index 0000000..c9f2058 --- /dev/null +++ b/test/integration/watcher-with-rev-dependency-file-update.js @@ -0,0 +1,113 @@ +if (parseInt(process.version.match(/^v(\d+)/)[1]) < 16) { + const test = require("ava"); + test("tests don't support node version < 16", async t => { + t.pass(); + }); + return; +} + +const { spawn } = require("child_process"); +const path = require("path"); +const { promises: fs } = require("fs"); +const { setTimeout } = require("timers/promises"); +const { createHash } = require("crypto"); + +const test = require("ava"); +const Semaphore = require("@debonet/es6semaphore"); + +const createProject = require("./_create-project-default"); +let dir; +let proc; +let pid; + +const headerCssContent = "header{background-color:pink}"; +const headerRevHash = createHash("sha256").update(headerCssContent).digest("hex").slice(0, 8); + +const updatedHeaderCssContent = "header{background-color:red}"; +const updatedHeaderRevHash = createHash("sha256").update(updatedHeaderCssContent).digest("hex").slice(0, 8); + +const styleCssContent = "header{background-color:pink}body{background-color:red}"; +const styleRevHash = createHash("sha256").update(styleCssContent).digest("hex").slice(0, 8); + +const updatedStyleCssContent = "header{background-color:red}body{background-color:red}"; +const updatedStyleRevHash = createHash("sha256").update(updatedStyleCssContent).digest("hex").slice(0, 8); + +test.after.always("cleanup child process", t => { + if (proc && proc.exitCode === null) { + pid ? process.kill(pid, "SIGINT") : proc.kill(); + } +}); + +test.before(async t => { + let sem = new Semaphore(1); + await sem.wait(); + dir = createProject("watcher-dependency-file-update"); + proc = spawn("npx", ["@11ty/eleventy", "--config=config-for-watcher-with-rev.js", "--watch"], { cwd: dir, shell: true, timeout: 20000 }); + proc.on("exit", (code, signal) => { + if (process.platform === "darwin") + pid = undefined; + sem.signal(); + }); + proc.stdout.on("data", function(data) { + let str = data.toString(); + + let match = str.match(/^Eleventy PID: (\d+)/); + if (match) { + pid = parseInt(match[1]); + } + + if (str.trim() === "[11ty] Watching…") { + sem.signal(); + } + }); + await sem.wait(); + await setTimeout(300); + + + let stylesheetsDir = path.join(dir, "_site", "stylesheets"); + let csses = await fs.readdir(stylesheetsDir); + t.deepEqual(csses, [`header-${ headerRevHash }.css`, `style-${ styleRevHash }.css`]); + + + let headerSCSS = path.join(dir, "stylesheets", "header.scss"); + fs.writeFile(headerSCSS, `header { + background-color: red; + }`); + + await sem.wait(); + await setTimeout(300); + + if (pid && process.kill(pid, "SIGINT")) + pid = undefined; + + await sem.wait(); +}); + +test("write CSS files with correct revision hashes", async t => { + let stylesheetsDir = path.join(dir, "_site", "stylesheets"); + let csses = await fs.readdir(stylesheetsDir); + t.is(csses.length, 4); + t.true(csses.includes(`header-${ headerRevHash }.css`)); + t.true(csses.includes(`header-${ updatedHeaderRevHash }.css`)); + t.true(csses.includes(`style-${ styleRevHash }.css`)); + t.true(csses.includes(`style-${ updatedStyleRevHash }.css`)); +}); + + +test("watcher works", async t => { + let headerPath = path.join(dir, "_site", "stylesheets", `header-${ headerRevHash }.css`); + let headerCss = await fs.readFile(headerPath, { encoding: "utf8" }); + t.is(headerCss, headerCssContent); + + let updatedHeaderPath = path.join(dir, "_site", "stylesheets", `header-${ updatedHeaderRevHash }.css`); + let updatedHeaderCss = await fs.readFile(updatedHeaderPath, { encoding: "utf8" }); + t.is(updatedHeaderCss, updatedHeaderCssContent); + + let stylePath = path.join(dir, "_site", "stylesheets", `style-${ styleRevHash }.css`); + let styleCss = await fs.readFile(stylePath, { encoding: "utf8" }); + t.is(styleCss, styleCssContent); + + let updatedStylePath = path.join(dir, "_site", "stylesheets", `style-${ updatedStyleRevHash }.css`); + let updatedStyleCss = await fs.readFile(updatedStylePath, { encoding: "utf8" }); + t.is(updatedStyleCss, updatedStyleCssContent); +}); diff --git a/test/integration/watcher-with-rev.js b/test/integration/watcher-with-rev.js new file mode 100644 index 0000000..16baf7c --- /dev/null +++ b/test/integration/watcher-with-rev.js @@ -0,0 +1,104 @@ +if (parseInt(process.version.match(/^v(\d+)/)[1]) < 16) { + const test = require("ava"); + test("tests don't support node version < 16", async t => { + t.pass(); + }); + return; +} + +const { spawn } = require("child_process"); +const path = require("path"); +const { promises: fs } = require("fs"); +const { setTimeout } = require("timers/promises"); +const { createHash } = require("crypto"); + +const test = require("ava"); +const Semaphore = require("@debonet/es6semaphore"); + +const createProject = require("./_create-project-default"); +let dir; +let proc; +let pid; + +const headerCssContent = "header{background-color:pink}"; +const headerRevHash = createHash("sha256").update(headerCssContent).digest("hex").slice(0, 8); + +const styleCssContent = "header{background-color:pink}body{background-color:red}"; +const styleRevHash = createHash("sha256").update(styleCssContent).digest("hex").slice(0, 8); + +const updatedStyleCssContent = "header{background-color:pink}body{background-color:red;color:blue}"; +const updatedStyleRevHash = createHash("sha256").update(updatedStyleCssContent).digest("hex").slice(0, 8); + +test.after.always("cleanup child process", t => { + if (proc && proc.exitCode === null) { + pid ? process.kill(pid, "SIGINT") : proc.kill(); + } +}); + +test.before(async t => { + let sem = new Semaphore(1); + await sem.wait(); + dir = createProject("watcher-with-rev"); + proc = spawn("npx", ["@11ty/eleventy", "--config=config-for-watcher-with-rev.js", "--watch"], { cwd: dir, shell: true, timeout: 20000 }); + proc.on("exit", (code, signal) => { + console.debug("exit"); + if (process.platform === "darwin") + pid = undefined; + sem.signal(); + }); + proc.stdout.on("data", function(data) { + let str = data.toString(); + + let match = str.match(/^Eleventy PID: (\d+)/); + if (match) { + pid = parseInt(match[1]); + } + + if (str.trim() === "[11ty] Watching…") + sem.signal(); + }); + await sem.wait(); + await setTimeout(300); + + + let stylesheetsDir = path.join(dir, "_site", "stylesheets"); + let csses = await fs.readdir(stylesheetsDir); + t.deepEqual(csses, [`header-${ headerRevHash }.css`, `style-${ styleRevHash }.css`]); + + + let styleSCSS = path.join(dir, "stylesheets", "style.scss"); + fs.writeFile(styleSCSS, `@use "colors"; + @use "header"; + + body { + background-color: colors.$background; + color: blue; + }`); + + await sem.wait(); + await setTimeout(300); + + if (pid && process.kill(pid, "SIGINT")) + pid = undefined; + + await sem.wait(); +}); + +test("write CSS files with correct revision hashes", async t => { + let stylesheetsDir = path.join(dir, "_site", "stylesheets"); + let csses = await fs.readdir(stylesheetsDir); + t.is(csses.length, 3); + t.true(csses.includes(`header-${ headerRevHash }.css`)); + t.true(csses.includes(`style-${ styleRevHash }.css`)); + t.true(csses.includes(`style-${ updatedStyleRevHash }.css`)); +}); + +test("watcher works", async t => { + let stylePath = path.join(dir, "_site", "stylesheets", `style-${ styleRevHash }.css`); + let styleCss = await fs.readFile(stylePath, { encoding: "utf8" }); + t.is(styleCss, styleCssContent); + + let updatedStylePath = path.join(dir, "_site", "stylesheets", `style-${ updatedStyleRevHash }.css`); + let updatedStyleCss = await fs.readFile(updatedStylePath, { encoding: "utf8" }); + t.is(updatedStyleCss, updatedStyleCssContent); +});