Skip to content

Commit 75831b7

Browse files
committed
Refactor into modular library and CLI with folder archive support.
Restructure the codebase into domain modules, add streaming folder compression, and document the architecture.
1 parent 1b06b06 commit 75831b7

27 files changed

Lines changed: 1959 additions & 1032 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ npm run build # compile to dist/
9797
npm run test:watch
9898
```
9999

100+
## Architecture
101+
102+
For a guided tour of the codebase — module layout, data-flow diagrams, algorithms, and design patterns — see **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**.
103+
100104
## License
101105

102106
MIT

docs/ARCHITECTURE.md

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# Architecture Guide
2+
3+
This document explains how `@startdoing/tc` is structured, the algorithms it uses, and the design patterns behind each layer. It is written for developers who want to learn from or extend the codebase.
4+
5+
## What this project does
6+
7+
The library turns **UTF-8 text** or **entire folder trees** into a single pasteable string (Base64 or Z85), and back again. The pipeline is:
8+
9+
```
10+
Input → tag → Brotli (max quality) → Base64 or Z85 → pasteable string
11+
```
12+
13+
Folder archives use a custom binary format before compression. Large outputs can be split into numbered part files for chat paste limits.
14+
15+
## Source layout (domain modules)
16+
17+
```
18+
src/
19+
├── index.ts # Public API barrel (npm package surface)
20+
├── types.ts # Shared types (Encoding)
21+
├── encoding/ # Base64 and Z85 text encodings
22+
│ ├── base64.ts
23+
│ ├── z85.ts
24+
│ └── index.ts # encodeBuffer / decodeBuffer facade
25+
├── compression/
26+
│ └── brotli.ts # Brotli sync + streaming wrappers
27+
├── payload/
28+
│ └── tags.ts # Type tags, wrap/decompress payload
29+
├── archive/ # Custom folder archive format
30+
│ ├── types.ts
31+
│ ├── format.ts # Serialize / deserialize / stream writes
32+
│ ├── collect.ts # In-memory directory walk
33+
│ └── unpack.ts # Restore archive to disk
34+
├── split/
35+
│ └── parts.ts # Split output into numbered files
36+
├── fs/
37+
│ └── paths.ts # Path validation and file reading
38+
├── api/
39+
│ ├── text.ts # compress() / decompress()
40+
│ └── folder.ts # compressFolder() / decompressToPath()
41+
├── streaming/
42+
│ └── folder.ts # Large-folder pipeline (disk-backed)
43+
└── cli/ # Terminal interface
44+
├── main.ts
45+
├── args.ts
46+
├── paths.ts
47+
├── analytics.ts
48+
├── output.ts
49+
├── usage.ts
50+
└── commands/
51+
├── compress.ts
52+
└── decompress.ts
53+
```
54+
55+
Each file has a `@module` header comment describing its role, algorithms, and patterns.
56+
57+
## End-to-end data flow
58+
59+
### Text compression
60+
61+
```mermaid
62+
flowchart LR
63+
A[UTF-8 text] --> B[wrapPayload TAG_TEXT]
64+
B --> C[Brotli compress]
65+
C --> D[Base64 or Z85 encode]
66+
D --> E[Pasteable string]
67+
```
68+
69+
### Folder compression (in-memory)
70+
71+
```mermaid
72+
flowchart LR
73+
A[Directory tree] --> B[collectEntries walk]
74+
B --> C[serializeArchive]
75+
C --> D[wrapPayload TAG_FOLDER]
76+
D --> E[Brotli compress]
77+
E --> F[Base64 or Z85 encode]
78+
F --> G[Pasteable string]
79+
```
80+
81+
### Folder compression (streaming — CLI default for folders)
82+
83+
```mermaid
84+
flowchart LR
85+
A[Directory tree] --> B[buildArchiveFile temp]
86+
B --> C[stream Brotli temp]
87+
C --> D[stream encode output.txt]
88+
D --> E[optional split parts]
89+
```
90+
91+
Peak memory stays bounded by chunk buffers (1–3 MiB), not total archive size.
92+
93+
## Applied mechanisms and patterns
94+
95+
### 1. Type tag (discriminated union)
96+
97+
After Brotli decompression, the **first byte** identifies the payload:
98+
99+
| Tag byte | Constant | Meaning |
100+
|----------|--------------|----------------|
101+
| `0x01` | `TAG_TEXT` | UTF-8 string |
102+
| `0x02` | `TAG_FOLDER` | Binary archive |
103+
104+
This lets one encoded string represent either text or a folder without external metadata — similar to MIME types or protobuf field tags.
105+
106+
**Module:** `src/payload/tags.ts`
107+
108+
### 2. Strategy pattern (encoding selection)
109+
110+
`encodeBuffer(buffer, encoding)` and `decodeBuffer(str, encoding)` dispatch to Base64 or Z85 based on the `Encoding` type (`64 | 85`). Callers never branch on encoding elsewhere.
111+
112+
**Module:** `src/encoding/index.ts`
113+
114+
### 3. Brotli maximum-quality compression
115+
116+
We always use `BROTLI_MAX_QUALITY` (11) and `BROTLI_MAX_WINDOW_BITS` because the tool optimises for **smallest output**, not speed. `SIZE_HINT` is set to input length so the encoder can pre-allocate.
117+
118+
**Module:** `src/compression/brotli.ts`
119+
120+
### 4. Custom flat archive format
121+
122+
Instead of ZIP or tar, we use a minimal length-prefixed binary format:
123+
124+
```
125+
directory: [0x44] [pathLen u32le] [path utf-8]
126+
file: [0x46] [pathLen u32le] [path] [contentLen u32le] [content]
127+
```
128+
129+
Design choices:
130+
131+
- **Flat list** — produced by depth-first walk; easy to stream to disk.
132+
- **Sorted children** — deterministic output for testing.
133+
- **No metadata** — permissions and timestamps dropped for simplicity.
134+
- **Path validation** — rejects `..` and absolute paths (zip-slip prevention).
135+
136+
**Modules:** `src/archive/format.ts`, `src/archive/collect.ts`, `src/archive/unpack.ts`
137+
138+
### 5. Z85 Base85 encoding
139+
140+
Z85 (ZeroMQ RFC 32) maps 4 bytes → 5 printable characters using a base-85 alphabet chosen to avoid quotes and backslashes. It is ~8% more compact than Base64.
141+
142+
Padding: a 1-byte prefix stores how many zero bytes were appended so arbitrary-length data round-trips.
143+
144+
**Module:** `src/encoding/z85.ts`
145+
146+
### 6. Split-file output
147+
148+
When encoded output exceeds 30,000 characters (or a user `-s` limit), it is split into numbered parts:
149+
150+
```
151+
output.txt → output.1.txt, output.2.txt, …
152+
```
153+
154+
Zero-padding width matches total part count so lexical sort equals numeric sort.
155+
156+
**Module:** `src/split/parts.ts`
157+
158+
### 7. Staged streaming pipeline
159+
160+
For large folders, each stage writes to a temp file and the next stage reads from it:
161+
162+
```
163+
walk → archive.bin → compressed.bin → output.txt
164+
```
165+
166+
Pattern: **bounded memory via temp files** instead of loading everything into RAM.
167+
168+
**Module:** `src/streaming/folder.ts`
169+
170+
## Dependency graph
171+
172+
```mermaid
173+
flowchart TB
174+
subgraph public [Public API]
175+
index[index.ts]
176+
end
177+
178+
subgraph api [API layer]
179+
text[api/text.ts]
180+
folder[api/folder.ts]
181+
end
182+
183+
subgraph core [Core domains]
184+
tags[payload/tags.ts]
185+
brotli[compression/brotli.ts]
186+
encoding[encoding/]
187+
archive[archive/]
188+
split[split/parts.ts]
189+
fspaths[fs/paths.ts]
190+
end
191+
192+
subgraph internal [Internal only]
193+
streaming[streaming/folder.ts]
194+
cli[cli/]
195+
end
196+
197+
index --> text
198+
index --> folder
199+
index --> tags
200+
index --> archive
201+
index --> split
202+
index --> fspaths
203+
204+
text --> tags
205+
folder --> archive
206+
folder --> tags
207+
208+
tags --> brotli
209+
tags --> encoding
210+
211+
streaming --> archive
212+
streaming --> brotli
213+
streaming --> encoding
214+
streaming --> split
215+
streaming --> tags
216+
217+
cli --> text
218+
cli --> streaming
219+
cli --> tags
220+
cli --> archive
221+
cli --> split
222+
cli --> fspaths
223+
```
224+
225+
## Two folder compression paths
226+
227+
| Path | Function | When used | Memory |
228+
|------|----------|-----------|--------|
229+
| In-memory | `compressFolder()` | Library API, small folders | Loads all files into RAM |
230+
| Streaming | `compressFolderToPath()` | CLI folder compress | Bounded by chunk buffers |
231+
232+
Both produce the same wire format; only the build strategy differs.
233+
234+
## Public vs internal modules
235+
236+
**Exported** via `src/index.ts` (published on npm):
237+
238+
- `compress`, `decompress`, `compressFolder`, `decompressToPath`
239+
- `decompressPayload`, `TAG_TEXT`, `TAG_FOLDER`
240+
- Split helpers, path helpers, `unpackDirectory`
241+
242+
**Internal** (not in package exports):
243+
244+
- `src/streaming/` — used by CLI only
245+
- `src/cli/` — terminal interface
246+
- `src/encoding/z85.ts` internals — use `encodeBuffer` instead
247+
248+
## Extending the codebase
249+
250+
| Goal | Where to start |
251+
|------|----------------|
252+
| New encoding (e.g. hex) | Add `encoding/hex.ts`, extend `Encoding` type and facade |
253+
| Archive metadata (mtime) | Extend `ArchiveEntry` and wire format in `archive/format.ts` |
254+
| Async text API | Mirror `api/text.ts` with async Brotli from `zlib/promises` |
255+
| New payload type | Add tag constant in `payload/tags.ts`, route in decompress |
256+
257+
## Running and testing
258+
259+
```bash
260+
npm install
261+
npm test # builds then runs Vitest
262+
npm run build # compile src/ → dist/
263+
npm run check # Biome lint + TypeScript
264+
```
265+
266+
Tests import from `src/index.js` (library) and execute `dist/cli.js` (CLI integration).
267+
268+
## Further reading
269+
270+
- [Brotli format (RFC 7932)](https://www.rfc-editor.org/rfc/rfc7932)
271+
- [Z85 specification (ZeroMQ RFC 32)](https://rfc.zeromq.org/spec/32/)
272+
- [Base64 (RFC 4648)](https://www.rfc-editor.org/rfc/rfc4648)

src/api/folder.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @module api/folder
3+
*
4+
* High-level API for folder compression and decompression.
5+
*
6+
* ## In-memory path (`compressFolder`)
7+
*
8+
* Walks the tree, builds a binary archive in RAM, compresses, and encodes.
9+
* Suitable for small-to-medium folders.
10+
*
11+
* ## Decompression (`decompressToPath`)
12+
*
13+
* Decodes, decompresses, checks the folder tag, and unpacks to disk.
14+
*/
15+
16+
import { collectEntries } from "../archive/collect.js";
17+
import { serializeArchive } from "../archive/format.js";
18+
import { unpackDirectory } from "../archive/unpack.js";
19+
import {
20+
compressTaggedPayload,
21+
decompressPayload,
22+
TAG_FOLDER,
23+
} from "../payload/tags.js";
24+
import type { Encoding } from "../types.js";
25+
26+
/**
27+
* Pack a directory tree into a single encoded string (in-memory).
28+
*
29+
* @returns Encoded blob plus statistics about the source folder.
30+
*/
31+
export function compressFolder(dirPath: string, encoding: Encoding = 64) {
32+
const entries = collectEntries(dirPath);
33+
const archive = serializeArchive(entries);
34+
const encoded = compressTaggedPayload(TAG_FOLDER, archive, encoding);
35+
const files = entries.filter((e) => e.type === "f");
36+
const originalBytes = files.reduce(
37+
(sum, e) => sum + (e.content?.length ?? 0),
38+
0,
39+
);
40+
return {
41+
encoded,
42+
fileCount: files.length,
43+
dirCount: entries.length - files.length,
44+
originalBytes,
45+
archiveBytes: archive.length,
46+
};
47+
}
48+
49+
/**
50+
* Decode and unpack a folder archive to a destination directory.
51+
*
52+
* @throws If the payload is text, not a folder archive.
53+
*/
54+
export function decompressToPath(
55+
encoded: string,
56+
destDir: string,
57+
encoding: Encoding = 64,
58+
) {
59+
const { tag, data } = decompressPayload(encoded, encoding);
60+
if (tag !== TAG_FOLDER) {
61+
throw new Error(
62+
"This payload is compressed text, not a folder. Use decompress.",
63+
);
64+
}
65+
return unpackDirectory(data, destDir);
66+
}

src/api/text.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @module api/text
3+
*
4+
* High-level API for compressing and decompressing UTF-8 text strings.
5+
*
6+
* ## Pipeline
7+
*
8+
* ```
9+
* compress: text → UTF-8 bytes → tag → Brotli → Base64/Z85
10+
* decompress: Base64/Z85 → Brotli → tag check → UTF-8 string
11+
* ```
12+
*
13+
* Text and folder payloads share the same outer encoding but are
14+
* distinguished by the leading tag byte after decompression.
15+
*/
16+
17+
import {
18+
compressTaggedPayload,
19+
decompressPayload,
20+
TAG_TEXT,
21+
} from "../payload/tags.js";
22+
import type { Encoding } from "../types.js";
23+
24+
/**
25+
* Compress a UTF-8 string to a pasteable encoded blob.
26+
*
27+
* @param text - Input string (any Unicode code points).
28+
* @param encoding - `64` (Base64) or `85` (Z85); default Base64.
29+
*/
30+
export function compress(text: string, encoding: Encoding = 64): string {
31+
return compressTaggedPayload(TAG_TEXT, Buffer.from(text, "utf-8"), encoding);
32+
}
33+
34+
/**
35+
* Decompress an encoded text payload back to a UTF-8 string.
36+
*
37+
* @throws If the payload is a folder archive (wrong tag).
38+
*/
39+
export function decompress(encoded: string, encoding: Encoding = 64): string {
40+
const raw = decompressPayload(encoded, encoding);
41+
if (raw.tag !== TAG_TEXT) {
42+
throw new Error(
43+
"This payload is a compressed folder, not text. Use decompressToPath.",
44+
);
45+
}
46+
return raw.data.toString("utf-8");
47+
}

0 commit comments

Comments
 (0)