diff --git a/index.js b/index.js index f4b464c..2293e23 100644 --- a/index.js +++ b/index.js @@ -16,11 +16,15 @@ const { * publishDir: string to path * } */ + const nextOnNetlify = (options = {}) => { const functionsPath = options.functionsDir || NETLIFY_FUNCTIONS_PATH; const publishPath = options.publishDir || NETLIFY_PUBLISH_PATH; - prepareFolders({ functionsPath, publishPath }); + const trackNextOnNetlifyFiles = prepareFolders({ + functionsPath, + publishPath, + }); copyPublicFiles(publishPath); @@ -33,6 +37,8 @@ const nextOnNetlify = (options = {}) => { setupRedirects(publishPath); setupHeaders(publishPath); + + trackNextOnNetlifyFiles(); }; module.exports = nextOnNetlify; diff --git a/lib/helpers/handleFileTracking.js b/lib/helpers/handleFileTracking.js new file mode 100644 index 0000000..736d9f5 --- /dev/null +++ b/lib/helpers/handleFileTracking.js @@ -0,0 +1,80 @@ +const { join } = require("path"); +const { + existsSync, + readdirSync, + readFileSync, + writeFileSync, + removeSync, +} = require("fs-extra"); +const findCacheDir = require("find-cache-dir"); +const { NETLIFY_PUBLISH_PATH, NETLIFY_FUNCTIONS_PATH } = require("../config"); + +const TRACKING_FILE_SEPARATOR = "---"; + +// Clean configured publish and functions folders and track next-on-netlify files +// for future cleans +const handleFileTracking = ({ functionsPath, publishPath }) => { + const isConfiguredFunctionsDir = functionsPath !== NETLIFY_FUNCTIONS_PATH; + const isConfiguredPublishDir = publishPath !== NETLIFY_PUBLISH_PATH; + + const cacheDir = findCacheDir({ name: "next-on-netlify", create: true }); + const trackingFilePath = join(cacheDir, ".nonfiletracking"); + + if (existsSync(trackingFilePath)) { + const trackingFile = readFileSync(trackingFilePath, "utf8"); + const [trackedFunctions, trackedPublish] = trackingFile.split( + TRACKING_FILE_SEPARATOR + ); + + const cleanConfiguredFiles = (trackedFiles) => { + trackedFiles.forEach((file) => { + const filePath = join(publishPath, file); + if (file !== "" && existsSync(filePath)) { + removeSync(filePath); + } + }); + }; + + if (isConfiguredPublishDir) { + cleanConfiguredFiles(trackedPublish.split("\n")); + } + if (isConfiguredFunctionsDir) { + cleanConfiguredFiles(trackedFunctions.split("\n")); + } + } + + const functionsBeforeRun = existsSync(functionsPath) + ? readdirSync(functionsPath) + : []; + const publishBeforeRun = existsSync(publishPath) + ? readdirSync(publishPath) + : []; + + // this callback will run at the end of nextOnNetlify() + const trackNewFiles = () => { + const functionsAfterRun = isConfiguredFunctionsDir + ? readdirSync(functionsPath) + : functionsBeforeRun; + const publishAfterRun = isConfiguredPublishDir + ? readdirSync(publishPath) + : publishBeforeRun; + const getDifference = (before, after) => + after.filter((filePath) => !before.includes(filePath)); + const newFunctionsFiles = getDifference( + functionsBeforeRun, + functionsAfterRun + ); + const newPublishFiles = getDifference(publishBeforeRun, publishAfterRun); + + const allTrackedFiles = [ + ...newFunctionsFiles, + TRACKING_FILE_SEPARATOR, + ...newPublishFiles, + ]; + writeFileSync(trackingFilePath, allTrackedFiles.join("\n")); + }; + + return trackNewFiles; +}; + +module.exports = handleFileTracking; diff --git a/lib/steps/prepareFolders.js b/lib/steps/prepareFolders.js index af497ac..cb65598 100644 --- a/lib/steps/prepareFolders.js +++ b/lib/steps/prepareFolders.js @@ -1,26 +1,37 @@ +const { join } = require("path"); const { emptyDirSync } = require("fs-extra"); +const findCacheDir = require("find-cache-dir"); const { logTitle, log } = require("../helpers/logger"); const { NETLIFY_PUBLISH_PATH, NETLIFY_FUNCTIONS_PATH } = require("../config"); +const handleFileTracking = require("../helpers/handleFileTracking"); -// Empty existing publish and functions folders +// Clean existing publish and functions folders const prepareFolders = ({ functionsPath, publishPath }) => { logTitle("🚀 Next on Netlify 🚀"); - if (functionsPath === NETLIFY_FUNCTIONS_PATH) { + const isNotConfiguredFunctionsDir = functionsPath === NETLIFY_FUNCTIONS_PATH; + const isNotConfiguredPublishDir = publishPath === NETLIFY_PUBLISH_PATH; + + if (isNotConfiguredFunctionsDir) { log(" ", "Functions directory: ", functionsPath); } - if (publishPath === NETLIFY_PUBLISH_PATH) { + if (isNotConfiguredPublishDir) { log(" ", "Publish directory: ", publishPath); } - if ( - functionsPath === NETLIFY_FUNCTIONS_PATH || - publishPath === NETLIFY_PUBLISH_PATH - ) { + if (isNotConfiguredFunctionsDir || isNotConfiguredPublishDir) { log(" ", "Make sure these are set in your netlify.toml file."); } - if (publishPath === NETLIFY_PUBLISH_PATH) emptyDirSync(publishPath); - if (functionsPath === NETLIFY_FUNCTIONS_PATH) emptyDirSync(functionsPath); + // We can empty these dirs knowing there will only be stale NoN-generated files inside + if (isNotConfiguredPublishDir) { + emptyDirSync(publishPath); + } + if (isNotConfiguredFunctionsDir) { + emptyDirSync(functionsPath); + } + + // This returns a function that runs as the last step of nextOnNetlify() + return handleFileTracking({ functionsPath, publishPath }); }; module.exports = prepareFolders; diff --git a/tests/configurableDirs.test.js b/tests/configurableDirs.test.js new file mode 100644 index 0000000..aa122a7 --- /dev/null +++ b/tests/configurableDirs.test.js @@ -0,0 +1,147 @@ +// Test next-on-netlify when config is set from a function in next.config.js +// See: https://github.com/netlify/next-on-netlify/issues/25 + +const { parse, join } = require("path"); +const { existsSync, readdirSync, readFileSync } = require("fs-extra"); +const buildNextApp = require("./helpers/buildNextApp"); + +// The name of this test file (without extension) +const FILENAME = parse(__filename).name; + +// The directory which will be used for testing. +// We simulate a NextJS app within that directory, with pages, and a +// package.json file. +const PROJECT_PATH = join(__dirname, "builds", FILENAME); +const FUNCTIONS_DIR = "my-functions"; +const PUBLISH_DIR = "my-publish"; + +// Capture the output to verify successful build +let buildOutput; + +beforeAll( + async () => { + runOutput = await buildNextApp() + .forTest(__filename) + .withPages("pages") + .withNextConfig("next.config.js") + .withPackageJson("package.json") + .withCustomFunctions("my-functions") + .runWithRequire({ functionsDir: FUNCTIONS_DIR, publishDir: PUBLISH_DIR }); + }, + // time out after 180 seconds + 180 * 1000 +); + +describe("next-on-netlify", () => { + const functionsDir = join(PROJECT_PATH, FUNCTIONS_DIR); + + test("builds successfully", () => { + expect(runOutput).toMatch("Built successfully!"); + }); + + test("copies custom Netlify Function to configured functions directory", () => { + expect(existsSync(join(functionsDir, "someTestFunction.js"))).toBe(true); + }); + + test("creates a Netlify Function for each SSR page", () => { + expect(existsSync(join(functionsDir, "next_index", "next_index.js"))).toBe( + true + ); + expect( + existsSync(join(functionsDir, "next_shows_id", "next_shows_id.js")) + ).toBe(true); + expect( + existsSync( + join(functionsDir, "next_shows_params", "next_shows_params.js") + ) + ).toBe(true); + expect( + existsSync( + join( + functionsDir, + "next_getServerSideProps_static", + "next_getServerSideProps_static.js" + ) + ) + ).toBe(true); + expect( + existsSync( + join( + functionsDir, + "next_getServerSideProps_id", + "next_getServerSideProps_id.js" + ) + ) + ).toBe(true); + }); + + test("copies static pages to output directory", () => { + const OUTPUT_PATH = join(PROJECT_PATH, PUBLISH_DIR); + + expect(existsSync(join(OUTPUT_PATH, "static.html"))).toBe(true); + expect(existsSync(join(OUTPUT_PATH, "static/[id].html"))).toBe(true); + }); + + test("copies static assets to out_publish/_next/ directory", () => { + const dirs = readdirSync( + join(PROJECT_PATH, PUBLISH_DIR, "_next", "static") + ); + + expect(dirs.length).toBe(2); + expect(dirs).toContain("chunks"); + }); +}); + +describe("clean up of NoN files", () => { + test("creates a .nonfiletracking to audit NoN-specific files between builds", () => { + const cacheDir = join(PROJECT_PATH, "/node_modules/.cache/next-on-netlify"); + const dirs = readdirSync(cacheDir); + expect(dirs[0]).toEqual(".nonfiletracking"); + }); + + test(".nonfiletracking contains NoN-specific files", () => { + const cacheDir = join(PROJECT_PATH, "/node_modules/.cache/next-on-netlify"); + const fileList = readFileSync(join(cacheDir, ".nonfiletracking"), "utf8"); + // had to test equality this way because of windows :) + const isSameList = (arr1, arr2) => + arr1.reduce((isSame, func) => { + if (arr2.includes(func)) { + isSame = true; + } else { + isSame = false; + } + return isSame; + }, true); + const nextFunctions = [ + "next_api_shows_id", + "next_api_shows_params", + "next_api_static", + "next_getServerSideProps_all_slug", + "next_getServerSideProps_id", + "next_getServerSideProps_static", + "next_getStaticProps_id", + "next_getStaticProps_static", + "next_getStaticProps_withFallback_id", + "next_getStaticProps_withFallback_slug", + "next_getStaticProps_withRevalidate_id", + "next_getStaticProps_withRevalidate_withFallback_id", + "next_getStaticProps_withrevalidate", + "next_index", + "next_shows_id", + "next_shows_params", + ]; + const fileListFunctions = fileList.split("---")[0].split("\n"); + expect(isSameList(nextFunctions, fileListFunctions)).toBe(true); + expect(fileListFunctions.includes("someTestFunction.js")).toBe(false); + const publishFiles = [ + "404.html", + "_next", + "_redirects", + "getStaticProps", + "static", + "static.html", + ]; + const fileListPublish = fileList.split("---")[1].split("\n"); + expect(isSameList(publishFiles, fileListPublish)).toBe(true); + }); +}); diff --git a/tests/fixtures/my-functions/someTestFunction.js b/tests/fixtures/my-functions/someTestFunction.js new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/buildNextApp.js b/tests/helpers/buildNextApp.js index eaee609..8073da6 100644 --- a/tests/helpers/buildNextApp.js +++ b/tests/helpers/buildNextApp.js @@ -46,6 +46,10 @@ class NextAppBuilder { return this.withFile(packageJsonFile, "package.json"); } + withCustomFunctions(functionsDir) { + return this.withFile(functionsDir); + } + // Copy a file from the fixtures folder to the app's staging folder withFile(fixture, target = null) { // If no target file name is given, use the same name as the fixture @@ -61,9 +65,8 @@ class NextAppBuilder { return this; } - // Build the application with next build - async build() { - // Generate a cach hash ID from the current contents of the staging folder. + async buildNextApp() { + // Generate a cache hash ID from the current contents of the staging folder. const { hash: cacheHash } = await hashElement(this.__stagingPath, { encoding: "hex", }); @@ -83,11 +86,29 @@ class NextAppBuilder { // run next-on-netlify copySync(this.__cachePath, this.__appPath); - // Run next-on-netlify + process.chdir(this.__appPath); + } + + async build() { + await this.buildNextApp(); + + // Run next-on-netlify as postbuild script const { stdout } = await npmRun("next-on-netlify", this.__appPath); return stdout; } + async runWithRequire(options) { + await this.buildNextApp(); + + // Run next-on-netlify as an imported module + const nextOnNetlify = require("../.."); + nextOnNetlify({ + functionsDir: join(this.__appPath, options.functionsDir), + publishDir: join(this.__appPath, options.publishDir), + }); + return "Built successfully!"; + } + /***************************************************************************** * Private functions ****************************************************************************/