Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🚀 Feature: Find a way to allow passing await handlebars() directly to files in producers
### Feature Request Checklist

- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Right now, the `handlebars()` function returns `CreatedEntry | undefined`. `CreatedEntry` can be a directory or a file. But, `files` creations must be directories, not files. As a result, if you want to `await handlebars()` in a producer, you typically have to use an `as` assertion.

Example from https://github.com/bingo-examples/create-handlebars-example/blob/bca1bd40bc7ca4ed834d23d264dfdbbbc18b52b2/src/index.ts#L31-L34:

```ts
files: (await handlebars(
	path.join(import.meta.dirname, "../template"),
	options,
)) as CreatedDirectory,
```

This is not ideal. I think I'll want to add something like dedicated file and directory equivalents, which throw an error if given the incorrect entry type.

### Additional Info

💝 

Pull request description:
feat: add handlebarsDirectory, handlebarsFile APIs
## PR Checklist

- [x] Addresses an existing open issue: fixes #266
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Updates:

* `bingo-fs`: adding an `isFile` predicate for convenience, as well as mentions of `CreatedDirectory` and `CreatedFileEntry` in docs
* `bingo-handlebars`: added `handlebarsDirectory` and `handlebarsFile` functions that assert on those respective creation types

💝 

Patch:
diff --git a/packages/bingo-fs/src/index.ts b/packages/bingo-fs/src/index.ts
index 5f9240f..e7c3be5 100644
--- a/packages/bingo-fs/src/index.ts
+++ b/packages/bingo-fs/src/index.ts
@@ -1,2 +1,3 @@
 export * from "./intake.js";
+export * from "./isFile.js";
 export type * from "./types.js";
diff --git a/packages/bingo-fs/src/isFile.ts b/packages/bingo-fs/src/isFile.ts
new file mode 100644
index 0000000..8eeae24
--- /dev/null
+++ b/packages/bingo-fs/src/isFile.ts
@@ -0,0 +1,5 @@
+import { CreatedEntry } from "./types.js";
+
+export function isFile(entry: CreatedEntry | undefined): entry is CreatedEntry {
+	return typeof entry === "string" || Array.isArray(entry);
+}
diff --git a/packages/bingo-handlebars/src/executeTemplatesRecursive.ts b/packages/bingo-handlebars/src/executeTemplatesRecursive.ts
index af1dd04..9976369 100644
--- a/packages/bingo-handlebars/src/executeTemplatesRecursive.ts
+++ b/packages/bingo-handlebars/src/executeTemplatesRecursive.ts
@@ -1,7 +1,19 @@
-import { CreatedEntry } from "bingo-fs";
+import { CreatedDirectory, CreatedEntry, CreatedFileEntry } from "bingo-fs";
 
 import { executeTemplate } from "./executeTemplate.js";
 
