Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stupid-suits-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bluecadet/launchpad-content": minor
---

Add symlink plugin
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
51 changes: 51 additions & 0 deletions docs/src/reference/content/plugins/symlink.md
Original file line number Diff line number Diff line change
@@ -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<boolean>)`
- **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.
210 changes: 210 additions & 0 deletions packages/content/src/plugins/__tests__/symlink.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions packages/content/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
83 changes: 83 additions & 0 deletions packages/content/src/plugins/symlink.ts
Original file line number Diff line number Diff line change
@@ -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<typeof symlinkSchema>) {
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,
});
}
},
},
});
}