diff --git a/README.md b/README.md index c5c1e49..800eb09 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Usage: top-bun [options] --dest, -d path to build destination directory (default: "public") --ignore, -i comma separated gitignore style ignore string --drafts Build draft pages with the `.draft.{md,js,html}` page suffix. + --copy Path to directories to copy into dist; can be used multiple times --target, -t comma separated target strings for esbuild --noEsbuildMeta skip writing the esbuild metafile to disk --watch-only watch and build the src directory without serving @@ -587,6 +588,38 @@ These imports will include the `root.layout.js` layout assets into the `blog.lay All static assets in the `src` directory are copied 1:1 to the `public` directory. Any file in the `src` directory that doesn't end in `.js`, `.css`, `.html`, or `.md` is copied to the `dest` directory. +### 📁 `--copy` directories + +You can specify directories to copy into your `dest` directory using the `--copy` flag. Everything in those directories will be copied as-is into the destination, including js, css, html and markdown, preserving the internal directory structure. Conflicting files are not detected or reported and will cause undefined behavior. + +Copy folders must live **outside** of the `src` directory. + +This is useful when you have legacy or archived site content that you want to include in your site, but don't want `top-bun` to process or modify it. +In general, static content should live in your primary `src` directory, however for merging in old static assets over your top-bun build is sometimes easier to reason about when it's kept in a separate folder and isn't processed in any way. + +For example: + +``` +src/... +oldsite/ +├── client.js +├── hello.html +└── styles/ + └── globals.css +``` + +After build: + +``` +src/... +oldsite/... +public/ +├── client.js +├── hello.html +└── styles/ + └── globals.css +``` + ## Templates Template files let you write any kind of file type to the `dest` folder while customizing the contents of that file with access to the site [Variables](#variables) object, or inject any other kind of data fetched at build time. Template files can be located anywhere and look like: diff --git a/bin.js b/bin.js index d8bd51b..2f6e81d 100755 --- a/bin.js +++ b/bin.js @@ -80,6 +80,11 @@ const options = { type: 'boolean', help: 'watch and build the src folder without serving', }, + copy: { + type: 'string', + help: 'path to directories to copy into dist; can be used multiple times', + multiple: true + }, help: { type: 'boolean', short: 'h', @@ -202,6 +207,11 @@ top-bun eject actions: if (argv['target']) opts.target = String(argv['target']).split(',') if (argv['noEsbuildMeta']) opts.metafile = false if (argv['drafts']) opts.buildDrafts = true + if (argv['copy']) { + const copyPaths = Array.isArray(argv['copy']) ? argv['copy'] : [argv['copy']] + // @ts-expect-error + opts.copy = copyPaths.map(p => resolve(cwd, p)) + } const topBun = new TopBun(src, dest, opts) diff --git a/index.js b/index.js index a21fb54..0a44f38 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ import { once } from 'events' import assert from 'node:assert' import chokidar from 'chokidar' -import { basename, relative } from 'path' +import { basename, relative, resolve } from 'node:path' // @ts-ignore import makeArray from 'make-array' import ignore from 'ignore' @@ -11,6 +11,7 @@ import { inspect } from 'util' import browserSync from 'browser-sync' import { getCopyGlob } from './lib/build-static/index.js' +import { getCopyDirs } from './lib/build-copy/index.js' import { builder } from './lib/builder.js' import { TopBunAggregateError } from './lib/helpers/top-bun-aggregate-error.js' @@ -66,7 +67,7 @@ export class TopBun { /** @type {string} */ #dest = '' /** @type {Readonly} */ opts /** @type {FSWatcher?} */ #watcher = null - /** @type {any?} */ #cpxWatcher = null + /** @type {any[]?} */ #cpxWatchers = null /** @type {browserSync.BrowserSyncInstance?} */ #browserSyncServer = null /** @@ -83,14 +84,29 @@ export class TopBun { this.#src = src this.#dest = dest + const copyDirs = opts?.copy ?? [] + this.opts = { ...opts, ignore: [ ...DEFAULT_IGNORES, basename(dest), + ...copyDirs.map(dir => basename(dir)), ...makeArray(opts.ignore), ], } + + if (copyDirs && copyDirs.length > 0) { + const absDest = resolve(this.#dest) + for (const copyDir of copyDirs) { + // Copy dirs can be in the src dir (nested builds), but not in the dest dir. + const absCopyDir = resolve(copyDir) + const relToDest = relative(absDest, absCopyDir) + if (relToDest === '' || !relToDest.startsWith('..')) { + throw new Error(`copyDir ${copyDir} is within the dest directory`) + } + } + } } get watching () { @@ -126,7 +142,12 @@ export class TopBun { report = err.results } - this.#cpxWatcher = cpx.watch(getCopyGlob(this.#src), this.#dest, { ignore: this.opts.ignore }) + const copyDirs = getCopyDirs(this.opts.copy) + + this.#cpxWatchers = [ + cpx.watch(getCopyGlob(this.#src), this.#dest, { ignore: this.opts.ignore }), + ...copyDirs.map(copyDir => cpx.watch(copyDir, this.#dest)) + ] if (serve) { const bs = browserSync.create() this.#browserSyncServer = bs @@ -136,20 +157,22 @@ export class TopBun { }) } - this.#cpxWatcher.on('watch-ready', () => { - console.log('Copy watcher ready') + this.#cpxWatchers.forEach(w => { + w.on('watch-ready', () => { + console.log('Copy watcher ready') - this.#cpxWatcher.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { - console.log(`Copy ${e.srcPath} to ${e.dstPath}`) - }) + w.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { + console.log(`Copy ${e.srcPath} to ${e.dstPath}`) + }) - this.#cpxWatcher.on('remove', (/** @type{{ path: string }} */e) => { - console.log(`Remove ${e.path}`) - }) - }) + w.on('remove', (/** @type{{ path: string }} */e) => { + console.log(`Remove ${e.path}`) + }) - this.#cpxWatcher.on('watch-error', (/** @type{Error} */err) => { - console.log(`Copy error: ${err.message}`) + w.on('watch-error', (/** @type{Error} */err) => { + console.log(`Copy error: ${err.message}`) + }) + }) }) const ig = ignore().add(this.opts.ignore ?? []) @@ -204,11 +227,13 @@ export class TopBun { } async stopWatching () { - if ((!this.watching || !this.#cpxWatcher)) throw new Error('Not watching') - if (this.#watcher) await this.#watcher.close() - this.#cpxWatcher.close() + if ((!this.watching || !this.#cpxWatchers)) throw new Error('Not watching') + if (this.#watcher) this.#watcher.close() + this.#cpxWatchers.forEach(w => { + w.close() + }) this.#watcher = null - this.#cpxWatcher = null + this.#cpxWatchers = null this.#browserSyncServer?.exit() // This will kill the process this.#browserSyncServer = null } diff --git a/lib/build-copy/fixtures/bing/another.copy/.keep b/lib/build-copy/fixtures/bing/another.copy/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/build-copy/fixtures/foo.copy/.keep b/lib/build-copy/fixtures/foo.copy/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/build-copy/fixtures/foo.copy/bar/baz.copy/.keep b/lib/build-copy/fixtures/foo.copy/bar/baz.copy/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js new file mode 100644 index 0000000..983705c --- /dev/null +++ b/lib/build-copy/index.js @@ -0,0 +1,60 @@ +// @ts-ignore +import cpx from 'cpx2' +import { join } from 'node:path' +const copy = cpx.copy + +/** + * @typedef {Awaited>} CopyBuilderReport + */ + +/** + * @typedef {import('../builder.js').BuildStepResult<'static', CopyBuilderReport>} CopyBuildStepResult + */ + +/** + * @typedef {import('../builder.js').BuildStep<'static', CopyBuilderReport>} CopyBuildStep + */ + +/** + * @param {string[]} copy + * @return {string[]} + */ +export function getCopyDirs (copy = []) { + const copyGlobs = copy?.map((dir) => join(dir, '**')) + return copyGlobs +} + +/** + * run CPX2 on src folder + * + * @type {CopyBuildStep} + */ +export async function buildCopy (_src, dest, _siteData, opts) { + /** @type {CopyBuildStepResult} */ + const results = { + type: 'static', + report: {}, + errors: [], + warnings: [], + } + + const copyDirs = getCopyDirs(opts?.copy) + + const copyTasks = copyDirs.map((copyDir) => { + return copy(copyDir, dest) + }) + + const settled = await Promise.allSettled(copyTasks) + + for (const [index, result] of Object.entries(settled)) { + // @ts-expect-error + const copyDir = copyDirs[index] + if (result.status === 'rejected') { + const buildError = new Error('Error copying copy folders', { cause: result.reason }) + results.errors.push(buildError) + } else { + results.report[copyDir] = result.value + } + } + return results +} diff --git a/lib/build-copy/index.test.js b/lib/build-copy/index.test.js new file mode 100644 index 0000000..ec47797 --- /dev/null +++ b/lib/build-copy/index.test.js @@ -0,0 +1,8 @@ +import tap from 'tap' +import { getCopyDirs } from './index.js' + +tap.test('getCopyDirs returns correct src/dest pairs', async (t) => { + const copyDirs = getCopyDirs(['fixtures']) + + t.strictSame(copyDirs, ['fixtures/**']) +}) diff --git a/lib/builder.js b/lib/builder.js index 9c2253b..09fcb98 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -1,6 +1,7 @@ import { buildPages } from './build-pages/index.js' import { identifyPages } from './identify-pages.js' import { buildStatic } from './build-static/index.js' +import { buildCopy } from './build-copy/index.js' import { buildEsbuild } from './build-esbuild/index.js' import { TopBunAggregateError } from './helpers/top-bun-aggregate-error.js' import { ensureDest } from './helpers/ensure-dest.js' @@ -40,12 +41,12 @@ import { ensureDest } from './helpers/ensure-dest.js' /** * @typedef TopBunOpts - * @property {string[]|undefined} [ignore] - Array of file/folder patterns to ignore. * @property {boolean|undefined} [static=true] - Enable/disable static file processing * @property {boolean|undefined} [metafile=true] - Enable/disable the writing of the esbuild metadata file. * @property {string[]|undefined} [ignore=[]] - Array of ignore strings * @property {string[]|undefined} [target=[]] - Array of target strings to pass to esbuild * @property {boolean|undefined} [buildDrafts=false] - Build draft files with the published:false variable + * @property {string[]|undefined} [copy=[]] - Array of paths to copy their contents into the dest directory */ /** @@ -57,6 +58,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @typedef {import('./build-esbuild/index.js').EsBuildStepResults} EsBuildStepResults * @typedef {import('./build-pages/index.js').PageBuildStepResult} PageBuildStepResult * @typedef {import('./build-static/index.js').StaticBuildStepResult} StaticBuildStepResult + * @typedef {import('./build-copy/index.js').CopyBuildStepResult} CopyBuildStepResult */ /** @@ -64,6 +66,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @property {SiteData} siteData * @property {EsBuildStepResults} esbuildResults * @property {StaticBuildStepResult} [staticResults] + * @property {CopyBuildStepResult} [copyResults] * @property {PageBuildStepResult} [pageBuildResults] * @property {BuildStepWarnings} warnings */ @@ -112,11 +115,13 @@ export async function builder (src, dest, opts) { const [ esbuildResults, staticResults, + copyResults, ] = await Promise.all([ buildEsbuild(src, dest, siteData, opts), opts.static ? buildStatic(src, dest, siteData, opts) : Promise.resolve(), + buildCopy(src, dest, siteData, opts), ]) /** @type {Results} */ @@ -135,6 +140,10 @@ export async function builder (src, dest, opts) { results.staticResults = staticResults } + errors.push(...copyResults.errors) + warnings.push(...copyResults.warnings) + results.copyResults = copyResults + if (errors.length > 0) { const preBuildError = new TopBunAggregateError(errors, 'Prebuild finished but there were errors.', results) throw preBuildError diff --git a/lib/helpers/top-bun-warning.js b/lib/helpers/top-bun-warning.js index ca498e9..52affe3 100644 --- a/lib/helpers/top-bun-warning.js +++ b/lib/helpers/top-bun-warning.js @@ -8,7 +8,7 @@ * 'TOP_BUN_WARNING_UNKNOWN_PAGE_BUILDER' | * 'TOP_BUN_WARNING_DUPLICATE_GLOBAL_STYLE' | * 'TOP_BUN_WARNING_DUPLICATE_GLOBAL_CLIENT' | - * 'TOP_BUN_WARNING_DUPLICATE_ESBUILD_SETINGS' | + * 'TOP_BUN_WARNING_DUPLICATE_ESBUILD_SETTINGS' | * 'TOP_BUN_WARNING_DUPLICATE_GLOBAL_VARS' * } TopBunWarningCode */ diff --git a/lib/identify-pages.js b/lib/identify-pages.js index c66399d..d941ab0 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -84,7 +84,7 @@ const shaper = ({ * @typedef TemplateInfo * @property {WalkerFile} templateFile - The template file info. * @property {string} path - The the path of the parent dir of the template - * @property {string} outputName - The derived output name of the template file. Might be overriden. + * @property {string} outputName - The derived output name of the template file. Might be overridden. */ /** @@ -296,12 +296,12 @@ export async function identifyPages (src, opts = {}) { if (templateSuffixs.some(suffix => fileName.endsWith(suffix))) { const suffix = templateSuffixs.find(suffix => fileName.endsWith(suffix)) if (!suffix) throw new Error('template suffix not found') - const temlateFileName = fileName.slice(0, -suffix.length) + const templateFileName = fileName.slice(0, -suffix.length) templates.push({ templateFile: fileInfo, path: dir, - outputName: temlateFileName, + outputName: templateFileName, }) } @@ -341,7 +341,7 @@ export async function identifyPages (src, opts = {}) { if (esbuildSettingsNames.some(name => basename(fileName) === name)) { if (esbuildSettings) { warnings.push({ - code: 'TOP_BUN_WARNING_DUPLICATE_ESBUILD_SETINGS', + code: 'TOP_BUN_WARNING_DUPLICATE_ESBUILD_SETTINGS', message: `Skipping ${fileInfo.relname}. Duplicate esbuild options ${fileName} to ${esbuildSettings.filepath}`, }) } else { diff --git a/test-cases/general-features/copyfolder/oldsite/client.js b/test-cases/general-features/copyfolder/oldsite/client.js new file mode 100644 index 0000000..cea4f10 --- /dev/null +++ b/test-cases/general-features/copyfolder/oldsite/client.js @@ -0,0 +1 @@ +console.log('hello world') diff --git a/test-cases/general-features/copyfolder/oldsite/hello.html b/test-cases/general-features/copyfolder/oldsite/hello.html new file mode 100644 index 0000000..1339b08 --- /dev/null +++ b/test-cases/general-features/copyfolder/oldsite/hello.html @@ -0,0 +1,11 @@ + + + + + + Hello + + +

Hello world

+ + diff --git a/test-cases/general-features/copyfolder/oldsite/styles/globals.css b/test-cases/general-features/copyfolder/oldsite/styles/globals.css new file mode 100644 index 0000000..b50e6ea --- /dev/null +++ b/test-cases/general-features/copyfolder/oldsite/styles/globals.css @@ -0,0 +1,3 @@ +body { + background-color: #f0f0f0; +} diff --git a/test-cases/general-features/copyfolder/some.type b/test-cases/general-features/copyfolder/some.type new file mode 100644 index 0000000..45b983b --- /dev/null +++ b/test-cases/general-features/copyfolder/some.type @@ -0,0 +1 @@ +hi diff --git a/test-cases/general-features/index.test.js b/test-cases/general-features/index.test.js index dcd9cda..92681ea 100644 --- a/test-cases/general-features/index.test.js +++ b/test-cases/general-features/index.test.js @@ -10,7 +10,7 @@ const __dirname = import.meta.dirname tap.test('general-features', async (t) => { const src = path.join(__dirname, './src') const dest = path.join(__dirname, './public') - const siteUp = new TopBun(src, dest) + const siteUp = new TopBun(src, dest, { copy: [path.join(__dirname, './copyfolder')] }) await rm(dest, { recursive: true, force: true }) @@ -156,4 +156,16 @@ tap.test('general-features', async (t) => { t.fail(`Assertions failed on ${filePath}`) } } + + const expected = [ + 'client.js', + 'hello.html', + 'styles/globals.css' + ] + + for (const rel of expected) { + const full = path.join(dest, 'oldsite', rel) + const st = await stat(full) + t.ok(st.isFile(), `oldsite/${rel} exists and is a file`) + } }) diff --git a/test-cases/nested-dest/copydir/somemarkdown.md b/test-cases/nested-dest/copydir/somemarkdown.md new file mode 100644 index 0000000..05c9a50 --- /dev/null +++ b/test-cases/nested-dest/copydir/somemarkdown.md @@ -0,0 +1 @@ +Hello world! I was copied diff --git a/test-cases/nested-dest/index.test.js b/test-cases/nested-dest/index.test.js index 38381fe..27d16b0 100644 --- a/test-cases/nested-dest/index.test.js +++ b/test-cases/nested-dest/index.test.js @@ -8,7 +8,11 @@ const __dirname = import.meta.dirname tap.test('nested-dest', async (t) => { const src = __dirname const dest = path.join(__dirname, './public') - const siteUp = new TopBun(src, dest) + const siteUp = new TopBun(src, dest, { + copy: [ + path.join(__dirname, './copydir') + ] + }) await rm(dest, { recursive: true, force: true })