+export function executeTemplatesRecursive(
+	source: CreatedFileEntry | undefined,
+	options: object | undefined,
+): CreatedFileEntry | undefined;
+export function executeTemplatesRecursive(
+	source: CreatedDirectory | undefined,
+	options: object | undefined,
+): CreatedDirectory | undefined;
+export function executeTemplatesRecursive(
+	source: CreatedEntry | undefined,
+	options: object | undefined,
+): CreatedEntry | undefined;
 export function executeTemplatesRecursive(
 	source: CreatedEntry | undefined,
 	options: object | undefined,
diff --git a/packages/bingo-handlebars/src/handlebars.ts b/packages/bingo-handlebars/src/handlebars.ts
index 07d9507..795c761 100644
--- a/packages/bingo-handlebars/src/handlebars.ts
+++ b/packages/bingo-handlebars/src/handlebars.ts
@@ -5,7 +5,7 @@ import { executeTemplatesRecursive } from "./executeTemplatesRecursive.js";
 export async function handlebars(sourcePath: string, options?: object) {
 	const source = await intake(sourcePath);
 
-	if (!source) {
+	if (source === undefined) {
 		throw new Error(
 			`handlebars() must be given a path to a file or directory. '${sourcePath}' does not exist.`,
 		);
diff --git a/packages/bingo-handlebars/src/handlebarsDirectory.ts b/packages/bingo-handlebars/src/handlebarsDirectory.ts
new file mode 100644
index 0000000..6c1f62d
--- /dev/null
+++ b/packages/bingo-handlebars/src/handlebarsDirectory.ts
@@ -0,0 +1,24 @@
+import { intake, isFile } from "bingo-fs";
+
+import { executeTemplatesRecursive } from "./executeTemplatesRecursive.js";
+
+export async function handlebarsDirectory(
+	sourcePath: string,
+	options?: object,
+) {
+	const source = await intake(sourcePath);
+
+	if (!source) {
+		throw new Error(
+			`handlebarsDirectory() must be given a path to a directory. '${sourcePath}' does not exist.`,
+		);
+	}
+
+	if (isFile(source)) {
+		throw new Error(
+			`handlebarsDirectory() must be given a path to a directory. '${sourcePath}' is a file.`,
+		);
+	}
+
+	return executeTemplatesRecursive(source, options);
+}
diff --git a/packages/bingo-handlebars/src/handlebarsFile.ts b/packages/bingo-handlebars/src/handlebarsFile.ts
new file mode 100644
index 0000000..869fc2f
--- /dev/null
+++ b/packages/bingo-handlebars/src/handlebarsFile.ts
@@ -0,0 +1,21 @@
+import { intake, isFile } from "bingo-fs";
+
+import { executeTemplatesRecursive } from "./executeTemplatesRecursive.js";
+
+export async function handlebarsFile(sourcePath: string, options?: object) {
+	const source = await intake(sourcePath);
+
+	if (source === undefined) {
+		throw new Error(
+			`handlebarsDirectory() must be given a path to a file or directory. '${sourcePath}' does not exist.`,
+		);
+	}
+
+	if (!isFile(source)) {
+		throw new Error(
+			`handlebarsDirectory() must be given a path to a file. '${sourcePath}' is a directory.`,
+		);
+	}
+
+	return executeTemplatesRecursive(source, options);
+}
diff --git a/packages/site/src/content/docs/build/packages/bingo-fs.mdx b/packages/site/src/content/docs/build/packages/bingo-fs.mdx
index 18eaa90..dc60edf 100644
--- a/packages/site/src/content/docs/build/packages/bingo-fs.mdx
+++ b/packages/site/src/content/docs/build/packages/bingo-fs.mdx
@@ -34,9 +34,19 @@ For example, given a structure like:
 }
 ```
 
+## Directories
+
+> TypeScript type name: `CreatedDirectory`
+
+Directories in a `files` object are represented as objects.
+Property keys are the names of children.
+Values are the file or directory underneath that key.
+
 ## Files
 
-Each property in a `files` object may be one of the following:
+> TypeScript type name: `CreatedFileEntry`
+
+Each property value in a directory object may be one of the following:
 
 - `false` or `undefined`: Ignored
 - `object`: A directory, whose properties recursively are file creations
diff --git a/packages/site/src/content/docs/engines/handlebars/about.mdx b/packages/site/src/content/docs/engines/handlebars/about.mdx
index 8b1b23f..cfd4e1c 100644
--- a/packages/site/src/content/docs/engines/handlebars/about.mdx
+++ b/packages/site/src/content/docs/engines/handlebars/about.mdx
@@ -14,7 +14,7 @@ The [`bingo-handlebars`](https://npmjs.com/package/bingo-handlebars) package pro
 
 If you'd like to use the Handlebars langauge in your Bingo template, you have two options:
 
-1. [`handlebars()`](/engines/handlebars/handlebars): applies Handlebars templates from a single path.
+1. [`handlebars()`](/engines/handlebars/handlebars) & co.: applies Handlebars templates from a single path.
 2. [`loadHandlebars()`](/engines/handlebars/handlebars): generates a reusable `handlebars()`
 
 :::tip
diff --git a/packages/site/src/content/docs/engines/handlebars/handlebars.mdx b/packages/site/src/content/docs/engines/handlebars/handlebars.mdx
index b854f15..32b7914 100644
--- a/packages/site/src/content/docs/engines/handlebars/handlebars.mdx
+++ b/packages/site/src/content/docs/engines/handlebars/handlebars.mdx
@@ -3,12 +3,22 @@ description: Applies Handlebars templates from a single path.
 title: handlebars()
 ---
 
-The exported `handlebars()` function generates [`files` Creations](/build/concepts/creations#files) using Handlebars templates on disk.
-It takes up to two parameters:
+`bingo-handlebars` exports three primary functions for directly using Handlebars templates in Bingo templates:
 
-1. `sourcePath: string` _(required)_: absolute path to a template file or a directory containing Handlebars templates files
+- [`handlebars`](#handlebars): generates a directory or files from a path
+- [`handlebarsFile`](#handlebarsfile): generates a file from a path
+- [`handlebarsDirectory`](#handlebarsdirectory): generates a directory from a path
+
+Each function generates [`files` Creations](/build/concepts/creations#files) using Handlebars templates on disk.
+Each takes up to two parameters:
+
+1. `sourcePath: string` _(required)_: absolute path to a Handlebars template file or directory
 2. `options: object` _(optional)_: any values to use in the templates
 
+## `handlebars`
+
+Generates files creations from a directory of templates or a single template file.
+
 For example, this template uses a `../template/package.json.hbs` file relative to the template's source file:
 
 ```ts
@@ -59,9 +69,69 @@ Given the options `{ owner: "your-username", repository : "my-handlebars-app" }`
 }
 ```
 
+### `handlebarsDirectory`
+
+Behaves the same as `handlebars()`, but only supports generating from a directory.
+Throws an error if the `sourcePath` is a file.
+
+```ts
+import { createTemplate } from "bingo";
+import { handlebarsDirectory } from "bingo-handlebars";
+import path from "node:path";
+
+export default createTemplate({
+	about: { name: "My Handlebars Template" },
+	options: {
+		owner: z.string(),
+		repository: z.string(),
+	},
+	async produce({ options }) {
+		return {
+			files: await handlebarsDirectory(
+				path.join(import.meta.dirname, "../template"),
+				options,
+			),
+		};
+	},
+});
+```
+
+This can be useful for locations such as the root [`file` Creation](/build/concepts/creations#files) where TypeScript requires a directory (`CreatedDirectory`) rather than a file (`CreatedFileEntry`).
+
+### `handlebarsFile`
+
+Behaves the same as `handlebars()`, but only supports generating a single file.
+Throws an error if the `sourcePath` is a directory.
+
+```ts
+import { createTemplate } from "bingo";
+import { handlebarsFile } from "bingo-handlebars";
+import path from "node:path";
+
+export default createTemplate({
+	about: { name: "My Handlebars Template" },
+	options: {
+		owner: z.string(),
+		repository: z.string(),
+	},
+	async produce({ options }) {
+		return {
+			files: {
+				"package.json": await handlebarsFile(
+					path.join(import.meta.dirname, "../template/package.json.hbs"),
+					options,
+				),
+			},
+		};
+	},
+});
+```
+
+This can be useful for locations where TypeScript requires a file (`CreatedFileEntry`) rather than a directory (`CreatedDirectory`).
+
 ## Directories
 
-If the path given to the generated `handlebars()` contains a directory, that directory will be recursively read.
+Directory paths given to [`handlebars()`](#handlebars) and [`handlebarsDirectory()`](#handlebarsdirectory) will be recursively read.
 Any file names ending with `.hbs` will have that suffix removed.
 
 For example, this template uses a `../template` directory relative to the template's source file for all its `files` Creations:


Test patch:
diff --git a/packages/bingo-fs/src/isFile.test.ts b/packages/bingo-fs/src/isFile.test.ts
new file mode 100644
index 0000000..7c30ecc
--- /dev/null
+++ b/packages/bingo-fs/src/isFile.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "vitest";
+
+import { isFile } from "./isFile.js";
+import { CreatedEntry } from "./types.js";
+
+describe("isFile", () => {
+	test.each([
+		[false, false],
+		[undefined, false],
+		[{}, false],
+		["", true],
+		[[""], true],
+		[["..."], true],
+		[["", {}], true],
+		[["...", {}], true],
+		[["", { executable: false }], true],
+		[["", { executable: true }], true],
+	] satisfies [CreatedEntry | undefined, boolean][])(
+		"%s",
+		(input, expected) => {
+			expect(isFile(input)).toBe(expected);
+		},
+	);
+});
diff --git a/packages/bingo-handlebars/src/handlebarsDirectory.test.ts b/packages/bingo-handlebars/src/handlebarsDirectory.test.ts
new file mode 100644
index 0000000..170cdbe
--- /dev/null
+++ b/packages/bingo-handlebars/src/handlebarsDirectory.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { handlebarsDirectory } from "./handlebarsDirectory.js";
+
+const mockIntake = vi.fn();
+
+vi.mock("bingo-fs", async () => ({
+	...(await vi.importActual("bingo-fs")),
+	get intake() {
+		return mockIntake;
+	},
+}));
+
+const sourcePath = "path/to/template";
+
+const options = { abc: 123 };
+
+describe("handlebarsDirectory", () => {
+	it("throws an error when the source does not exist", async () => {
+		mockIntake.mockResolvedValueOnce(undefined);
+
+		const act = async () => await handlebarsDirectory(sourcePath);
+
+		await expect(act).rejects.toMatchInlineSnapshot(
+			`[Error: handlebarsDirectory() must be given a path to a directory. 'path/to/template' does not exist.]`,
+		);
+	});
+
+	it("throws an error when the source is a file", async () => {
+		mockIntake.mockResolvedValueOnce(["..."]);
+
+		const act = async () => await handlebarsDirectory(sourcePath);
+
+		await expect(act).rejects.toMatchInlineSnapshot(
+			`[Error: handlebarsDirectory() must be given a path to a directory. 'path/to/template' is a file.]`,
+		);
+	});
+
+	it("calls executeTemplatesRecursive with the options when the source exists", async () => {
+		mockIntake.mockResolvedValueOnce({
+			index: ["{{abc}}"],
+		});
+
+		const actual = await handlebarsDirectory(sourcePath, options);
+
+		expect(actual).toEqual({
+			index: ["123"],
+		});
+	});
+});
diff --git a/packages/bingo-handlebars/src/handlebarsFile.test.ts b/packages/bingo-handlebars/src/handlebarsFile.test.ts
new file mode 100644
index 0000000..b69b53e
--- /dev/null
+++ b/packages/bingo-handlebars/src/handlebarsFile.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { handlebarsFile } from "./handlebarsFile.js";
+
+const mockIntake = vi.fn();
+
+vi.mock("bingo-fs", async () => ({
+	...(await vi.importActual("bingo-fs")),
+	get intake() {
+		return mockIntake;
+	},
+}));
+
+const sourcePath = "path/to/template";
+
+const options = { abc: 123 };
+
+describe("handlebarsFile", () => {
+	it("throws an error when the source does not exist", async () => {
+		mockIntake.mockResolvedValueOnce(undefined);
+
+		const act = async () => await handlebarsFile(sourcePath);
+
+		await expect(act).rejects.toMatchInlineSnapshot(
+			`[Error: handlebarsDirectory() must be given a path to a file or directory. 'path/to/template' does not exist.]`,
+		);
+	});
+
+	it("throws an error when the source is a directory", async () => {
+		mockIntake.mockResolvedValueOnce({ index: ["..."] });
+
+		const act = async () => await handlebarsFile(sourcePath);
+
+		await expect(act).rejects.toMatchInlineSnapshot(
+			`[Error: handlebarsDirectory() must be given a path to a file. 'path/to/template' is a directory.]`,
+		);
+	});
+
+	it("calls executeTemplatesRecursive with the options when the source exists", async () => {
+		mockIntake.mockResolvedValueOnce(["{{abc}}"]);
+
+		const actual = await handlebarsFile(sourcePath, options);
+
+		expect(actual).toEqual(["123"]);
+	});
+});