diff --git a/src/ssgs/astro.js b/src/ssgs/astro.js new file mode 100644 index 0000000..94a9507 --- /dev/null +++ b/src/ssgs/astro.js @@ -0,0 +1,29 @@ +import Ssg from './ssg.js'; + +export default class Astro extends Ssg { + constructor() { + super('astro'); + } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.push({ + value: 'npx astro build', + attribution: 'default for Astro sites', + }); + commands.output.push({ + value: 'dist', + attribution: 'default for Astro sites', + }); + + return commands; + } +} diff --git a/src/ssgs/bridgetown.js b/src/ssgs/bridgetown.js index b008c64..1f87e29 100644 --- a/src/ssgs/bridgetown.js +++ b/src/ssgs/bridgetown.js @@ -1,3 +1,4 @@ +import { joinPaths } from '../utility.js'; import Ssg from './ssg.js'; export default class Bridgetown extends Ssg { @@ -12,4 +13,43 @@ export default class Bridgetown extends Ssg { templateExtensions() { return super.templateExtensions().concat(['.liquid']); } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + if (filePaths.includes(joinPaths([options.source, 'Gemfile']))) { + commands.install.unshift({ + value: 'bundle install', + attribution: 'because of your Gemfile', + }); + + if (options.source) { + commands.environment['BUNDLE_GEMFILE'] = { + value: joinPaths([options.source, 'Gemfile']), + attribution: 'because of your Gemfile', + }; + } + } + + if (filePaths.includes('bin/bridgetown')) { + commands.build.unshift({ + value: 'bin/bridgetown deploy', + attribution: 'most common for Bridgetown sites', + }); + } + + commands.output.unshift({ + value: 'output', + attribution: 'most common for Bridgetown sites', + }); + + return commands; + } } diff --git a/src/ssgs/gatsby.js b/src/ssgs/gatsby.js new file mode 100644 index 0000000..e1e19a3 --- /dev/null +++ b/src/ssgs/gatsby.js @@ -0,0 +1,29 @@ +import Ssg from './ssg.js'; + +export default class Gatsby extends Ssg { + constructor() { + super('gatsby'); + } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.unshift({ + value: 'npx gatsby build', + attribution: 'default for Gatsby sites', + }); + commands.output.unshift({ + value: 'public', + attribution: 'default for Gatsby sites', + }); + + return commands; + } +} diff --git a/src/ssgs/hugo.js b/src/ssgs/hugo.js index d82492c..a4577ef 100644 --- a/src/ssgs/hugo.js +++ b/src/ssgs/hugo.js @@ -200,8 +200,13 @@ export default class Hugo extends Ssg { async generateBuildCommands(filePaths, options) { const commands = await super.generateBuildCommands(filePaths, options); + commands.install.unshift({ + value: 'export NODE_PATH=`pwd`/node_modules:$NODE_PATH', + attribution: 'workaround for a Hugo issue', // https://github.com/gohugoio/hugo/issues/9800 + }); + commands.build.unshift({ - value: 'hugo', + value: 'hugo -b /', attribution: 'most common for Hugo sites', }); commands.output.unshift({ diff --git a/src/ssgs/jekyll.js b/src/ssgs/jekyll.js index 26c79db..7b0af33 100644 --- a/src/ssgs/jekyll.js +++ b/src/ssgs/jekyll.js @@ -345,6 +345,13 @@ export default class Jekyll extends Ssg { value: 'bundle exec jekyll build', attribution: 'because of your Gemfile', }); + + if (options.source) { + commands.environment['BUNDLE_GEMFILE'] = { + value: joinPaths([options.source, 'Gemfile']), + attribution: 'because of your Gemfile', + }; + } } else { commands.build.unshift({ value: 'jekyll build', diff --git a/src/ssgs/lume.js b/src/ssgs/lume.js new file mode 100644 index 0000000..ac7373c --- /dev/null +++ b/src/ssgs/lume.js @@ -0,0 +1,29 @@ +import Ssg from './ssg.js'; + +export default class Lume extends Ssg { + constructor() { + super('lume'); + } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.push({ + value: 'deno task lume', + attribution: 'default for Lume sites', + }); + commands.output.unshift({ + value: '_site', + attribution: 'most common for Lume sites', + }); + + return commands; + } +} diff --git a/src/ssgs/mkdocs.js b/src/ssgs/mkdocs.js new file mode 100644 index 0000000..cf1f0a2 --- /dev/null +++ b/src/ssgs/mkdocs.js @@ -0,0 +1,29 @@ +import Ssg from './ssg.js'; + +export default class MkDocs extends Ssg { + constructor() { + super('mkdocs'); + } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.push({ + value: 'npx mkdocs build', + attribution: 'default for MkDocs sites', + }); + commands.output.unshift({ + value: 'site', + attribution: 'most common for MkDocs sites', + }); + + return commands; + } +} diff --git a/src/ssgs/next-js.js b/src/ssgs/next-js.js index 48c3465..7fbd784 100644 --- a/src/ssgs/next-js.js +++ b/src/ssgs/next-js.js @@ -20,4 +20,26 @@ export default class NextJs extends Ssg { 'public/', // static assets ]); } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.unshift({ + value: 'npx next build && npx next export', + attribution: 'most common for Next.js sites', + }); + commands.output.unshift({ + value: 'out', + attribution: 'default for Next.js sites', + }); + + return commands; + } } diff --git a/src/ssgs/nuxt-js.js b/src/ssgs/nuxt-js.js new file mode 100644 index 0000000..74ba430 --- /dev/null +++ b/src/ssgs/nuxt-js.js @@ -0,0 +1,29 @@ +import Ssg from './ssg.js'; + +export default class NuxtJs extends Ssg { + constructor() { + super('nuxtjs'); + } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + + commands.build.push({ + value: 'npx nuxt generate', + attribution: 'default for Nuxt sites', + }); + commands.output.unshift({ + value: 'dist', + attribution: 'most common for Nuxt sites', + }); + + return commands; + } +} diff --git a/src/ssgs/ssg.js b/src/ssgs/ssg.js index e840f8c..b88bad2 100644 --- a/src/ssgs/ssg.js +++ b/src/ssgs/ssg.js @@ -466,10 +466,20 @@ export default class Ssg { const packageJsonPath = joinPaths([options.source, 'package.json']); if (filePaths.includes(packageJsonPath)) { - commands.install.push({ - value: 'npm i', - attribution: 'because of your `package.json` file', - }); + const useYarn = + filePaths.includes(joinPaths([options.source, 'yarn.lock'])) && + !filePaths.includes(joinPaths([options.source, 'package-lock.json'])); + if (useYarn) { + commands.install.push({ + value: 'yarn', + attribution: 'because of your `yarn.lock` file', + }); + } else { + commands.install.push({ + value: 'npm i', + attribution: 'because of your `package.json` file', + }); + } commands.preserved.push({ value: 'node_modules/', @@ -477,16 +487,74 @@ export default class Ssg { }); try { - const raw = options.readFile ? await options.readFile(packageJsonPath) : undefined; - const parsed = raw ? JSON.parse(raw) : undefined; + if (options.readFile) { + const parsed = await parseDataFile(packageJsonPath, options.readFile); + if (parsed?.scripts?.build) { + commands.build.push({ + value: 'npm run build', + attribution: 'found in your `package.json` file', + }); + } + } + } catch (_e) {} + } - if (parsed?.scripts?.build) { - commands.build.push({ - value: 'npm run build', - attribution: 'found in your `package.json` file', + /** + * Check a value from a settings file and add to build commands. + * + * @param value {unknown} + * @param filename {string} + * @param type {keyof import('../types').BuildCommands} + */ + const validateAndAddCommandFromSettings = (value, filename, type) => { + if (value && typeof value === 'string') { + if (type === 'environment') { + commands[type].value = { + value, + attribution: `found in your \`${filename}\` file`, + }; + } else { + commands[type].push({ + value, + attribution: `found in your \`${filename}\` file`, }); } - } catch (_e) {} + } + }; + + if (options.readFile) { + const forestrySettingsPath = '.forestry/settings.yml'; + if (filePaths.includes(forestrySettingsPath)) { + try { + const parsed = await parseDataFile(forestrySettingsPath, options.readFile); + validateAndAddCommandFromSettings( + parsed?.build?.install_dependencies_command, + forestrySettingsPath, + 'install', + ); + } catch (_e) {} + } + + const netlifySettingsPath = 'netlify.toml'; + if (filePaths.includes(netlifySettingsPath)) { + // https://docs.netlify.com/configure-builds/file-based-configuration/ + try { + const parsed = await parseDataFile(netlifySettingsPath, options.readFile); + validateAndAddCommandFromSettings(parsed?.build?.command, netlifySettingsPath, 'build'); + validateAndAddCommandFromSettings(parsed?.build?.publish, netlifySettingsPath, 'output'); + } catch (_e) {} + } + + const vercelSettingsPath = 'vercel.json'; + if (filePaths.includes(vercelSettingsPath)) { + // https://vercel.com/docs/projects/project-configuration + try { + const parsed = await parseDataFile(vercelSettingsPath, options.readFile); + validateAndAddCommandFromSettings(parsed?.installCommand, vercelSettingsPath, 'install'); + validateAndAddCommandFromSettings(parsed?.buildCommand, vercelSettingsPath, 'build'); + validateAndAddCommandFromSettings(parsed?.outputDirectory, vercelSettingsPath, 'output'); + } catch (_e) {} + } } return commands; diff --git a/src/ssgs/ssgs.js b/src/ssgs/ssgs.js index 00c4d1c..ace22e6 100644 --- a/src/ssgs/ssgs.js +++ b/src/ssgs/ssgs.js @@ -6,6 +6,11 @@ import NextJs from './next-js.js'; import Ssg from './ssg.js'; import Sveltekit from './sveltekit.js'; import Static from './static.js'; +import Astro from './astro.js'; +import NuxtJs from './nuxt-js.js'; +import Gatsby from './gatsby.js'; +import MkDocs from './mkdocs.js'; +import Lume from './lume.js'; /** @type {Record} */ export const ssgs = { @@ -13,15 +18,15 @@ export const ssgs = { jekyll: new Jekyll(), eleventy: new Eleventy(), nextjs: new NextJs(), - astro: new Ssg('astro'), + astro: new Astro(), sveltekit: new Sveltekit(), bridgetown: new Bridgetown(), - lume: new Ssg('lume'), - mkdocs: new Ssg('mkdocs'), + lume: new Lume(), + mkdocs: new MkDocs(), docusaurus: new Ssg('docusaurus'), - gatsby: new Ssg('gatsby'), + gatsby: new Gatsby(), hexo: new Ssg('hexo'), - nuxtjs: new Ssg('nuxtjs'), + nuxtjs: new NuxtJs(), sphinx: new Ssg('sphinx'), static: new Static(), legacy: new Ssg('legacy'), diff --git a/src/ssgs/sveltekit.js b/src/ssgs/sveltekit.js index 578ee9b..4ec6d2a 100644 --- a/src/ssgs/sveltekit.js +++ b/src/ssgs/sveltekit.js @@ -1,3 +1,4 @@ +import { joinPaths } from '../utility.js'; import Ssg from './ssg.js'; export default class Sveltekit extends Ssg { @@ -24,4 +25,30 @@ export default class Sveltekit extends Ssg { 'static/', // static assets ]); } + + /** + * Generates a list of build suggestions. + * + * @param filePaths {string[]} List of input file paths. + * @param options {{ config?: Record; source?: string; readFile?: (path: string) => Promise; }} + * @returns {Promise} + */ + async generateBuildCommands(filePaths, options) { + const commands = await super.generateBuildCommands(filePaths, options); + const viteConfigPath = joinPaths([options.source, 'vite.config.js']); + + if (filePaths.includes(viteConfigPath)) { + commands.build.push({ + value: 'npx vite build', + attribution: 'because of your `vite.config.js` file', + }); + } + + commands.output.push({ + value: 'build', + attribution: 'most common for SvelteKit sites', + }); + + return commands; + } } diff --git a/test/build-commands.test.js b/test/build-commands.test.js new file mode 100644 index 0000000..4421d96 --- /dev/null +++ b/test/build-commands.test.js @@ -0,0 +1,85 @@ +import test from 'ava'; +import Ssg from '../src/ssgs/ssg.js'; + +const readFileMock = async (path) => { + if (path.endsWith('package.json')) { + return `{ "scripts": { "build": "webpack" } }`; + } + if (path.endsWith('.forestry/settings.yml')) { + return `build:\n install_dependencies_command: npm i`; + } + if (path.endsWith('netlify.toml')) { + return `[build]\npublish = "out"\ncommand = "webpack"`; + } + if (path.endsWith('vercel.json')) { + return `{ + "buildCommand": "webpack", + "installCommand": "npm i", + "outputDirectory": "out" + }`; + } + + return ''; +}; + +test('Look at package.json', async (t) => { + const ssg = new Ssg(); + const filePaths = ['package.json']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.install?.[0]?.value, 'npm i'); + t.is(buildCommands?.build?.[0]?.value, 'npm run build'); + t.is(buildCommands?.preserved?.[0]?.value, 'node_modules/'); +}); + +test('Look at yarn.lock', async (t) => { + const ssg = new Ssg(); + const filePaths = ['package.json', 'yarn.lock']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.install?.[0]?.value, 'yarn'); +}); + +test("Don't prefer yarn over npm", async (t) => { + const ssg = new Ssg(); + const filePaths = ['package.json', 'package-lock.json', 'yarn.lock']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.install?.[0]?.value, 'npm i'); +}); + +test('Read forestry settings', async (t) => { + const ssg = new Ssg(); + const filePaths = ['.forestry/settings.yml']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.install?.[0]?.value, 'npm i'); +}); + +test('Read netlify settings', async (t) => { + const ssg = new Ssg(); + const filePaths = ['netlify.toml']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.build?.[0]?.value, 'webpack'); + t.is(buildCommands?.output?.[0]?.value, 'out'); +}); + +test('Read vercel settings', async (t) => { + const ssg = new Ssg(); + const filePaths = ['vercel.json']; + const buildCommands = await ssg.generateBuildCommands(filePaths, { readFile: readFileMock }); + t.is(buildCommands?.install?.[0]?.value, 'npm i'); + t.is(buildCommands?.build?.[0]?.value, 'webpack'); + t.is(buildCommands?.output?.[0]?.value, 'out'); +}); + +test('Avoid misconfigurations in settings files', async (t) => { + const ssg = new Ssg(); + const readWeirdFile = () => `{ + "buildCommand": ["webpack"], + "installCommand": true, + "outputDirectory": 5 + }`; + const buildCommands = await ssg.generateBuildCommands(['vercel.json'], { + readFile: readWeirdFile, + }); + t.falsy(buildCommands?.install?.length); + t.falsy(buildCommands?.build?.length); + t.falsy(buildCommands?.output?.length); +});