Skip to content

Commit 2f4029a

Browse files
authored
feat(config): add unwrapSingleRootDir (#15)
* feat(config): add unwrapSingleRootDir * feat: implement unwrapSingleRootDir * docs: update README * ci: add precheck and typecheck * refactor: improve type safety * feat(config): add unwrapSingleRootDir option * perf(materialize): use slice for prefix removal * feat(test): add checkout arg handling * fix: test * fix: test
1 parent dca81ab commit 2f4029a

21 files changed

Lines changed: 524 additions & 148 deletions

.github/workflows/ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,47 @@ on:
88
- master
99

1010
jobs:
11+
precheck:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Setup pnpm
18+
uses: pnpm/action-setup@v4
19+
with:
20+
run_install: false
21+
22+
- name: Setup Node
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version: 22
26+
cache: pnpm
27+
28+
- name: Install dependencies
29+
run: pnpm install --frozen-lockfile
30+
31+
- name: Audit dependencies
32+
run: pnpm audit --audit-level=high
33+
34+
- name: Lint
35+
run: pnpm lint
36+
37+
- name: Typecheck
38+
run: pnpm typecheck
39+
40+
- name: Test
41+
run: pnpm test
42+
43+
- name: Build
44+
run: pnpm build
45+
46+
- name: Size limit
47+
run: pnpm size
48+
1149
build:
50+
needs: precheck
51+
if: needs.precheck.result == 'success'
1252
runs-on: ${{ matrix.os }}
1353
strategy:
1454
fail-fast: false

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,12 @@ All fields in `defaults` apply to all sources unless overridden per-source.
9494
| `mode` | Cache mode. Default: `"materialize"`. |
9595
| `include` | Glob patterns to copy. Default: `["**/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc}"]`. |
9696
| `targetMode` | How to link or copy from the cache to the destination. Default: `"symlink"` on Unix, `"copy"` on Windows. |
97-
| `depth` | Git clone depth. Default: `1`. |
9897
| `required` | Whether missing sources should fail. Default: `true`. |
9998
| `maxBytes` | Maximum total bytes to materialize. Default: `200000000` (200 MB). |
10099
| `maxFiles` | Maximum total files to materialize. |
101100
| `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com"]`. |
102101
| `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format (`"tree"`, `"compressed"`). |
102+
| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). Default: `false`. |
103103

104104
### Source options
105105

@@ -123,6 +123,7 @@ All fields in `defaults` apply to all sources unless overridden per-source.
123123
| `maxBytes` | Maximum total bytes to materialize. |
124124
| `maxFiles` | Maximum total files to materialize. |
125125
| `toc` | Generate per-source `TOC.md`. Supports `true`, `false`, or a format (`"tree"`, `"compressed"`). |
126+
| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). |
126127

127128
> **Note**: Sources are always downloaded to `.docs/<id>/`. If you provide a `targetDir`, `docs-cache` will create a symlink or copy pointing from the cache to that target directory. The target should be outside `.docs`. Git operation timeout is configured via the `--timeout-ms` CLI flag, not as a per-source configuration option.
128129

docs.config.schema.json

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@
3737
"type": "string",
3838
"enum": ["symlink", "copy"]
3939
},
40-
"depth": {
41-
"type": "number",
42-
"minimum": 1
43-
},
4440
"required": {
4541
"type": "boolean"
4642
},
@@ -60,6 +56,9 @@
6056
"minLength": 1
6157
}
6258
},
59+
"unwrapSingleRootDir": {
60+
"type": "boolean"
61+
},
6362
"toc": {
6463
"anyOf": [
6564
{
@@ -107,10 +106,6 @@
107106
"type": "string",
108107
"enum": ["materialize"]
109108
},
110-
"depth": {
111-
"type": "number",
112-
"minimum": 1
113-
},
114109
"include": {
115110
"type": "array",
116111
"items": {
@@ -171,6 +166,9 @@
171166
"tocFormat": {
172167
"type": "string",
173168
"enum": ["tree", "compressed"]
169+
},
170+
"unwrapSingleRootDir": {
171+
"type": "boolean"
174172
}
175173
},
176174
"required": ["id", "repo"],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
}
8585
],
8686
"simple-git-hooks": {
87-
"pre-commit": "pnpm lint-staged"
87+
"pre-commit": "pnpm lint-staged && pnpm typecheck"
8888
},
8989
"lint-staged": {
9090
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [

src/cli/index.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import process from "node:process";
33
import pc from "picocolors";
44
import { ExitCode } from "./exit-code";
55
import { parseArgs } from "./parse-args";
6-
import type { CliOptions } from "./types";
6+
import type { CliCommand } from "./types";
77
import { setSilentMode, symbols, ui } from "./ui";
88

99
export const CLI_NAME = "docs-cache";
@@ -93,12 +93,10 @@ const parseAddEntries = (rawArgs: string[]) => {
9393
return entries;
9494
};
9595

96-
const runCommand = async (
97-
command: string,
98-
options: CliOptions,
99-
positionals: string[],
100-
rawArgs: string[],
101-
) => {
96+
const runCommand = async (parsed: CliCommand, rawArgs: string[]) => {
97+
const command = parsed.command;
98+
const options = parsed.options;
99+
const positionals = parsed.args;
102100
if (command === "add") {
103101
const { addSources } = await import("../add");
104102
const { runSync } = await import("../sync");
@@ -380,12 +378,7 @@ export async function main(): Promise<void> {
380378
process.exit(ExitCode.InvalidArgument);
381379
}
382380

383-
await runCommand(
384-
parsed.command,
385-
parsed.options,
386-
parsed.positionals,
387-
parsed.rawArgs,
388-
);
381+
await runCommand(parsed.parsed, parsed.rawArgs);
389382
} catch (error) {
390383
errorHandler(error as Error);
391384
}

src/cli/parse-args.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import process from "node:process";
22

33
import cac from "cac";
44
import { ExitCode } from "./exit-code";
5-
import type { CliOptions } from "./types";
5+
import type { CliCommand, CliOptions } from "./types";
66

77
const COMMANDS = [
88
"add",
@@ -23,6 +23,7 @@ export type ParsedArgs = {
2323
positionals: string[];
2424
rawArgs: string[];
2525
help: boolean;
26+
parsed: CliCommand;
2627
};
2728

2829
export const parseArgs = (argv = process.argv): ParsedArgs => {
@@ -83,6 +84,11 @@ export const parseArgs = (argv = process.argv): ParsedArgs => {
8384
positionals: result.args.slice(1),
8485
rawArgs,
8586
help: Boolean(result.options.help),
87+
parsed: {
88+
command: command ?? null,
89+
args: result.args.slice(1),
90+
options,
91+
},
8692
};
8793
} catch (error) {
8894
const message = error instanceof Error ? error.message : String(error);

src/cli/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,15 @@ export type CliOptions = {
1111
timeoutMs?: number;
1212
silent: boolean;
1313
};
14+
15+
export type CliCommand =
16+
| { command: "add"; args: string[]; options: CliOptions }
17+
| { command: "remove"; args: string[]; options: CliOptions }
18+
| { command: "sync"; args: string[]; options: CliOptions }
19+
| { command: "status"; args: string[]; options: CliOptions }
20+
| { command: "clean"; args: string[]; options: CliOptions }
21+
| { command: "clean-cache"; args: string[]; options: CliOptions }
22+
| { command: "prune"; args: string[]; options: CliOptions }
23+
| { command: "verify"; args: string[]; options: CliOptions }
24+
| { command: "init"; args: string[]; options: CliOptions }
25+
| { command: null; args: string[]; options: CliOptions };

src/config-schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ export const DefaultsSchema = z
1616
mode: CacheModeSchema,
1717
include: z.array(z.string().min(1)).min(1),
1818
targetMode: TargetModeSchema.optional(),
19-
depth: z.number().min(1),
2019
required: z.boolean(),
2120
maxBytes: z.number().min(1),
2221
maxFiles: z.number().min(1).optional(),
2322
allowHosts: z.array(z.string().min(1)).min(1),
2423
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
24+
unwrapSingleRootDir: z.boolean().optional(),
2525
})
2626
.strict();
2727

@@ -33,14 +33,14 @@ export const SourceSchema = z
3333
targetMode: TargetModeSchema.optional(),
3434
ref: z.string().min(1).optional(),
3535
mode: CacheModeSchema.optional(),
36-
depth: z.number().min(1).optional(),
3736
include: z.array(z.string().min(1)).optional(),
3837
exclude: z.array(z.string().min(1)).optional(),
3938
required: z.boolean().optional(),
4039
maxBytes: z.number().min(1).optional(),
4140
maxFiles: z.number().min(1).optional(),
4241
integrity: IntegritySchema.optional(),
4342
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
43+
unwrapSingleRootDir: z.boolean().optional(),
4444
})
4545
.strict();
4646

src/config.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ export interface DocsCacheDefaults {
2020
mode: CacheMode;
2121
include: string[];
2222
targetMode?: "symlink" | "copy";
23-
depth: number;
2423
required: boolean;
2524
maxBytes: number;
2625
maxFiles?: number;
2726
allowHosts: string[];
2827
toc?: boolean | TocFormat;
28+
unwrapSingleRootDir?: boolean;
2929
}
3030

3131
export interface DocsCacheSource {
@@ -35,14 +35,14 @@ export interface DocsCacheSource {
3535
targetMode?: "symlink" | "copy";
3636
ref?: string;
3737
mode?: CacheMode;
38-
depth?: number;
3938
include?: string[];
4039
exclude?: string[];
4140
required?: boolean;
4241
maxBytes?: number;
4342
maxFiles?: number;
4443
integrity?: DocsCacheIntegrity;
4544
toc?: boolean | TocFormat;
45+
unwrapSingleRootDir?: boolean;
4646
}
4747

4848
export interface DocsCacheConfig {
@@ -60,14 +60,14 @@ export interface DocsCacheResolvedSource {
6060
targetMode?: "symlink" | "copy";
6161
ref: string;
6262
mode: CacheMode;
63-
depth: number;
6463
include?: string[];
6564
exclude?: string[];
6665
required: boolean;
6766
maxBytes: number;
6867
maxFiles?: number;
6968
integrity?: DocsCacheIntegrity;
7069
toc?: boolean | TocFormat;
70+
unwrapSingleRootDir?: boolean;
7171
}
7272

7373
export const DEFAULT_CONFIG_FILENAME = "docs.config.json";
@@ -81,11 +81,11 @@ export const DEFAULT_CONFIG: DocsCacheConfig = {
8181
mode: "materialize",
8282
include: ["**/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc}"],
8383
targetMode: DEFAULT_TARGET_MODE,
84-
depth: 1,
8584
required: true,
8685
maxBytes: 200000000,
8786
allowHosts: ["github.com", "gitlab.com"],
8887
toc: true,
88+
unwrapSingleRootDir: false,
8989
},
9090
sources: [],
9191
};
@@ -246,15 +246,16 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
246246
.join("; ");
247247
throw new Error(`Config does not match schema: ${details}.`);
248248
}
249+
const configInput = parsed.data;
249250

250-
const cacheDir = input.cacheDir
251-
? assertString(input.cacheDir, "cacheDir")
251+
const cacheDir = configInput.cacheDir
252+
? assertString(configInput.cacheDir, "cacheDir")
252253
: DEFAULT_CACHE_DIR;
253254

254-
const defaultsInput = input.defaults;
255+
const defaultsInput = configInput.defaults;
255256
const targetModeOverride =
256-
input.targetMode !== undefined
257-
? assertTargetMode(input.targetMode, "targetMode")
257+
configInput.targetMode !== undefined
258+
? assertTargetMode(configInput.targetMode, "targetMode")
258259
: undefined;
259260
const defaultValues = DEFAULT_CONFIG.defaults as DocsCacheDefaults;
260261
let defaults: DocsCacheDefaults = defaultValues;
@@ -280,10 +281,6 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
280281
defaultsInput.targetMode !== undefined
281282
? assertTargetMode(defaultsInput.targetMode, "defaults.targetMode")
282283
: (targetModeOverride ?? defaultValues.targetMode),
283-
depth:
284-
defaultsInput.depth !== undefined
285-
? assertPositiveNumber(defaultsInput.depth, "defaults.depth")
286-
: defaultValues.depth,
287284
required:
288285
defaultsInput.required !== undefined
289286
? assertBoolean(defaultsInput.required, "defaults.required")
@@ -304,6 +301,13 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
304301
defaultsInput.toc !== undefined
305302
? (defaultsInput.toc as boolean | TocFormat)
306303
: defaultValues.toc,
304+
unwrapSingleRootDir:
305+
defaultsInput.unwrapSingleRootDir !== undefined
306+
? assertBoolean(
307+
defaultsInput.unwrapSingleRootDir,
308+
"defaults.unwrapSingleRootDir",
309+
)
310+
: defaultValues.unwrapSingleRootDir,
307311
};
308312
} else if (targetModeOverride !== undefined) {
309313
defaults = {
@@ -312,11 +316,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
312316
};
313317
}
314318

315-
if (!Array.isArray(input.sources)) {
316-
throw new Error("sources must be an array.");
317-
}
318-
319-
const sources = input.sources.map((entry, index) => {
319+
const sources = configInput.sources.map((entry, index) => {
320320
if (!isRecord(entry)) {
321321
throw new Error(`sources[${index}] must be an object.`);
322322
}
@@ -348,12 +348,6 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
348348
if (entry.mode !== undefined) {
349349
source.mode = assertMode(entry.mode, `sources[${index}].mode`);
350350
}
351-
if (entry.depth !== undefined) {
352-
source.depth = assertPositiveNumber(
353-
entry.depth,
354-
`sources[${index}].depth`,
355-
);
356-
}
357351
if (entry.include !== undefined) {
358352
source.include = assertStringArray(
359353
entry.include,
@@ -394,6 +388,12 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
394388
if (entry.toc !== undefined) {
395389
source.toc = entry.toc as boolean | TocFormat;
396390
}
391+
if (entry.unwrapSingleRootDir !== undefined) {
392+
source.unwrapSingleRootDir = assertBoolean(
393+
entry.unwrapSingleRootDir,
394+
`sources[${index}].unwrapSingleRootDir`,
395+
);
396+
}
397397

398398
return source;
399399
});
@@ -433,14 +433,15 @@ export const resolveSources = (
433433
targetMode: source.targetMode ?? defaults.targetMode,
434434
ref: source.ref ?? defaults.ref,
435435
mode: source.mode ?? defaults.mode,
436-
depth: source.depth ?? defaults.depth,
437436
include: source.include ?? defaults.include,
438437
exclude: source.exclude,
439438
required: source.required ?? defaults.required,
440439
maxBytes: source.maxBytes ?? defaults.maxBytes,
441440
maxFiles: source.maxFiles ?? defaults.maxFiles,
442441
integrity: source.integrity,
443442
toc: source.toc ?? defaults.toc,
443+
unwrapSingleRootDir:
444+
source.unwrapSingleRootDir ?? defaults.unwrapSingleRootDir,
444445
}));
445446
};
446447

0 commit comments

Comments
 (0)