altalandi 0.5.0 → 0.6.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/skill/SKILL.md CHANGED
@@ -57,8 +57,9 @@ yourself or summarise the result in your own words.
57
57
  something should still change, say so separately under a heading such as
58
58
  "Mín tillaga (ekki yfirfarin af Altalandi)", or ask before running it again.
59
59
  - **One call per coherent text** (a page section, an email, one UI string). For
60
- many strings, run them one by one, at most 4 at a time, and paste each block.
60
+ a few strings, run them one by one, at most 4 at a time, and paste each block.
61
61
  Keep keys, placeholders (`{name}`, `%s`), HTML and code out of the text you send.
62
+ For a whole folder, i18n catalog or website, use the folder run below instead.
62
63
  - **Product names and coined words** the meaning check cannot recognise can make
63
64
  it refuse a rewrite (exit 3, "ekki tókst að staðfesta"). That is the safe
64
65
  result: keep the original and tell the user.
@@ -74,6 +75,24 @@ yourself or summarise the result in your own words.
74
75
  `--no-store` when the user asks that their text not be kept, or when it is
75
76
  confidential.
76
77
 
78
+ ## A whole folder, i18n catalog or website
79
+
80
+ One command refines every `.md`, `.txt`, `.html` and `.json` file in a folder,
81
+ or a web page, or a site. It keeps code, URLs, placeholders, tags and JSON keys
82
+ byte for byte, never writes to the input, and writes only changes whose meaning
83
+ check passed.
84
+
85
+ 1. **Estimate first:** `npx -y altalandi@latest <folder or URL> --dry-run` (add
86
+ `--site` for a whole site). Show the user the estimate it prints: files,
87
+ passages, characters, units and the price if paid from credit.
88
+ 2. **Only when the user agrees,** run the same command with `--yes` in place of
89
+ `--dry-run`. The result goes to `<folder>-is` unless they name a place
90
+ (`--out <path>`).
91
+ 3. **Show the summary it prints and the report's path.** Exit 0: done. Exit 2:
92
+ done, but some passages kept their original text; the report lists them and
93
+ why. Exit 4: out of credit; the finished files are written, and the same
94
+ command finishes the rest after a top-up.
95
+
77
96
  ## Connect this computer (once)
78
97
 
79
98
  Only when a call exits with 2 (`Not logged in`):
package/update.mjs CHANGED
@@ -166,7 +166,9 @@ function npmGlobalRoot() {
166
166
  });
167
167
  }
168
168
 
169
- async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
169
+ /** A y/N question on stderr; only y/yes (or j/já) is a yes. Shared with the
170
+ * folder and site runs, which ask before sending anything. */
171
+ export async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
170
172
  const rl = createInterface({ input, output });
