Skip to content

Commit

Permalink
feat: config update util (#162)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 committed Jun 11, 2024
1 parent 213d9fa commit 8d4bf3e
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 4 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules
coverage
dist
types
.tmp
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ c12 (pronounced as /siːtwelv/, like c-twelve) is a smart configuration loader.
- [Extends configurations](https://github.com/unjs/c12#extending-configuration) from multiple local or git sources
- Overwrite with [environment-specific configuration](#environment-specific-configuration)
- Config watcher with auto-reload and HMR support
- Create or update configuration files with [magicast](https://github.com/unjs/magicast)

## 🦴 Used by

Expand All @@ -36,7 +37,7 @@ c12 (pronounced as /siːtwelv/, like c-twelve) is a smart configuration loader.

Install package:

<!-- automd:pm-install no-version -->
<!-- automd:pm-install -->

```sh
# ✨ Auto-detect
Expand Down Expand Up @@ -376,6 +377,56 @@ console.log("initial config", config.config);
// await config.unwatch();
```

## Updating config

> [!NOTE]
> This feature is experimental
Update or create a new configuration files.

Add `magicast` peer dependency:

<!-- automd:pm-install name="magicast" dev -->

```sh
# ✨ Auto-detect
npx nypm install -D magicast

# npm
npm install -D magicast

# yarn
yarn add -D magicast

# pnpm
pnpm install -D magicast

# bun
bun install -D magicast
```

<!-- /automd -->

Import util from `c12/update`

```js
const { configFile, created } = await updateConfig({
cwd: ".",
configFile: "foo.config",
onCreate: ({ configFile }) => {
// You can prompt user if wants to create a new config file and return false to cancel
console.log(`Creating new config file in ${configFile}...`);
return "export default { test: true }";
},
onUpdate: (config) => {
// You can update the config contents just like an object
config.test2 = false;
},
});

console.log(`Config file ${created ? "created" : "updated"} in ${configFile}`);
```

## Contribution

<details>
Expand Down
19 changes: 17 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./update": {
"import": "./dist/update.mjs",
"require": "./dist/update.cjs",
"types": "./dist/update.d.ts"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
"dist",
"update.d.ts"
],
"scripts": {
"build": "automd && unbuild",
Expand Down Expand Up @@ -51,10 +57,19 @@
"eslint": "^9.4.0",
"eslint-config-unjs": "^0.3.2",
"expect-type": "^0.19.0",
"magicast": "^0.3.4",
"prettier": "^3.3.2",
"typescript": "^5.4.5",
"unbuild": "^2.0.0",
"vitest": "^1.6.0"
},
"peerDependencies": {
"magicast": "^0.3.4"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
},
"packageManager": "pnpm@9.3.0"
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

138 changes: 138 additions & 0 deletions src/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { resolvePath as _resolvePath } from "mlly";
import { SUPPORTED_EXTENSIONS } from "./loader";
import { join } from "pathe";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, extname } from "node:path";

const UPDATABLE_EXTS = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"] as const;

/**
* @experimental Update a config file or create a new one.
*/
export async function updateConfig(
opts: UpdateConfigOptions,
): Promise<UpdateConfigResult> {
const { parseModule } = await import("magicast");

// Try to find an existing config file
let configFile =
(await _tryResolve(opts.configFile, opts.cwd, SUPPORTED_EXTENSIONS)) ||
(await _tryResolve(
`.config/${opts.configFile}`,
opts.cwd,
SUPPORTED_EXTENSIONS,
));

// If not found
let created = false;
if (!configFile) {
configFile = join(
opts.cwd,
opts.configFile + (opts.createExtension || ".ts"),
);
const createResult =
(await opts.onCreate?.({ configFile: configFile })) || true;
if (!createResult) {
throw new Error("Config file creation aborted.");
}
const content =
typeof createResult === "string" ? createResult : `export default {}\n`;
await mkdir(dirname(configFile), { recursive: true });
await writeFile(configFile, content, "utf8");
created = true;
}

// Make sure extension is editable
const ext = extname(configFile);
if (!UPDATABLE_EXTS.includes(ext as any)) {
throw new Error(
`Unsupported config file extension: ${ext} (${configFile}) (supported: ${UPDATABLE_EXTS.join(", ")})`,
);
}

const contents = await readFile(configFile, "utf8");
const _module = parseModule(contents, opts.magicast);

const defaultExport = _module.exports.default;
if (!defaultExport) {
throw new Error("Default export is missing in the config file!");
}
const configObj =
defaultExport.$type === "function-call"
? defaultExport.$args[0]
: defaultExport;

opts.onUpdate?.(configObj);

await writeFile(configFile, _module.generate().code);

return {
configFile,
created,
};
}

// --- Internal ---

function _tryResolve(path: string, cwd: string, exts: readonly string[]) {
return _resolvePath(path, {
url: cwd,
extensions: exts as string[],
}).catch(() => undefined);
}

// --- Types ---

export interface UpdateConfigResult {
configFile?: string;
created?: boolean;
}

type MaybePromise<T> = T | Promise<T>;

type MagicAstOptions = Exclude<
Parameters<(typeof import("magicast"))["parseModule"]>[1],
undefined
>;

export interface UpdateConfigOptions {
/**
* Current working directory
*/
cwd: string;

/**
* Config file name
*/
configFile: string;

/**
* Extension used for new config file.
*/
createExtension?: string;

/**
* Magicast options
*/
magicast?: MagicAstOptions;

/**
* Update function.
*/
onUpdate?: (config: any) => MaybePromise<void>;

/**
* Handle default config creation.
*
* Tip: you can use this option as a hook to prompt users about config creation.
*
* Context object:
* - path: determined full path to the config file
*
* Returns types:
* - string: custom config template
* - true: write the template
* - false: abort the operation
*/
onCreate?: (ctx: { configFile: string }) => MaybePromise<string | boolean>;
}
2 changes: 1 addition & 1 deletion test/index.test.ts → test/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const r = (path: string) =>
const transformPaths = (object: object) =>
JSON.parse(JSON.stringify(object).replaceAll(r("."), "<path>/"));

describe("c12", () => {
describe("loader", () => {
it("load fixture config", async () => {
type UserConfig = Partial<{
virtual: boolean;
Expand Down
42 changes: 42 additions & 0 deletions test/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { fileURLToPath } from "node:url";
import { expect, it, describe, beforeAll } from "vitest";
import { normalize } from "pathe";
import { updateConfig } from "../src/update";
import { readFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";

const r = (path: string) =>
normalize(fileURLToPath(new URL(path, import.meta.url)));

describe("update config file", () => {
const tmpDir = r("./.tmp");
beforeAll(async () => {
await rm(tmpDir, { recursive: true }).catch(() => {});
});
it("create new config", async () => {
let onCreateFile;
const res = await updateConfig({
cwd: tmpDir,
configFile: "foo.config",
onCreate: ({ configFile }) => {
onCreateFile = configFile;
return "export default { test: true }";
},
onUpdate: (config) => {
config.test2 = false;
},
});
expect(res.created).toBe(true);
expect(res.configFile).toBe(r("./.tmp/foo.config.ts"));
expect(onCreateFile).toBe(r("./.tmp/foo.config.ts"));

expect(existsSync(r("./.tmp/foo.config.ts"))).toBe(true);
const contents = await readFile(r("./.tmp/foo.config.ts"), "utf8");
expect(contents).toMatchInlineSnapshot(`
"export default {
test: true,
test2: false
};"
`);
});
});
1 change: 1 addition & 0 deletions update.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dist/update";

0 comments on commit 8d4bf3e

Please sign in to comment.