md-bundle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/SPEC.md +41 -0
- package/dist/bundle.d.ts +26 -0
- package/dist/bundle.js +73 -0
- package/dist/error.d.ts +12 -0
- package/dist/error.js +18 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/io.d.ts +13 -0
- package/dist/io.js +123 -0
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +71 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.js +18 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Holden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# md-bundle
|
|
2
|
+
|
|
3
|
+
A Markdown bundle is a root Markdown file plus optional related files in the
|
|
4
|
+
same folder tree, addressed by bundle-relative paths.
|
|
5
|
+
|
|
6
|
+
The root file stays the entry point. Supporting Markdown files, scripts, and
|
|
7
|
+
assets let the bundle grow by progressive disclosure instead of becoming one
|
|
8
|
+
giant document.
|
|
9
|
+
|
|
10
|
+
An [agent skill](https://agentskills.io/) is one example of a Markdown bundle:
|
|
11
|
+
`SKILL.md` is the root file, while references, scripts, and assets can live
|
|
12
|
+
beside it. `md-bundle` makes that folder shape reusable for skills, agent
|
|
13
|
+
instructions, docs, specs, and other Markdown artifacts.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install md-bundle
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```txt
|
|
24
|
+
my-skill/
|
|
25
|
+
SKILL.md
|
|
26
|
+
references/checklist.md
|
|
27
|
+
assets/logo.png
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { getTextFile, loadBundle } from "md-bundle";
|
|
32
|
+
|
|
33
|
+
const bundle = await loadBundle("my-skill/SKILL.md");
|
|
34
|
+
|
|
35
|
+
const root = bundle.root.content;
|
|
36
|
+
const checklist = getTextFile(bundle, "references/checklist.md");
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Bundle Model
|
|
40
|
+
|
|
41
|
+
A bundle has one root Markdown file and zero or more related files beside it.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
type MarkdownBundle = {
|
|
45
|
+
root: MarkdownBundleTextFile;
|
|
46
|
+
files: MarkdownBundleFile[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type MarkdownBundleFile = MarkdownBundleTextFile | MarkdownBundleBinaryFile;
|
|
50
|
+
|
|
51
|
+
type MarkdownBundleTextFile = {
|
|
52
|
+
path: string;
|
|
53
|
+
content: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type MarkdownBundleBinaryFile = {
|
|
57
|
+
path: string;
|
|
58
|
+
bytes: Uint8Array;
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```txt
|
|
63
|
+
SKILL.md
|
|
64
|
+
references/checklist.md
|
|
65
|
+
assets/logo.png
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`root` is the text file clients should read first. `files` contains the complete
|
|
69
|
+
bundle, including the root file.
|
|
70
|
+
|
|
71
|
+
Files are addressed by paths relative to the bundle directory. Text files store
|
|
72
|
+
`content`. Binary files store `bytes`.
|
|
73
|
+
|
|
74
|
+
## API Reference
|
|
75
|
+
|
|
76
|
+
### Load
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
loadBundle(path, { rootPath }?);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Reads a bundle from a root Markdown file or directory.
|
|
83
|
+
|
|
84
|
+
When `path` is a file, that file is the bundle root and its parent directory is
|
|
85
|
+
the bundle directory. When `path` is a directory, `rootPath` can identify the
|
|
86
|
+
root file. If omitted, `loadBundle` tries to infer it.
|
|
87
|
+
|
|
88
|
+
### Create
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
createBundle({ rootPath, files });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Creates a normalized bundle from in-memory files.
|
|
95
|
+
|
|
96
|
+
### Get Files
|
|
97
|
+
|
|
98
|
+
- `getFile(bundle, bundlePath)`: returns a text or binary file.
|
|
99
|
+
- `getTextFile(bundle, bundlePath)`: returns a text file.
|
|
100
|
+
- `getBinaryFile(bundle, bundlePath)`: returns a binary file.
|
|
101
|
+
|
|
102
|
+
### File Guards
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
isTextFile(file);
|
|
106
|
+
isBinaryFile(file);
|
|
107
|
+
isMarkdownFile(file);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
These narrow `MarkdownBundleFile` when filtering or branching on file type.
|
|
111
|
+
|
|
112
|
+
### Bundle References
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
resolveBundleReference(fromPath, referencePath);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Resolves a reference written in one bundle file to a normalized bundle path.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
resolveBundleReference("docs/index.md", "./intro.md");
|
|
122
|
+
// "docs/intro.md"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
formatBundleReference(fromPath, targetPath);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Formats a bundle path as a relative reference from one file to another.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
formatBundleReference("docs/index.md", "assets/logo.png");
|
|
133
|
+
// "../assets/logo.png"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Write
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
writeBundle(bundle, directory);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Writes bundle files into `directory`, preserving bundle-relative paths.
|
|
143
|
+
|
|
144
|
+
### Errors
|
|
145
|
+
|
|
146
|
+
Expected failures throw `MarkdownBundleError` with a stable `code`.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
class MarkdownBundleError extends Error {
|
|
150
|
+
code: string;
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Spec
|
|
155
|
+
|
|
156
|
+
See [SPEC.md](./SPEC.md) for precise behavior around path normalization, root
|
|
157
|
+
inference, loading, references, and writing.
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# md-bundle spec
|
|
2
|
+
|
|
3
|
+
This spec defines behavior that is intentionally more precise than the README.
|
|
4
|
+
|
|
5
|
+
## Bundle Model
|
|
6
|
+
|
|
7
|
+
- Files are sorted lexicographically by path.
|
|
8
|
+
|
|
9
|
+
## Paths
|
|
10
|
+
|
|
11
|
+
- Bundle paths use `/` and are relative to the bundle directory.
|
|
12
|
+
- Stored file paths cannot be empty, absolute, or escape the bundle.
|
|
13
|
+
- `.` and `..` segments are resolved before storage.
|
|
14
|
+
|
|
15
|
+
## Construction
|
|
16
|
+
|
|
17
|
+
- `rootPath` must identify one text `.md` file in `files`.
|
|
18
|
+
- File paths must be valid and unique.
|
|
19
|
+
|
|
20
|
+
## Loading
|
|
21
|
+
|
|
22
|
+
- Without `rootPath`, loading infers the root from top-level Markdown files:
|
|
23
|
+
the only `.md` file, otherwise `SKILL.md`, otherwise `index.md`.
|
|
24
|
+
- Missing or ambiguous roots fail.
|
|
25
|
+
- Loading skips `.git` directories and does not follow symlinks.
|
|
26
|
+
- Valid UTF-8 files load as text; non-UTF-8 files load as binary.
|
|
27
|
+
|
|
28
|
+
## Bundle References
|
|
29
|
+
|
|
30
|
+
- References resolve from the directory of the file that contains them.
|
|
31
|
+
- References starting with `/` resolve from the bundle root.
|
|
32
|
+
- Reference resolution can return `""` for the bundle root.
|
|
33
|
+
- References cannot escape the bundle.
|
|
34
|
+
- Resolution does not check target existence.
|
|
35
|
+
- Formatted references are relative to the directory of the file that contains
|
|
36
|
+
them.
|
|
37
|
+
|
|
38
|
+
## Writing
|
|
39
|
+
|
|
40
|
+
- Parent directories are created, but existing output directories are not
|
|
41
|
+
deleted.
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MarkdownBundle, MarkdownBundleBinaryFile, MarkdownBundleFile, MarkdownBundleTextFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Input accepted by `createBundle`.
|
|
4
|
+
*/
|
|
5
|
+
export type CreateBundleInput = {
|
|
6
|
+
/** Bundle path of the root Markdown file. */
|
|
7
|
+
rootPath: string;
|
|
8
|
+
/** Files to normalize into the bundle. */
|
|
9
|
+
files: MarkdownBundleFile[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Create a normalized in-memory bundle.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createBundle(input: CreateBundleInput): MarkdownBundle;
|
|
15
|
+
/**
|
|
16
|
+
* Get any bundle file by path.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getFile(bundle: MarkdownBundle, bundlePath: string): MarkdownBundleFile;
|
|
19
|
+
/**
|
|
20
|
+
* Get a text bundle file by path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getTextFile(bundle: MarkdownBundle, bundlePath: string): MarkdownBundleTextFile;
|
|
23
|
+
/**
|
|
24
|
+
* Get a binary bundle file by path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getBinaryFile(bundle: MarkdownBundle, bundlePath: string): MarkdownBundleBinaryFile;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { fail } from "./error.js";
|
|
2
|
+
import { compareBundlePaths, normalizeBundleFilePath } from "./paths.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create a normalized in-memory bundle.
|
|
5
|
+
*/
|
|
6
|
+
export function createBundle(input) {
|
|
7
|
+
const rootPath = normalizeBundleFilePath(input.rootPath);
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const inputFile of input.files) {
|
|
11
|
+
const file = normalizeInputFile(inputFile);
|
|
12
|
+
if (seen.has(file.path)) {
|
|
13
|
+
fail("FILE_DUPLICATE", `Duplicate bundle file: ${file.path}`);
|
|
14
|
+
}
|
|
15
|
+
seen.add(file.path);
|
|
16
|
+
files.push(file);
|
|
17
|
+
}
|
|
18
|
+
const sortedFiles = files.sort((left, right) => compareBundlePaths(left.path, right.path));
|
|
19
|
+
const root = sortedFiles.find((file) => isTextFile(file) && file.path === rootPath && file.path.endsWith(".md"));
|
|
20
|
+
if (root === undefined) {
|
|
21
|
+
const matchingFile = sortedFiles.find((file) => file.path === rootPath);
|
|
22
|
+
if (matchingFile === undefined) {
|
|
23
|
+
fail("ROOT_MISSING", `Bundle root does not exist: ${rootPath}`);
|
|
24
|
+
}
|
|
25
|
+
fail("ROOT_INVALID", `Bundle root must be a text Markdown file: ${rootPath}`);
|
|
26
|
+
}
|
|
27
|
+
return { root, files: sortedFiles };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get any bundle file by path.
|
|
31
|
+
*/
|
|
32
|
+
export function getFile(bundle, bundlePath) {
|
|
33
|
+
const normalized = normalizeBundleFilePath(bundlePath);
|
|
34
|
+
const file = bundle.files.find((item) => item.path === normalized);
|
|
35
|
+
return file === undefined
|
|
36
|
+
? fail("FILE_MISSING", `Bundle file does not exist: ${normalized}`)
|
|
37
|
+
: file;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get a text bundle file by path.
|
|
41
|
+
*/
|
|
42
|
+
export function getTextFile(bundle, bundlePath) {
|
|
43
|
+
const file = getFile(bundle, bundlePath);
|
|
44
|
+
return isTextFile(file) ? file : fail("FILE_NOT_TEXT", `Bundle file is not text: ${file.path}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get a binary bundle file by path.
|
|
48
|
+
*/
|
|
49
|
+
export function getBinaryFile(bundle, bundlePath) {
|
|
50
|
+
const file = getFile(bundle, bundlePath);
|
|
51
|
+
return isBinaryFile(file)
|
|
52
|
+
? file
|
|
53
|
+
: fail("FILE_NOT_BINARY", `Bundle file is not binary: ${file.path}`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalize one caller-provided file into the stored bundle shape.
|
|
57
|
+
*/
|
|
58
|
+
function normalizeInputFile(file) {
|
|
59
|
+
const normalized = normalizeBundleFilePath(file.path);
|
|
60
|
+
if (isTextFile(file)) {
|
|
61
|
+
return { path: normalized, content: file.content };
|
|
62
|
+
}
|
|
63
|
+
if (isBinaryFile(file)) {
|
|
64
|
+
return { path: normalized, bytes: file.bytes };
|
|
65
|
+
}
|
|
66
|
+
fail("FILE_INVALID", `Invalid bundle file: ${file.path}`);
|
|
67
|
+
}
|
|
68
|
+
function isTextFile(file) {
|
|
69
|
+
return "content" in file && typeof file.content === "string" && !("bytes" in file);
|
|
70
|
+
}
|
|
71
|
+
function isBinaryFile(file) {
|
|
72
|
+
return "bytes" in file && file.bytes instanceof Uint8Array && !("content" in file);
|
|
73
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown by expected md-bundle failures.
|
|
3
|
+
*/
|
|
4
|
+
export declare class MarkdownBundleError<TCode extends string = string> extends Error {
|
|
5
|
+
/** Machine-readable error code. */
|
|
6
|
+
readonly code: TCode;
|
|
7
|
+
constructor(code: TCode, message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Throw an expected md-bundle failure with a stable error code.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fail<TCode extends string>(code: TCode, message: string): never;
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown by expected md-bundle failures.
|
|
3
|
+
*/
|
|
4
|
+
export class MarkdownBundleError extends Error {
|
|
5
|
+
/** Machine-readable error code. */
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "MarkdownBundleError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Throw an expected md-bundle failure with a stable error code.
|
|
15
|
+
*/
|
|
16
|
+
export function fail(code, message) {
|
|
17
|
+
throw new MarkdownBundleError(code, message);
|
|
18
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createBundle, getBinaryFile, getFile, getTextFile } from "./bundle.js";
|
|
2
|
+
export { MarkdownBundleError } from "./error.js";
|
|
3
|
+
export { loadBundle, writeBundle } from "./io.js";
|
|
4
|
+
export { formatBundleReference, resolveBundleReference } from "./paths.js";
|
|
5
|
+
export { isBinaryFile, isMarkdownFile, isTextFile } from "./types.js";
|
|
6
|
+
export type { CreateBundleInput } from "./bundle.js";
|
|
7
|
+
export type { LoadBundleOptions } from "./io.js";
|
|
8
|
+
export type { MarkdownBundle, MarkdownBundleBinaryFile, MarkdownBundleFile, MarkdownBundleTextFile, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createBundle, getBinaryFile, getFile, getTextFile } from "./bundle.js";
|
|
2
|
+
export { MarkdownBundleError } from "./error.js";
|
|
3
|
+
export { loadBundle, writeBundle } from "./io.js";
|
|
4
|
+
export { formatBundleReference, resolveBundleReference } from "./paths.js";
|
|
5
|
+
export { isBinaryFile, isMarkdownFile, isTextFile } from "./types.js";
|
package/dist/io.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MarkdownBundle } from "./types.js";
|
|
2
|
+
export type LoadBundleOptions = {
|
|
3
|
+
/** Bundle path of the root Markdown file. */
|
|
4
|
+
rootPath?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Load a bundle from a root file or bundle directory.
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadBundle(inputPath: string, options?: LoadBundleOptions): Promise<MarkdownBundle>;
|
|
10
|
+
/**
|
|
11
|
+
* Write bundle files into a directory.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeBundle(bundle: MarkdownBundle, directory: string): Promise<void>;
|
package/dist/io.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createBundle } from "./bundle.js";
|
|
4
|
+
import { fail } from "./error.js";
|
|
5
|
+
import { compareBundlePaths, normalizeBundleFilePath } from "./paths.js";
|
|
6
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
|
+
/**
|
|
8
|
+
* Load a bundle from a root file or bundle directory.
|
|
9
|
+
*/
|
|
10
|
+
export async function loadBundle(inputPath, options = {}) {
|
|
11
|
+
let inputStats;
|
|
12
|
+
try {
|
|
13
|
+
inputStats = await lstat(inputPath);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
fail("BUNDLE_LOAD_ERROR", `Could not read bundle path: ${inputPath}`);
|
|
17
|
+
}
|
|
18
|
+
if (inputStats.isSymbolicLink()) {
|
|
19
|
+
fail("BUNDLE_LOAD_ERROR", `Bundle path cannot be a symlink: ${inputPath}`);
|
|
20
|
+
}
|
|
21
|
+
if (!inputStats.isDirectory() && !inputStats.isFile()) {
|
|
22
|
+
fail("BUNDLE_LOAD_ERROR", `Bundle path is not a file or directory: ${inputPath}`);
|
|
23
|
+
}
|
|
24
|
+
const bundleDirectory = inputStats.isDirectory() ? inputPath : path.dirname(inputPath);
|
|
25
|
+
const defaultRootPath = inputStats.isFile()
|
|
26
|
+
? path.basename(inputPath).replaceAll("\\", "/")
|
|
27
|
+
: undefined;
|
|
28
|
+
const files = await loadBundleFiles(bundleDirectory);
|
|
29
|
+
const rootPath = options.rootPath ?? defaultRootPath;
|
|
30
|
+
if (rootPath !== undefined) {
|
|
31
|
+
return createBundle({ rootPath, files });
|
|
32
|
+
}
|
|
33
|
+
const inferredRootPath = inferRootPath(files);
|
|
34
|
+
return createBundle({ rootPath: inferredRootPath, files });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write bundle files into a directory.
|
|
38
|
+
*/
|
|
39
|
+
export async function writeBundle(bundle, directory) {
|
|
40
|
+
for (const file of bundle.files) {
|
|
41
|
+
const bundlePath = normalizeBundleFilePath(file.path);
|
|
42
|
+
const outputPath = path.join(directory, ...bundlePath.split("/"));
|
|
43
|
+
try {
|
|
44
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
45
|
+
await writeFile(outputPath, isTextFile(file) ? file.content : file.bytes);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
fail("BUNDLE_WRITE_ERROR", `Could not write bundle file: ${bundlePath}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Load every regular file below a bundle directory.
|
|
54
|
+
*/
|
|
55
|
+
async function loadBundleFiles(directory) {
|
|
56
|
+
const files = [];
|
|
57
|
+
async function visit(currentDirectory) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(currentDirectory, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
fail("BUNDLE_LOAD_ERROR", `Could not read directory: ${currentDirectory}`);
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries.sort((left, right) => compareBundlePaths(left.name, right.name))) {
|
|
66
|
+
if ((entry.name === ".git" && entry.isDirectory()) || entry.isSymbolicLink())
|
|
67
|
+
continue;
|
|
68
|
+
const absolutePath = path.join(currentDirectory, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
await visit(absolutePath);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!entry.isFile())
|
|
74
|
+
continue;
|
|
75
|
+
let bytes;
|
|
76
|
+
try {
|
|
77
|
+
bytes = await readFile(absolutePath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
fail("BUNDLE_LOAD_ERROR", `Could not read file: ${absolutePath}`);
|
|
81
|
+
}
|
|
82
|
+
const bundlePath = path.relative(directory, absolutePath).replaceAll("\\", "/");
|
|
83
|
+
const text = decodeUtf8(bytes);
|
|
84
|
+
files.push(text !== undefined
|
|
85
|
+
? { path: bundlePath, content: text }
|
|
86
|
+
: { path: bundlePath, bytes: new Uint8Array(bytes) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await visit(directory);
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Infer a conventional root from top-level Markdown files.
|
|
94
|
+
*/
|
|
95
|
+
function inferRootPath(files) {
|
|
96
|
+
const topLevelMarkdown = files
|
|
97
|
+
.map((file) => normalizeBundleFilePath(file.path))
|
|
98
|
+
.filter((filePath) => !filePath.includes("/") && filePath.endsWith(".md"));
|
|
99
|
+
if (topLevelMarkdown.length === 1) {
|
|
100
|
+
return topLevelMarkdown[0];
|
|
101
|
+
}
|
|
102
|
+
if (topLevelMarkdown.includes("SKILL.md")) {
|
|
103
|
+
return "SKILL.md";
|
|
104
|
+
}
|
|
105
|
+
if (topLevelMarkdown.includes("index.md")) {
|
|
106
|
+
return "index.md";
|
|
107
|
+
}
|
|
108
|
+
fail(topLevelMarkdown.length === 0 ? "ROOT_MISSING" : "ROOT_AMBIGUOUS", "Could not infer bundle root. Pass an explicit rootPath.");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Decode bytes only when they are valid UTF-8.
|
|
112
|
+
*/
|
|
113
|
+
function decodeUtf8(bytes) {
|
|
114
|
+
try {
|
|
115
|
+
return textDecoder.decode(bytes);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isTextFile(file) {
|
|
122
|
+
return "content" in file;
|
|
123
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a stored bundle file path, which cannot be the bundle root.
|
|
3
|
+
*/
|
|
4
|
+
export declare function normalizeBundleFilePath(filePath: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a reference written in one bundle file to a bundle path.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveBundleReference(fromPath: string, referencePath: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Format a bundle path as a relative reference from one bundle file.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatBundleReference(fromPath: string, targetPath: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Compare bundle paths with stable JavaScript string ordering.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compareBundlePaths(left: string, right: string): number;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fail } from "./error.js";
|
|
3
|
+
/**
|
|
4
|
+
* Normalize a stored bundle file path, which cannot be the bundle root.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeBundleFilePath(filePath) {
|
|
7
|
+
const normalized = normalizeBundlePath(filePath);
|
|
8
|
+
if (normalized === "") {
|
|
9
|
+
fail("PATH_INVALID", `Invalid bundle path: ${filePath}`);
|
|
10
|
+
}
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a reference written in one bundle file to a bundle path.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveBundleReference(fromPath, referencePath) {
|
|
17
|
+
if (referencePath === "") {
|
|
18
|
+
fail("PATH_INVALID", "Invalid bundle reference: empty path");
|
|
19
|
+
}
|
|
20
|
+
const from = normalizeBundleFilePath(fromPath);
|
|
21
|
+
const reference = referencePath.replaceAll("\\", "/");
|
|
22
|
+
const target = reference.startsWith("/")
|
|
23
|
+
? reference.slice(1)
|
|
24
|
+
: path.posix.join(path.posix.dirname(from), reference);
|
|
25
|
+
return normalizeBundlePath(target);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Format a bundle path as a relative reference from one bundle file.
|
|
29
|
+
*/
|
|
30
|
+
export function formatBundleReference(fromPath, targetPath) {
|
|
31
|
+
const from = normalizeBundleFilePath(fromPath);
|
|
32
|
+
const target = normalizeBundleFilePath(targetPath);
|
|
33
|
+
const fromDirectory = path.posix.dirname(from);
|
|
34
|
+
const relative = path.posix.relative(fromDirectory === "." ? "" : fromDirectory, target);
|
|
35
|
+
return relative === "" ? path.posix.basename(target) : relative;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Compare bundle paths with stable JavaScript string ordering.
|
|
39
|
+
*/
|
|
40
|
+
export function compareBundlePaths(left, right) {
|
|
41
|
+
if (left < right)
|
|
42
|
+
return -1;
|
|
43
|
+
if (left > right)
|
|
44
|
+
return 1;
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a bundle path that may point to the bundle root.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeBundlePath(filePath) {
|
|
51
|
+
if (isAbsolutePath(filePath)) {
|
|
52
|
+
fail("PATH_INVALID", `Invalid bundle path: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
const segments = [];
|
|
55
|
+
for (const segment of filePath.replaceAll("\\", "/").split("/")) {
|
|
56
|
+
if (segment === "" || segment === ".")
|
|
57
|
+
continue;
|
|
58
|
+
if (segment === "..") {
|
|
59
|
+
if (segments.length === 0) {
|
|
60
|
+
fail("PATH_INVALID", `Invalid bundle path: ${filePath}`);
|
|
61
|
+
}
|
|
62
|
+
segments.pop();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
segments.push(segment);
|
|
66
|
+
}
|
|
67
|
+
return segments.join("/");
|
|
68
|
+
}
|
|
69
|
+
function isAbsolutePath(filePath) {
|
|
70
|
+
return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath);
|
|
71
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder-shaped collection of files with one root Markdown file.
|
|
3
|
+
*/
|
|
4
|
+
export type MarkdownBundle = {
|
|
5
|
+
/** Text file clients should read first. */
|
|
6
|
+
root: MarkdownBundleTextFile;
|
|
7
|
+
/** Complete bundle file list, including `root`. */
|
|
8
|
+
files: MarkdownBundleFile[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* File stored in a Markdown bundle.
|
|
12
|
+
*/
|
|
13
|
+
export type MarkdownBundleFile = MarkdownBundleTextFile | MarkdownBundleBinaryFile;
|
|
14
|
+
/**
|
|
15
|
+
* Text file stored in a Markdown bundle.
|
|
16
|
+
*/
|
|
17
|
+
export type MarkdownBundleTextFile = {
|
|
18
|
+
/** Bundle-relative file path. */
|
|
19
|
+
path: string;
|
|
20
|
+
/** Text content. */
|
|
21
|
+
content: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Binary file stored in a Markdown bundle.
|
|
25
|
+
*/
|
|
26
|
+
export type MarkdownBundleBinaryFile = {
|
|
27
|
+
/** Bundle-relative file path. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Raw file bytes. */
|
|
30
|
+
bytes: Uint8Array;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Check whether a bundle file stores text content.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isTextFile(file: MarkdownBundleFile): file is MarkdownBundleTextFile;
|
|
36
|
+
/**
|
|
37
|
+
* Check whether a bundle file stores binary bytes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isBinaryFile(file: MarkdownBundleFile): file is MarkdownBundleBinaryFile;
|
|
40
|
+
/**
|
|
41
|
+
* Check whether a bundle file is a Markdown text file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isMarkdownFile(file: MarkdownBundleFile): file is MarkdownBundleTextFile;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether a bundle file stores text content.
|
|
3
|
+
*/
|
|
4
|
+
export function isTextFile(file) {
|
|
5
|
+
return "content" in file;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check whether a bundle file stores binary bytes.
|
|
9
|
+
*/
|
|
10
|
+
export function isBinaryFile(file) {
|
|
11
|
+
return "bytes" in file;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Check whether a bundle file is a Markdown text file.
|
|
15
|
+
*/
|
|
16
|
+
export function isMarkdownFile(file) {
|
|
17
|
+
return isTextFile(file) && file.path.endsWith(".md");
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "md-bundle",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Markdown bundles: one root Markdown file plus related docs, scripts, and assets.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/holdenmatt/md-bundle.git"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"SPEC.md"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"prepare": "tsc",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"lint": "oxlint",
|
|
26
|
+
"format": "oxfmt --write .",
|
|
27
|
+
"format:check": "oxfmt --check .",
|
|
28
|
+
"check": "pnpm format:check && pnpm lint && pnpm test && pnpm build"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^26.0.1",
|
|
32
|
+
"oxfmt": "^0.56.0",
|
|
33
|
+
"oxlint": "^1.71.0",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.9"
|
|
36
|
+
},
|
|
37
|
+
"packageManager": "pnpm@11.7.0"
|
|
38
|
+
}
|