171
173
  try {
172
174
  const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
package/web.mjs ADDED
@@ -0,0 +1,558 @@
1
+ // Web pages for `altalandi <url>`: fetch politely, keep the readable part, and
2
+ // turn it into Markdown that the folder pipeline then refines like any other
3
+ // document.
4
+ //
5
+ // readPage(url) one page → { url, title, markdown, links }
6
+ // readSite(url) up to maxPages same-origin pages under the URL's path,
7
+ // from /sitemap.xml or, without one, by following links
8
+ // htmlToMarkdown() main/article content (else body) without navigation,
9
+ // footers, scripts, styles, forms or hidden elements
10
+ //
11
+ // Every request names altalandi in its User-Agent, and robots.txt is obeyed: a
12
+ // Disallow for altalandi or for * stops the fetch, and Crawl-delay is honoured.
13
+ // Pages are fetched one at a time.
14
+ //
15
+ // Zero dependencies, like the rest of the CLI.
16
+
17
+ import { decodeEntities, tagAttributes, tokenizeHtml, VOID } from "./segment.mjs";
18
+
19
+ export const ROBOTS_AGENT = "altalandi";
20
+ export const userAgent = (version) => `altalandi-cli/${version} (+https://altalandi.is)`;
21
+
22
+ const MAX_PAGE_BYTES = 5 * 1024 * 1024;
23
+ const PAGE_TIMEOUT_MS = 30_000;
24
+ const MAX_CRAWL_DELAY_MS = 30_000;
25
+ const MAX_CHILD_SITEMAPS = 10;
26
+ const NOT_A_PAGE = /\.(?:pdf|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|zip|gz|tgz|rar|7z|tar|mp3|mp4|m4a|wav|ogg|webm|mov|avi|css|js|mjs|json|xml|rss|atom|txt|csv|xlsx?|docx?|pptx?|odt|woff2?|ttf|otf|eot|exe|dmg|apk|wasm)$/i;
27
+
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ function decodeBody(buf, contentType) {
31
+ let label = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || "")?.[1];
32
+ if (!label) {
33
+ const head = buf.subarray(0, 4096).toString("latin1");
34
+ label = /<meta[^>]+charset\s*=\s*["']?\s*([\w-]+)/i.exec(head)?.[1];
35
+ }
36
+ try {
37
+ return new TextDecoder(label || "utf-8").decode(buf);
38
+ } catch {
39
+ return new TextDecoder("utf-8").decode(buf);
40
+ }
41
+ }
42
+
43
+ /** GET with our User-Agent, a timeout and a size limit; the body is decoded
44
+ * with the page's own charset. */
45
+ async function get(url, io, { timeoutMs = PAGE_TIMEOUT_MS } = {}) {
46
+ const fetchImpl = io.fetch || globalThis.fetch;
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
49
+ try {
50
+ const res = await fetchImpl(url, {
51
+ headers: {
52
+ "user-agent": io.userAgent || userAgent("0"),
53
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
54
+ },
55
+ redirect: "follow",
56
+ signal: controller.signal,
57
+ });
58
+ const type = res.headers?.get?.("content-type") || "";
59
+ if (Number(res.headers?.get?.("content-length") || 0) > MAX_PAGE_BYTES) throw new Error("larger than 5 MB");
60
+ const buf = typeof res.arrayBuffer === "function"
61
+ ? Buffer.from(await res.arrayBuffer())
62
+ : Buffer.from(String((await res.text()) ?? ""), "utf8");
63
+ if (buf.length > MAX_PAGE_BYTES) throw new Error("larger than 5 MB");
64
+ return { status: res.status, ok: res.ok ?? (res.status >= 200 && res.status < 300), url: res.url || url, type, body: decodeBody(buf, type) };
65
+ } catch (e) {
66
+ if (controller.signal.aborted) throw new Error(`timed out after ${timeoutMs / 1000} s`);
67
+ throw e;
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ // ── robots.txt ──────────────────────────────────────────────────────────────
74
+
75
+ /** Groups of a robots.txt: { agents, rules: [{ allow, path }], crawlDelay }. */
76
+ export function parseRobots(text) {
77
+ const groups = [];
78
+ let group = null;
79
+ let agentLine = false;
80
+ for (const raw of String(text).split(/\r?\n/)) {
81
+ const line = raw.replace(/#.*$/, "").trim();
82
+ const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
83
+ if (!m) continue;
84
+ const field = m[1].toLowerCase();
85
+ const value = m[2].trim();
86
+ if (field === "user-agent") {
87
+ if (!group || !agentLine) groups.push(group = { agents: [], rules: [], crawlDelay: null });
88
+ group.agents.push(value.toLowerCase());
89
+ agentLine = true;
90
+ continue;
91
+ }
92
+ agentLine = false;
93
+ if (!group) continue;
94
+ if (field === "disallow" && value) group.rules.push({ allow: false, path: value });
95
+ else if (field === "allow" && value) group.rules.push({ allow: true, path: value });
96
+ else if (field === "crawl-delay" && Number.isFinite(Number(value))) group.crawlDelay = Number(value);
97
+ }
98
+ return groups;
99
+ }
100
+
101
+ const ours = (agent) => agent !== "*" && (agent.startsWith(ROBOTS_AGENT) || agent === `${ROBOTS_AGENT}-cli`);
102
+
103
+ function ruleMatches(rule, target) {
104
+ const anchored = rule.path.endsWith("$");
105
+ const body = anchored ? rule.path.slice(0, -1) : rule.path;
106
+ const pattern = body.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
107
+ const m = new RegExp(`^${pattern}${anchored ? "$" : ""}`).exec(target);
108
+ return m ? m[0].length : -1;
109
+ }
110
+
111
+ function groupAllows(groups, target) {
112
+ // The longest matching rule wins; on a tie, Allow wins (RFC 9309).
113
+ let best = { length: -1, allow: true };
114
+ for (const g of groups) {
115
+ for (const rule of g.rules) {
116
+ const length = ruleMatches(rule, target);
117
+ if (length > best.length || (length === best.length && length >= 0 && rule.allow)) best = { length, allow: rule.allow };
118
+ }
119
+ }
120
+ return best.allow;
121
+ }
122
+
123
+ /** May altalandi fetch this URL? Both the altalandi group and the * group must
124
+ * allow it. */
125
+ export function robotsAllows(groups, url) {
126
+ const u = new URL(url);
127
+ const target = u.pathname + u.search;
128
+ const mine = groups.filter((g) => g.agents.some(ours));
129
+ const star = groups.filter((g) => g.agents.includes("*"));
130
+ return (!mine.length || groupAllows(mine, target)) && (!star.length || groupAllows(star, target));
131
+ }
132
+
133
+ export function crawlDelayMs(groups) {
134
+ const delays = groups
135
+ .filter((g) => g.agents.some((a) => a === "*" || ours(a)))
136
+ .map((g) => g.crawlDelay)
137
+ .filter((d) => d != null);
138
+ return delays.length ? Math.min(MAX_CRAWL_DELAY_MS, Math.max(...delays) * 1000) : 0;
139
+ }
140
+
141
+ async function robotsFor(url, io, cache) {
142
+ const origin = new URL(url).origin;
143
+ if (!cache.has(origin)) {
144
+ let groups;
145
+ try {
146
+ const res = await get(`${origin}/robots.txt`, io, { timeoutMs: 15_000 });
147
+ if (res.status >= 200 && res.status < 300) groups = parseRobots(res.body);
148
+ else if (res.status >= 400 && res.status < 500) groups = []; // no robots.txt: nothing is disallowed
149
+ else groups = { error: `HTTP ${res.status}` };
150
+ } catch (e) {
151
+ groups = { error: e.message };
152
+ }
153
+ cache.set(origin, groups);
154
+ }
155
+ const groups = cache.get(origin);
156
+ // A robots.txt that exists but cannot be read is treated as "disallow all".
157
+ if (!Array.isArray(groups)) throw new Error(`could not read ${origin}/robots.txt (${groups.error}), so nothing was fetched from ${origin}`);
158
+ return groups;
159
+ }
160
+
161
+ // ── sitemap ─────────────────────────────────────────────────────────────────
162
+
163
+ /** Page URLs and child sitemaps listed in a sitemap or sitemap index. */
164
+ export function parseSitemap(xml) {
165
+ const locs = (tag) => [...String(xml).matchAll(new RegExp(`<(?:\\w+:)?${tag}\\b[^>]*>([\\s\\S]*?)<\\/(?:\\w+:)?${tag}>`, "gi"))]
166
+ .map((m) => /<(?:\w+:)?loc>\s*(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?\s*<\/(?:\w+:)?loc>/i.exec(m[1])?.[1])
167
+ .filter(Boolean)
168
+ .map((s) => decodeEntities(s.trim()));
169
+ return { pages: locs("url"), sitemaps: locs("sitemap") };
170
+ }
171
+
172
+ async function readSitemap(origin, io) {
173
+ let res;
174
+ try { res = await get(`${origin}/sitemap.xml`, io, { timeoutMs: 15_000 }); } catch { return null; }
175
+ if (!res.ok || !/<(?:\w+:)?(?:urlset|sitemapindex)\b/i.test(res.body)) return null;
176
+ const { pages, sitemaps } = parseSitemap(res.body);
177
+ const sameOrigin = (u) => { try { return new URL(u).origin === origin; } catch { return false; } };
178
+ for (const child of sitemaps.filter((u) => sameOrigin(u) && !/\.gz$/i.test(u)).slice(0, MAX_CHILD_SITEMAPS)) {
179
+ try {
180
+ const sub = await get(child, io, { timeoutMs: 15_000 });
181
+ if (sub.ok) pages.push(...parseSitemap(sub.body).pages);
182
+ } catch { /* one unreadable child sitemap does not end the run */ }
183
+ }
184
+ return pages.filter(sameOrigin);
185
+ }
186
+
187
+ // ── HTML → tree ─────────────────────────────────────────────────────────────
188
+
189
+ // Elements whose start closes an open <p> (the HTML parsing rules, simplified).
190
+ const CLOSES_P = new Set([
191
+ "address", "article", "aside", "blockquote", "details", "dialog", "div", "dl", "fieldset", "figcaption",
192
+ "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "main", "menu",
193
+ "nav", "ol", "p", "pre", "section", "table", "ul",
194
+ ]);
195
+ const IMPLIED = {
196
+ li: [["li"], ["ul", "ol"]],
197
+ dt: [["dt", "dd"], ["dl"]],
198
+ dd: [["dt", "dd"], ["dl"]],
199
+ tr: [["tr", "td", "th"], ["table", "thead", "tbody", "tfoot"]],
200
+ td: [["td", "th"], ["tr", "table"]],
201
+ th: [["td", "th"], ["tr", "table"]],
202
+ thead: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
203
+ tbody: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
204
+ tfoot: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
205
+ option: [["option"], ["select", "datalist", "optgroup"]],
206
+ };
207
+
208
+ function closeOpen(stack, names, scope) {
209
+ for (let k = stack.length - 1; k > 0; k--) {
210
+ if (scope.includes(stack[k].name)) return;
211
+ if (names.includes(stack[k].name)) { stack.length = k; return; }
212
+ }
213
+ }
214
+
215
+ /** A forgiving tree: { name, attrs, children }, text as { name: "#text", text }. */
216
+ export function parseHtml(src) {
217
+ const root = { name: "#document", attrs: {}, children: [] };
218
+ const stack = [root];
219
+ const top = () => stack[stack.length - 1];
220
+ for (const t of tokenizeHtml(src)) {
221
+ if (t.type === "text") top().children.push({ name: "#text", text: decodeEntities(src.slice(t.start, t.end)) });
222
+ else if (t.type === "rawtext" && (t.parent === "title" || t.parent === "textarea")) {
223
+ top().children.push({ name: "#text", text: decodeEntities(src.slice(t.start, t.end)) });
224
+ } else if (t.type === "tag") {
225
+ if (t.closing) {
226
+ for (let k = stack.length - 1; k > 0; k--) if (stack[k].name === t.name) { stack.length = k; break; }
227
+ continue;
228
+ }
229
+ if (CLOSES_P.has(t.name)) closeOpen(stack, ["p"], ["button", "td", "th", "table", "li"]);
230
+ if (IMPLIED[t.name]) closeOpen(stack, ...IMPLIED[t.name]);
231
+ const el = { name: t.name, attrs: tagAttributes(t.raw), children: [] };
232
+ top().children.push(el);
233
+ if (!VOID.has(t.name) && !t.selfClosing) stack.push(el);
234
+ }
235
+ }
236
+ return root;
237
+ }
238
+
239
+ function find(node, pred, { first = false, nested = true } = {}) {
240
+ const out = [];
241
+ const walk = (n) => {
242
+ for (const c of n.children || []) {
243
+ if (c.name[0] === "#") continue;
244
+ if (pred(c)) {
245
+ out.push(c);
246
+ if (first) return true;
247
+ if (!nested) continue;
248
+ }
249
+ if (walk(c) && first) return true;
250
+ }
251
+ return false;
252
+ };
253
+ walk(node);
254
+ return out;
255
+ }
256
+
257
+ const textOf = (node) => (node.name === "#text" ? node.text : (node.children || []).map(textOf).join(""));
258
+
259
+ // ── HTML → Markdown ─────────────────────────────────────────────────────────
260
+
261
+ const ALWAYS_DROP = new Set([
262
+ "script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed", "nav", "footer",
263
+ "form", "button", "select", "input", "textarea", "dialog", "head", "link", "meta", "map", "audio", "video",
264
+ "source", "track",
265
+ ]);
266
+ const CHROME = /(?:^|[\s_-])(?:cookie|consent|breadcrumbs?|skip-link|sr-only|visually-hidden|screen-reader-text)(?:$|[\s_-])/i;
267
+ const INLINE = new Set([
268
+ "a", "abbr", "b", "bdi", "bdo", "big", "cite", "code", "data", "del", "dfn", "em", "font", "i", "img", "ins",
269
+ "kbd", "label", "mark", "nobr", "q", "s", "samp", "small", "span", "strike", "strong", "sub", "sup", "time",
270
+ "tt", "u", "var", "wbr", "br",
271
+ ]);
272
+ const BR = "\u0000br\u0000";
273
+
274
+ function dropped(el, ctx) {
275
+ const role = (el.attrs.role || "").toLowerCase();
276
+ return ALWAYS_DROP.has(el.name)
277
+ || el.attrs.hidden !== undefined
278
+ || el.attrs["aria-hidden"] === "true"
279
+ || /^(?:navigation|contentinfo|search)$/.test(role)
280
+ || CHROME.test(`${el.attrs.class || ""} ${el.attrs.id || ""}`)
281
+ // Without a <main> or <article>, the page's own header and sidebars are
282
+ // site chrome, not content.
283
+ || (ctx.fallback && (el.name === "header" || el.name === "aside" || /^(?:banner|complementary)$/.test(role)));
284
+ }
285
+
286
+ // Text of an element without what dropped() removes (a "Copy" button in a <pre>).
287
+ const visibleText = (node, ctx) => (node.name === "#text" ? node.text
288
+ : node.name[0] === "#" || dropped(node, ctx) ? "" : (node.children || []).map((c) => visibleText(c, ctx)).join(""));
289
+
290
+ // Characters that would turn plain text into Markdown syntax.
291
+ const escapeText = (s) => s
292
+ .replace(/[\\`*[\]<]/g, (c) => `\\${c}`)
293
+ .replace(/(?<![A-Za-z0-9])_|_(?![A-Za-z0-9])/g, "\\_");
294
+
295
+ function escapeLineStart(line) {
296
+ if (/^(?:#{1,6}(?:\s|$)|[-+](?:\s|$)|>|={3,}\s*$)/.test(line)) return `\\${line}`;
297
+ const num = /^(\d{1,9})([.)])(?:\s|$)/.exec(line);
298
+ return num ? `${num[1]}\\${line.slice(num[1].length)}` : line;
299
+ }
300
+
301
+ function absolute(href, ctx) {
302
+ try {
303
+ return new URL(href, ctx.base).href.replace(/\(/g, "%28").replace(/\)/g, "%29");
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ const wrapWith = (s, mark) => {
310
+ const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(s);
311
+ return m[2] ? `${m[1]}${mark}${m[2]}${mark}${m[3]}` : s;
312
+ };
313
+
314
+ function codeSpan(text) {
315
+ const s = text.replace(/\s+/g, " ").trim();
316
+ if (!s) return "";
317
+ const longest = Math.max(0, ...(s.match(/`+/g) || []).map((r) => r.length));
318
+ const ticks = "`".repeat(longest + 1);
319
+ const pad = s.startsWith("`") || s.endsWith("`") ? " " : "";
320
+ return `${ticks}${pad}${s}${pad}${ticks}`;
321
+ }
322
+
323
+ function inline(node, ctx) {
324
+ if (node.name === "#text") return escapeText(node.text);
325
+ if (node.name[0] === "#" || dropped(node, ctx)) return "";
326
+ const inner = () => node.children.map((c) => inline(c, ctx)).join("");
327
+ switch (node.name) {
328
+ case "br": return BR;
329
+ case "img": {
330
+ const src = node.attrs.src ? absolute(node.attrs.src, ctx) : null;
331
+ return src ? `![${escapeText((node.attrs.alt || "").trim())}](${src})` : "";
332
+ }
333
+ case "a": {
334
+ const text = inner().trim();
335
+ const href = node.attrs.href;
336
+ if (!text) return "";
337
+ if (!href || href.startsWith("#") || /^\s*javascript:/i.test(href)) return text;
338
+ const target = absolute(href, ctx);
339
+ return target ? `[${text}](${target})` : text;
340
+ }
341
+ case "strong": case "b": return wrapWith(inner(), "**");
342
+ case "em": case "i": return wrapWith(inner(), "*");
343
+ case "del": case "s": case "strike": return wrapWith(inner(), "~~");
344
+ case "code": case "kbd": case "samp": case "var": case "tt": return codeSpan(visibleText(node, ctx));
345
+ default: return inner();
346
+ }
347
+ }
348
+
349
+ function paragraph(raw) {
350
+ const s = raw.replace(/\s+/g, " ")
351
+ .replace(new RegExp(`\\s*${BR}\\s*`, "g"), BR)
352
+ .replace(new RegExp(`^(?:${BR}|\\s)+|(?:${BR}|\\s)+$`, "g"), "");
353
+ return s ? s.split(BR).map((line) => escapeLineStart(line.trim())).join("\\\n") : "";
354
+ }
355
+
356
+ function blocks(node, ctx) {
357
+ const out = [];
358
+ let run = [];
359
+ const flush = () => {
360
+ const p = paragraph(run.join(""));
361
+ if (p) out.push(p);
362
+ run = [];
363
+ };
364
+ for (const child of node.children || []) {
365
+ if (child.name === "#text") { run.push(escapeText(child.text)); continue; }
366
+ if (child.name[0] === "#" || dropped(child, ctx)) continue;
367
+ if (INLINE.has(child.name)) { run.push(inline(child, ctx)); continue; }
368
+ flush();
369
+ out.push(...block(child, ctx));
370
+ }
371
+ flush();
372
+ return out;
373
+ }
374
+
375
+ const indentBlock = (s, pad) => s.split("\n").map((l) => (l ? pad + l : l)).join("\n");
376
+ const isList = (s) => /^(?:- |\d+\. )/.test(s);
377
+
378
+ function block(el, ctx) {
379
+ const h = /^h([1-6])$/.exec(el.name);
380
+ if (h) {
381
+ const text = paragraph(el.children.map((c) => inline(c, ctx)).join("")).replace(/\\\n/g, " ");
382
+ return text ? [`${"#".repeat(Number(h[1]))} ${text}`] : [];
383
+ }
384
+ switch (el.name) {
385
+ case "p": {
386
+ const p = paragraph(el.children.map((c) => inline(c, ctx)).join(""));
387
+ return p ? [p] : [];
388
+ }
389
+ case "ul":
390
+ case "ol": {
391
+ let n = Number.parseInt(el.attrs.start, 10) || 1;
392
+ const items = el.children.filter((c) => c.name === "li").map((li) => {
393
+ const marker = el.name === "ol" ? `${n++}. ` : "- ";
394
+ const body = blocks(li, ctx);
395
+ if (!body.length) return null;
396
+ const pad = " ".repeat(marker.length);
397
+ let s = marker + indentBlock(body[0], pad).slice(pad.length);
398
+ for (const b of body.slice(1)) s += (isList(b) ? "\n" : "\n\n") + indentBlock(b, pad);
399
+ return s;
400
+ }).filter(Boolean);
401
+ return items.length ? [items.join("\n")] : [];
402
+ }
403
+ case "blockquote": {
404
+ const inner = blocks(el, ctx).join("\n\n");
405
+ return inner ? [inner.split("\n").map((l) => (l ? `> ${l}` : ">")).join("\n")] : [];
406
+ }
407
+ case "pre": {
408
+ const code = visibleText(el, ctx).replace(/^\n/, "").replace(/\s+$/, "");
409
+ if (!code) return [];
410
+ const lang = /(?:^|\s)(?:language|lang)-([\w+-]+)/.exec(`${el.attrs.class || ""} ${find(el, (c) => c.name === "code", { first: true })[0]?.attrs.class || ""}`)?.[1] || "";
411
+ const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((r) => r.length));
412
+ const fence = "`".repeat(longest + 1);
413
+ return [`${fence}${lang}\n${code}\n${fence}`];
414
+ }
415
+ case "table": {
416
+ const rows = find(el, (c) => c.name === "tr", { nested: false }).map((tr) => tr.children
417
+ .filter((c) => c.name === "td" || c.name === "th")
418
+ .map((cell) => paragraph(cell.children.map((c) => inline(c, ctx)).join("")).replace(/\\\n/g, " ").replace(/\|/g, "\\|")));
419
+ const width = Math.max(0, ...rows.map((r) => r.length));
420
+ if (!width) return [];
421
+ const line = (cells) => `| ${[...cells, ...Array(width - cells.length).fill("")].join(" | ")} |`;
422
+ return [[line(rows[0]), line(Array(width).fill("---")), ...rows.slice(1).map(line)].join("\n")];
423
+ }
424
+ case "hr": return ["---"];
425
+ case "dl": return el.children
426
+ .filter((c) => c.name === "dt" || c.name === "dd")
427
+ .flatMap((c) => (c.name === "dt"
428
+ ? [wrapWith(paragraph(c.children.map((x) => inline(x, ctx)).join("")), "**")].filter(Boolean)
429
+ : blocks(c, ctx)));
430
+ default: return blocks(el, ctx); // div, section, article, li outside a list, figure …
431
+ }
432
+ }
433
+
434
+ /** The readable part of a page as Markdown, with the page title. */
435
+ export function htmlToMarkdown(html, pageUrl) {
436
+ const doc = parseHtml(html);
437
+ const baseHref = find(doc, (el) => el.name === "base" && el.attrs.href, { first: true })[0]?.attrs.href;
438
+ let base = pageUrl;
439
+ try { if (baseHref) base = new URL(baseHref, pageUrl).href; } catch { /* keep the page URL */ }
440
+ const title = textOf(find(doc, (el) => el.name === "title", { first: true })[0] || { name: "#text", text: "" }).replace(/\s+/g, " ").trim();
441
+
442
+ const main = find(doc, (el) => el.name === "main" || el.attrs.role === "main", { first: true })[0];
443
+ const articles = main ? [] : find(doc, (el) => el.name === "article", { nested: false });
444
+ const body = find(doc, (el) => el.name === "body", { first: true })[0] || doc;
445
+ const ctx = { base, fallback: !main && !articles.length };
446
+ const root = main || (articles.length === 1 ? articles[0] : articles.length ? { name: "#group", attrs: {}, children: articles } : body);
447
+
448
+ const parts = root.name === "#group" ? root.children.flatMap((a) => blocks(a, ctx)) : blocks(root, ctx);
449
+ if (title && !parts.some((p) => /^# /.test(p))) parts.unshift(`# ${escapeText(title)}`);
450
+ return { title, markdown: parts.join("\n\n") };
451
+ }
452
+
453
+ /** Links on a page, absolute, without fragments; rel=nofollow is respected. */
454
+ export function pageLinks(html, pageUrl) {
455
+ const doc = parseHtml(html);
456
+ const baseHref = find(doc, (el) => el.name === "base" && el.attrs.href, { first: true })[0]?.attrs.href;
457
+ let base = pageUrl;
458
+ try { if (baseHref) base = new URL(baseHref, pageUrl).href; } catch { /* keep the page URL */ }
459
+ const links = [];
460
+ for (const a of find(doc, (el) => el.name === "a" && el.attrs.href)) {
461
+ if (/(?:^|\s)nofollow(?:\s|$)/i.test(a.attrs.rel || "")) continue;
462
+ try {
463
+ const u = new URL(a.attrs.href, base);
464
+ if (u.protocol !== "http:" && u.protocol !== "https:") continue;
465
+ u.hash = "";
466
+ links.push(u.href);
467
+ } catch { /* not a URL */ }
468
+ }
469
+ return links;
470
+ }
471
+
472
+ // ── pages and sites ─────────────────────────────────────────────────────────
473
+
474
+ const withoutHash = (url) => { const u = new URL(url); u.hash = ""; return u.href; };
475
+
476
+ /** Fetch one page if robots.txt allows it. Throws with a readable reason. */
477
+ export async function readPage(url, io = {}, robots = new Map()) {
478
+ if (!robotsAllows(await robotsFor(url, io, robots), url)) throw new Error(`robots.txt does not allow altalandi to read ${url}`);
479
+ const res = await get(url, io);
480
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
481
+ if (res.url !== url && !robotsAllows(await robotsFor(res.url, io, robots), res.url)) {
482
+ throw new Error(`robots.txt does not allow altalandi to read ${res.url}`);
483
+ }
484
+ if (res.type && !/html|xml/i.test(res.type)) throw new Error(`${url} is not an HTML page (${res.type.split(";")[0]})`);
485
+ const { title, markdown } = htmlToMarkdown(res.body, res.url);
486
+ return { url: res.url, title, markdown, links: pageLinks(res.body, res.url) };
487
+ }
488
+
489
+ /** Pages of a site, starting with `url`: same origin, under the URL's folder
490
+ * (https://x.is/docs/intro covers /docs/…), from /sitemap.xml or, without
491
+ * one, by following links. Returns { pages, skipped, source }. */
492
+ export async function readSite(url, { maxPages = 20, io = {}, onPage } = {}) {
493
+ const robots = new Map();
494
+ const wait = io.sleep || sleep;
495
+ const first = await readPage(url, io, robots);
496
+ onPage?.(first);
497
+ const start = new URL(first.url);
498
+ const scope = start.pathname.slice(0, start.pathname.lastIndexOf("/") + 1) || "/";
499
+ const inScope = (link) => {
500
+ try {
501
+ const u = new URL(link);
502
+ return u.origin === start.origin && u.pathname.startsWith(scope) && !NOT_A_PAGE.test(u.pathname);
503
+ } catch {
504
+ return false;
505
+ }
506
+ };
507
+ const pages = [first];
508
+ const skipped = [];
509
+ const seen = new Set([withoutHash(url), withoutHash(first.url)]);
510
+ const listed = await readSitemap(start.origin, io);
511
+ const source = listed && listed.length ? "sitemap" : "links";
512
+ const queue = (source === "sitemap" ? listed : first.links).filter(inScope);
513
+ const delay = crawlDelayMs(await robotsFor(first.url, io, robots));
514
+ while (queue.length && pages.length < maxPages) {
515
+ const next = withoutHash(queue.shift());
516
+ if (seen.has(next)) continue;
517
+ seen.add(next);
518
+ if (!robotsAllows(await robotsFor(next, io, robots), next)) { skipped.push({ url: next, reason: "robots.txt" }); continue; }
519
+ if (delay) await wait(delay);
520
+ let page;
521
+ try {
522
+ page = await readPage(next, io, robots);
523
+ } catch (e) {
524
+ skipped.push({ url: next, reason: e.message });
525
+ continue;
526
+ }
527
+ const landed = withoutHash(page.url);
528
+ if (landed !== next && seen.has(landed)) continue;
529
+ seen.add(landed);
530
+ if (!inScope(page.url)) { skipped.push({ url: next, reason: `redirected to ${page.url}` }); continue; }
531
+ pages.push(page);
532
+ onPage?.(page);
533
+ if (source === "links") for (const link of page.links) if (inScope(link) && !seen.has(withoutHash(link))) queue.push(link);
534
+ }
535
+ return { pages, skipped, source };
536
+ }
537
+
538
+ /** A file name for a page: /docs/intro → docs/intro.md, / → index.md. */
539
+ export function pagePath(url) {
540
+ const u = new URL(url);
541
+ const decode = (s) => { try { return decodeURIComponent(s.replace(/\+/g, " ")); } catch { return s; } };
542
+ let path = decode(u.pathname).replace(/^\/+/, "");
543
+ if (!path || path.endsWith("/")) path += "index";
544
+ path = path.replace(/\.(?:html?|php|aspx?)$/i, "");
545
+ const segments = path.split("/").map(safeSegment).filter((s) => s && s !== "." && s !== "..");
546
+ let rel = segments.join("/") || "index";
547
+ if (u.search) rel += `-${safeSegment(decode(u.search.slice(1)).replace(/[=&;]/g, "-")).slice(0, 40)}`;
548
+ return `${rel}.md`;
549
+ }
550
+
551
+ /** One path segment that is safe on Windows, macOS and Linux. */
552
+ export function safeSegment(s) {
553
+ const clean = String(s)
554
+ .replace(/[<>:"/\\|?*\u0000-\u001f\s]+/g, "-")
555
+ .replace(/-{2,}/g, "-")
556
+ .replace(/^[-.]+|[-.]+$/g, "");
557
+ return /^(?:con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) ? `${clean}-` : clean;
558
+ }