diff --git a/.changeset/stupid-suits-design.md b/.changeset/stupid-suits-design.md new file mode 100644 index 00000000..805c94b1 --- /dev/null +++ b/.changeset/stupid-suits-design.md @@ -0,0 +1,5 @@ +--- +"@bluecadet/launchpad-content": minor +--- + +Add symlink plugin diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 21782cc2..d202f911 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -158,6 +158,7 @@ export default defineConfig({ text: "sanityImageUrlTransform", link: "/reference/content/plugins/sanity-image-url-transform", }, + { text: "symlink", link: "/reference/content/plugins/symlink" }, ], }, { text: "DataStore", link: "/reference/content/data-store" }, diff --git a/docs/src/reference/content/plugins/symlink.md b/docs/src/reference/content/plugins/symlink.md new file mode 100644 index 00000000..66295cb5 --- /dev/null +++ b/docs/src/reference/content/plugins/symlink.md @@ -0,0 +1,51 @@ +# symlink Content Plugin + +The `symlink` plugin is used to create symbolic links between files or directories. + +## Usage + +```typescript +import { symlink } from '@bluecadet/launchpad-content'; + +export default defineConfig({ + content: { + // ... + plugins: [ + symlink({ + target: 'path/to/target/file-or-directory', + path: 'path/to/symlink/file-or-directory', + condition: () => fs.existsSync('path/to/target') // Ex: only create symlink if target exists + }) + ] + } +}); +``` + +## Options + +### `target` + +- **Type:** `string` +- **Required** + +Specifies the target file or directory that the symlink will point to, which can be a relative or absolute path. + +Relative paths are resolved against the launchpad config directory. + +### `path` + +- **Type:** `string` +- **Required** + +Specifies the path where the symlink will be created. + +Relative paths are resolved against the launchpad config directory. + +### `condition` + +- **Type:** `boolean | (() => boolean | Promise)` +- **Default:** `true` + +Specifies a condition that must be met for the symlink to be created. This can be a boolean value or a function that returns a boolean or a promise that resolves to a boolean. + +If the condition is not met, the symlink will not be created. This is useful for scenarios where you want to conditionally create symlinks based on the existence of files or directories. \ No newline at end of file diff --git a/packages/content/src/plugins/__tests__/symlink.test.ts b/packages/content/src/plugins/__tests__/symlink.test.ts new file mode 100644 index 00000000..207cd079 --- /dev/null +++ b/packages/content/src/plugins/__tests__/symlink.test.ts @@ -0,0 +1,210 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { vol } from "memfs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import symlink from "../symlink.js"; +import { createTestPluginContext } from "./plugins.test-utils.js"; + +describe("symlink plugin", () => { + beforeEach(() => { + vol.reset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create symlink from target to path", async () => { + // Setup test files + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + }); + + vi.spyOn(fs, "symlink"); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(fs.symlink).toHaveBeenCalledWith(path.resolve("source"), path.resolve("destination")); + }); + + it("should throw error if target does not exist", async () => { + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "nonexistent", + path: "destination", + }); + + await expect(plugin.hooks.onContentFetchDone(ctx)).rejects.toThrow("Target directory"); + }); + + it("should skip if symlink path already exists", async () => { + // Setup target + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + vol.symlinkSync("source", "destination"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + }); + + vi.spyOn(fs, "symlink"); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(fs.symlink).not.toHaveBeenCalled(); + }); + + it("should remove existing non-symlink path before creating symlink", async () => { + // Setup target + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + vol.mkdirSync("destination/something/else", { recursive: true }); + vol.writeFileSync("destination/something/else/file.txt", "old content"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + }); + + vi.spyOn(fs, "rm"); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(fs.rm).toHaveBeenCalledWith(path.resolve("destination"), { + recursive: true, + force: true, + }); + + expect(vol.existsSync("destination")).toBe(true); + expect(vol.lstatSync("destination").isSymbolicLink()).toBe(true); + expect(vol.readFileSync("destination/file.txt", "utf-8")).toBe("test content"); + expect(vol.existsSync("destination/something/else")).toBe(false); + }); + + it("should skip when condition is false", async () => { + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + condition: false, + }); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(vol.existsSync("destination")).not.toBe(true); + }); + + it("should create symlink when condition is true", async () => { + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + condition: true, + }); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(vol.existsSync("destination")).toBe(true); + expect(vol.lstatSync("destination").isSymbolicLink()).toBe(true); + expect(vol.readFileSync("destination/file.txt", "utf-8")).toBe("test content"); + }); + + it("should handle function condition that returns boolean", async () => { + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + + const ctx = await createTestPluginContext(); + + const conditionFn = vi.fn().mockReturnValue(true); + const plugin = symlink({ + target: "source", + path: "destination", + condition: conditionFn, + }); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(conditionFn).toHaveBeenCalled(); + + expect(vol.existsSync("destination")).toBe(true); + expect(vol.lstatSync("destination").isSymbolicLink()).toBe(true); + expect(vol.readFileSync("destination/file.txt", "utf-8")).toBe("test content"); + }); + + it("should handle function condition that returns promise", async () => { + vol.mkdirSync("/download/source", { recursive: true }); + vol.writeFileSync("/download/source/file.txt", "test content"); + + const ctx = await createTestPluginContext(); + + const conditionFn = vi.fn().mockResolvedValueOnce(false); + const plugin = symlink({ + target: "source", + path: "destination", + condition: conditionFn, + }); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(conditionFn).toHaveBeenCalled(); + expect(vol.existsSync("destination")).not.toBe(true); + }); + + it("should throw error if symlink creation fails", async () => { + vol.mkdirSync("source", { recursive: true }); + vol.writeFileSync("source/file.txt", "test content"); + + // Mock fs.symlink to throw an error + vi.spyOn(fs, "symlink").mockRejectedValueOnce(new Error("Permission denied")); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: "source", + path: "destination", + }); + + await expect(plugin.hooks.onContentFetchDone(ctx)).rejects.toThrow("Failed to create symlink"); + }); + + it("should handle absolute paths", async () => { + const absoluteTarget = "/absolute/source"; + const absolutePath = "/absolute/destination"; + + vol.mkdirSync(absoluteTarget, { recursive: true }); + vol.writeFileSync(path.join(absoluteTarget, "file.txt"), "test content"); + + const ctx = await createTestPluginContext(); + + const plugin = symlink({ + target: absoluteTarget, + path: absolutePath, + }); + + await plugin.hooks.onContentFetchDone(ctx); + + expect(vol.existsSync("/absolute/destination")).toBe(true); + expect(vol.lstatSync("/absolute/destination").isSymbolicLink()).toBe(true); + expect(vol.readFileSync("/absolute/destination/file.txt", "utf-8")).toBe("test content"); + }); +}); diff --git a/packages/content/src/plugins/index.ts b/packages/content/src/plugins/index.ts index 1ddb4e21..a85c0176 100644 --- a/packages/content/src/plugins/index.ts +++ b/packages/content/src/plugins/index.ts @@ -5,3 +5,4 @@ export { default as sanityToHtml } from "./sanity-to-html.js"; export { default as sanityToMd } from "./sanity-to-markdown.js"; export { default as sanityToPlain } from "./sanity-to-plain.js"; export { default as sharp } from "./sharp.js"; +export { default as symlink } from "./symlink.js"; diff --git a/packages/content/src/plugins/symlink.ts b/packages/content/src/plugins/symlink.ts new file mode 100644 index 00000000..dd7160dd --- /dev/null +++ b/packages/content/src/plugins/symlink.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import chalk from "chalk"; +import { z } from "zod"; +import { defineContentPlugin } from "../content-plugin-driver.js"; +import { parsePluginConfig } from "./contentPluginHelpers.js"; + +const symlinkSchema = z.object({ + /** Symlink target (source file/directory) */ + target: z.string().describe("Symlink target (source file/directory)"), + /** Symlink path (destination file/directory) */ + path: z.string().describe("Symlink path (destination file/directory)"), + /** Condition to check before creating symlink */ + condition: z + .union([ + z.boolean(), + z + .function() + .args() + .returns(z.union([z.promise(z.boolean()), z.boolean()])), + ]) + .optional() + .describe("Condition to check before creating symlink"), +}); + +export default function symlink(options: z.input) { + const { + target, + path: symlinkPath, + condition, + } = parsePluginConfig("symlink", symlinkSchema, options); + + return defineContentPlugin({ + name: "symlink", + hooks: { + async onContentFetchDone(ctx) { + if (typeof condition === "function") { + const conditionResult = await condition(); + if (!conditionResult) return; + } else if (condition === false) { + return; + } + + const resolvedTarget = path.resolve(ctx.cwd, target); + const resolvedPath = path.resolve(ctx.cwd, symlinkPath); + + // ensure target exists + await fs.access(resolvedTarget).catch((e) => { + throw new Error(`Target directory ${chalk.gray(resolvedTarget)} does not exist.`, { + cause: e, + }); + }); + + const pathStats = await fs.lstat(resolvedPath).catch(() => null); + + if (pathStats) { + if (pathStats.isSymbolicLink()) { + ctx.logger.info(`symlink path ${chalk.gray(resolvedPath)} already exists, skipping`); + return; + } + ctx.logger.warn( + `symlink path ${chalk.gray(resolvedPath)} exists and is not a symlink. Removing it before creating symlink.`, + ); + await fs.rm(resolvedPath, { recursive: true, force: true }); + } + + ctx.logger.info( + `Creating symlink from ${chalk.gray(resolvedTarget)} to ${chalk.gray(resolvedPath)}`, + ); + + try { + // create parent directory of path if it doesn't exist + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.symlink(resolvedTarget, resolvedPath); + } catch (error) { + throw new Error(`Failed to create symlink ${resolvedTarget} -> ${resolvedPath}`, { + cause: error, + }); + } + }, + }, + }); +}