Skip to content

Commit f094d7c

Browse files
authored
Merge pull request #7 from thefrontside/cli-hotness
Interactive CLI progress display
2 parents 0f2b837 + 69d4155 commit f094d7c

12 files changed

Lines changed: 845 additions & 183 deletions

AGENTS.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Agents
2+
3+
This repository uses AI agents to assist with development tasks. This file provides entry points for agent discovery.
4+
5+
## Project Overview
6+
7+
Staticalize is a CLI tool and library that downloads and staticalizes websites
8+
from a sitemap. It fetches pages, rewrites links to point to a new base URL,
9+
and saves the result to disk. Published to JSR as `@frontside/staticalize` and
10+
to npm via a Deno build step.
11+
12+
## Effection
13+
14+
This repository builds on [Effection](https://github.com/thefrontside/effection), a structured concurrency library. Before working with code here, read the [Effection AGENTS.md](https://github.com/thefrontside/effection/blob/v4/AGENTS.md) for essential concepts:
15+
16+
- Operations vs Promises (lazy vs eager execution)
17+
- Scope ownership and structured concurrency
18+
- Entry points (`main()`, `run()`, `createScope()`)
19+
- Streams, channels, and the `each()` pattern
20+
21+
## Tech Stack
22+
23+
- **Runtime**: Deno
24+
- **Build**: TypeScript, `deno task`
25+
- **Testing**: Deno test runner (`deno test -A`)
26+
- **Linting/Formatting**: Deno built-in (`deno lint`, `deno fmt`)
27+
- **Dependencies**: Import map in `deno.json` (JSR + npm specifiers)
28+
29+
## Coding Standards
30+
31+
### TypeScript
32+
33+
- Strict mode enabled
34+
- Prefer `type` imports for type-only imports
35+
- Use explicit return types on public functions
36+
37+
### Effection Patterns
38+
39+
- Use structured concurrency (spawn, scope)
40+
- Resources must clean up properly on scope exit
41+
- Prefer `Operation<T>` for async operations
42+
- Use `until()` for lifting promises into operations
43+
- Never kill the process directly (`Deno.exit()`, `process.exit()`) — use `yield* exit()` from Effection
44+
45+
## Commit and PR Conventions
46+
47+
Use [gitmoji](https://gitmoji.dev) for commit and pull request subjects. For
48+
changes to files that direct the behavior of AI such as AGENTS.md or llms.txt
49+
use a robot emoji instead of the standard gitmoji for documentation.
50+
51+
Do not include any agent marketing material (e.g. "Generated with...",
52+
"Co-Authored-By: \<agent>") in commits, pull requests, issues, or comments.
53+
54+
Pull requests use **Motivation / Approach** sections (not Summary / Test plan).
55+

deno.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build:jsr": "deno run -A tasks/build-jsr.ts"
1414
},
1515
"imports": {
16+
"@effectionx/stream-helpers": "npm:@effectionx/stream-helpers@^0.8.2",
1617
"@std/assert": "jsr:@std/assert@1",
1718
"@std/expect": "jsr:@std/expect@^1",
1819
"@std/cli": "jsr:@std/cli@1",
@@ -21,14 +22,17 @@
2122
"@std/path": "jsr:@std/path@1",
2223
"@libs/xml": "jsr:@libs/xml@^6.0.0",
2324
"effection": "npm:effection@^4.0.2",
25+
"@effectionx/context-api": "npm:@effectionx/context-api",
26+
"clayterm": "https://esm.sh/pr/clayterm@e413755",
2427
"hast-util-from-html": "npm:hast-util-from-html@^2.0.3",
2528
"hast-util-select": "npm:hast-util-select@^6.0.4",
2629
"hast-util-to-html": "npm:hast-util-to-html@^9.0.5",
2730
"zod": "npm:zod@^4.1.11",
2831
"zod-opts": "npm:zod-opts@0.1.8",
2932
"configliere": "npm:configliere@^0.2.3",
3033
"dnt": "jsr:@deno/dnt@0.42.3",
31-
"@hono/hono": "jsr:@hono/hono"
34+
"@hono/hono": "jsr:@hono/hono",
35+
"node:process": "node:process"
3236
},
3337
"lint": {
3438
"rules": {

deno.lock

Lines changed: 61 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

downloader.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
call,
3+
type Operation,
4+
resource,
5+
until,
6+
useAbortSignal,
7+
} from "effection";
8+
import { dirname, join, normalize } from "@std/path";
9+
import { ensureDir } from "@std/fs/ensure-dir";
10+
import { fromHtml } from "hast-util-from-html";
11+
import { toHtml } from "hast-util-to-html";
12+
import { selectAll } from "hast-util-select";
13+
import { useTaskBuffer } from "./task-buffer.ts";
14+
import { createApi } from "@effectionx/context-api";
15+
16+
export interface Downloader extends Operation<void> {
17+
download(spec: string, context?: URL): Operation<void>;
18+
}
19+
20+
export interface DownloaderOptions {
21+
host: URL;
22+
base: URL;
23+
outdir: string;
24+
}
25+
26+
export type DownloadResult =
27+
| { ok: true; bytes: number }
28+
| { ok: false; url: string; referrer: string };
29+
30+
export const DownloadApi = createApi("@staticalize/download", {
31+
*download(
32+
downloader: Downloader,
33+
opts: DownloaderOptions,
34+
source: URL,
35+
referrer: URL,
36+
): Operation<DownloadResult> {
37+
let { host, base, outdir } = opts;
38+
let signal = yield* useAbortSignal();
39+
let path = normalize(join(outdir, source.pathname));
40+
41+
let response = yield* until(fetch(source.toString(), { signal }));
42+
if (response.ok) {
43+
if (response.headers.get("Content-Type")?.includes("html")) {
44+
let destpath = join(path, "index.html");
45+
let content = yield* until(response.text());
46+
let html = fromHtml(content);
47+
48+
let links = selectAll("link[href]", html);
49+
50+
for (let link of links) {
51+
let href = link.properties.href as string;
52+
yield* downloader.download(href, source);
53+
54+
// replace self-referencing absolute urls with the destination site
55+
if (href.startsWith(host.origin)) {
56+
let url = new URL(href);
57+
url.host = base.host;
58+
url.port = base.port;
59+
url.protocol = base.protocol;
60+
link.properties.href = url.href;
61+
}
62+
}
63+
64+
let assets = selectAll("[src]", html);
65+
66+
for (let element of assets) {
67+
let src = element.properties.src as string;
68+
yield* downloader.download(src, source);
69+
70+
// replace self-referencing absolute urls with the destination site
71+
if (src.startsWith(host.origin)) {
72+
let url = new URL(src);
73+
url.host = base.host;
74+
url.port = base.port;
75+
url.protocol = base.protocol;
76+
element.properties.src = url.href;
77+
}
78+
}
79+
80+
let withContents = selectAll("[content]", html);
81+
for (let element of withContents) {
82+
let attr = String(element.properties.content);
83+
if (attr.startsWith(host.origin)) {
84+
yield* downloader.download(attr, source);
85+
let url = new URL(attr);
86+
url.host = base.host;
87+
url.port = base.port;
88+
url.protocol = base.protocol;
89+
element.properties.content = url.href;
90+
}
91+
}
92+
93+
let output = toHtml(html);
94+
yield* call(async () => {
95+
let destdir = dirname(destpath);
96+
await ensureDir(destdir);
97+
await Deno.writeTextFile(destpath, output);
98+
});
99+
return { ok: true, bytes: new TextEncoder().encode(output).byteLength };
100+
} else {
101+
let size = Number(response.headers.get("Content-Length") ?? 0);
102+
yield* call(async () => {
103+
let destdir = dirname(path);
104+
await ensureDir(destdir);
105+
await Deno.writeFile(path, response.body!);
106+
});
107+
return { ok: true, bytes: size };
108+
}
109+
} else {
110+
return {
111+
ok: false,
112+
url: source.toString(),
113+
referrer: referrer.toString(),
114+
};
115+
}
116+
},
117+
});
118+
119+
const { download } = DownloadApi.operations;
120+
121+
export function useDownloader(opts: DownloaderOptions): Operation<Downloader> {
122+
let seen = new Map<string, boolean>();
123+
return resource(function* (provide) {
124+
let { host } = opts;
125+
126+
let buffer = yield* useTaskBuffer(75);
127+
128+
let downloader: Downloader = {
129+
*download(loc, context = host) {
130+
if (loc.startsWith("//")) {
131+
return;
132+
}
133+
let source = loc.match(/^\w+:/) ? new URL(loc) : new URL(loc, context);
134+
if (source.host !== host.host) {
135+
return;
136+
}
137+
let key = source.href;
138+
if (seen.get(key)) {
139+
return;
140+
}
141+
seen.set(key, true);
142+
143+
yield* buffer.spawn(() => download(downloader, opts, source, context));
144+
},
145+
*[Symbol.iterator]() {
146+
yield* buffer;
147+
},
148+
};
149+
150+
yield* provide(downloader);
151+
});
152+
}

0 commit comments

Comments
 (0)