getfilepress 0.0.0 → 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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -4
  3. package/package.json +89 -7
  4. package/packages/app/package.json +31 -0
  5. package/packages/app/src/app.d.ts +12 -0
  6. package/packages/app/src/app.html +12 -0
  7. package/packages/app/src/critical-theme.d.ts +4 -0
  8. package/packages/app/src/lib/content.server.ts +8 -0
  9. package/packages/app/src/lib/empty-theme.css +1 -0
  10. package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
  11. package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
  12. package/packages/app/src/lib/genie/ops.ts +237 -0
  13. package/packages/app/src/lib/genie/store.ts +217 -0
  14. package/packages/app/src/lib/genie/types.ts +61 -0
  15. package/packages/app/src/lib/pages.server.ts +7 -0
  16. package/packages/app/src/lib/site.server.ts +52 -0
  17. package/packages/app/src/lib/theme-entry.ts +7 -0
  18. package/packages/app/src/routes/+layout.svelte +29 -0
  19. package/packages/app/src/routes/+layout.ts +10 -0
  20. package/packages/app/src/routes/+page.server.ts +28 -0
  21. package/packages/app/src/routes/+page.svelte +67 -0
  22. package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
  23. package/packages/app/src/routes/[slug]/+page.svelte +47 -0
  24. package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
  25. package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
  26. package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
  27. package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
  28. package/packages/app/src/routes/robots.txt/+server.ts +11 -0
  29. package/packages/app/src/routes/rss.xml/+server.ts +13 -0
  30. package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
  31. package/packages/app/src/routes/tags/+page.server.ts +4 -0
  32. package/packages/app/src/routes/tags/+page.svelte +26 -0
  33. package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
  34. package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
  35. package/packages/app/src/routes/topics/+page.server.ts +28 -0
  36. package/packages/app/src/routes/topics/+page.svelte +47 -0
  37. package/packages/app/src/routes/writing/+page.server.ts +12 -0
  38. package/packages/app/src/routes/writing/+page.svelte +38 -0
  39. package/packages/app/src/site-theme.d.ts +2 -0
  40. package/packages/app/static/.gitkeep +0 -0
  41. package/packages/app/tsconfig.json +15 -0
  42. package/packages/app/vite-plugin-critical-theme.ts +70 -0
  43. package/packages/app/vite-plugin-genie.ts +114 -0
  44. package/packages/app/vite.config.ts +141 -0
  45. package/packages/core/package.json +51 -0
  46. package/packages/core/src/lib/assets/favicon.svg +1 -0
  47. package/packages/core/src/lib/components/Newsletter.svelte +13 -0
  48. package/packages/core/src/lib/components/PostCard.svelte +34 -0
  49. package/packages/core/src/lib/components/PostIndex.svelte +86 -0
  50. package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
  51. package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
  52. package/packages/core/src/lib/config.ts +154 -0
  53. package/packages/core/src/lib/content/content.ts +193 -0
  54. package/packages/core/src/lib/content/feeds.ts +102 -0
  55. package/packages/core/src/lib/content/markdown.ts +78 -0
  56. package/packages/core/src/lib/content/pages.ts +103 -0
  57. package/packages/core/src/lib/content/parse.ts +214 -0
  58. package/packages/core/src/lib/content/rehype-figure.ts +79 -0
  59. package/packages/core/src/lib/content/types.ts +88 -0
  60. package/packages/core/src/lib/format.ts +25 -0
  61. package/packages/core/src/lib/index.ts +27 -0
  62. package/packages/core/src/lib/server.ts +33 -0
  63. package/packages/core/src/lib/styles/fonts.css +72 -0
  64. package/packages/core/src/lib/styles/theme.css +762 -0
  65. package/packages/core/src/lib/theme.ts +5 -0
  66. package/packages/import/package.json +27 -0
  67. package/packages/import/src/cli.ts +346 -0
  68. package/packages/import/src/discover.ts +190 -0
  69. package/packages/import/src/extract.ts +378 -0
  70. package/packages/import/src/fetch.ts +63 -0
  71. package/packages/import/src/html-to-md.ts +21 -0
  72. package/packages/import/src/images.ts +247 -0
  73. package/packages/import/src/inspire.ts +417 -0
  74. package/packages/import/src/ir.ts +109 -0
  75. package/packages/import/src/ollama.ts +145 -0
  76. package/packages/import/src/stock.ts +218 -0
  77. package/packages/import/src/theme.ts +478 -0
  78. package/packages/import/src/write-site.ts +371 -0
  79. package/packages/import/tsconfig.json +13 -0
  80. package/pnpm-workspace.yaml +2 -0
  81. package/scripts/create-site.mjs +260 -0
  82. package/scripts/filepress.mjs +273 -0
  83. package/scripts/link-embedded-packages.mjs +40 -0
  84. package/scripts/postinstall.mjs +56 -0
@@ -0,0 +1,5 @@
1
+ // Self-hosted latin/latin-ext fonts + the Essay theme (no third-party requests).
2
+ // A site imports this once from its root layout:
3
+ // import '@filepress/core/theme';
4
+ import './styles/fonts.css';
5
+ import './styles/theme.css';
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@filepress/import",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Crawl an existing site and scaffold a filepress content-only sibling (optional Ollama restyle).",
7
+ "bin": {
8
+ "filepress-import": "./src/cli.ts"
9
+ },
10
+ "scripts": {
11
+ "import": "tsx src/cli.ts",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "dependencies": {
16
+ "fast-xml-parser": "^5.2.5",
17
+ "linkedom": "^0.18.12",
18
+ "turndown": "^7.2.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^26.1.0",
22
+ "@types/turndown": "^5.0.5",
23
+ "tsx": "^4.20.5",
24
+ "typescript": "^6.0.3",
25
+ "vitest": "^4.1.9"
26
+ }
27
+ }
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * filepress import — crawl a site, extract content, scaffold a sibling filepress site.
4
+ *
5
+ * Usage:
6
+ * pnpm --filter @filepress/import import -- --source https://example.com
7
+ * filepress import --source https://example.com --inspire https://www.catalystforge.com
8
+ */
9
+ import { createInterface } from 'node:readline/promises';
10
+ import { isAbsolute, resolve } from 'node:path';
11
+ import { stdin as input, stdout as output } from 'node:process';
12
+ import { discoverSite } from './discover.ts';
13
+ import { extractSite } from './extract.ts';
14
+ import { fetchText } from './fetch.ts';
15
+ import type { DesignBrief, ImportOptions } from './ir.ts';
16
+ import {
17
+ briefFromInspiration,
18
+ extractInspirationSignals,
19
+ type InspirationSignals
20
+ } from './inspire.ts';
21
+ import {
22
+ generateDesignBrief,
23
+ ollamaAvailable,
24
+ ollamaSetupHint,
25
+ summarizeHtmlForBrief
26
+ } from './ollama.ts';
27
+ import { harvestImagesFromPage, planImages, type ImagePlan } from './images.ts';
28
+ import { formatAttributionMarkdown, planStockCovers } from './stock.ts';
29
+ import { DEFAULT_BRIEF, themeCssFromBrief, tokensFromSourceCss } from './theme.ts';
30
+ import {
31
+ defaultEngineRoot,
32
+ defaultOutPath,
33
+ siteNameFromUrl,
34
+ writeSite
35
+ } from './write-site.ts';
36
+
37
+ /** pnpm sets INIT_CWD to the directory where the user invoked the command. */
38
+ function resolveUserPath(p: string): string {
39
+ if (isAbsolute(p)) return p;
40
+ const base = process.env.INIT_CWD?.trim() || process.cwd();
41
+ return resolve(base, p);
42
+ }
43
+
44
+ function fail(msg: string): never {
45
+ console.error(`filepress import: ${msg}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ function parseArgs(argv: string[]) {
50
+ const out: {
51
+ _: string[];
52
+ source?: string;
53
+ inspire: string[];
54
+ out?: string;
55
+ name?: string;
56
+ title?: string;
57
+ author?: string;
58
+ url?: string;
59
+ ollama: string;
60
+ model: string;
61
+ noLlm: boolean;
62
+ dryRun: boolean;
63
+ force: boolean;
64
+ yes: boolean;
65
+ fetchImages: boolean;
66
+ } = {
67
+ _: [],
68
+ inspire: [],
69
+ ollama: process.env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434',
70
+ model: process.env.FILEPRESS_OLLAMA_MODEL?.trim() || 'gemma4:12b',
71
+ noLlm: false,
72
+ dryRun: false,
73
+ force: false,
74
+ yes: false,
75
+ fetchImages: false
76
+ };
77
+
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const a = argv[i];
80
+ const next = () => {
81
+ const v = argv[++i];
82
+ if (!v) fail(`${a} requires a value`);
83
+ return v;
84
+ };
85
+ if (a === '--source') out.source = next();
86
+ else if (a === '--inspire') out.inspire.push(next());
87
+ else if (a === '--out') out.out = next();
88
+ else if (a === '--name') out.name = next();
89
+ else if (a === '--title') out.title = next();
90
+ else if (a === '--author') out.author = next();
91
+ else if (a === '--url') out.url = next();
92
+ else if (a === '--ollama') out.ollama = next();
93
+ else if (a === '--model') out.model = next();
94
+ else if (a === '--no-llm') out.noLlm = true;
95
+ else if (a === '--dry-run') out.dryRun = true;
96
+ else if (a === '--force') out.force = true;
97
+ else if (a === '--yes' || a === '-y') out.yes = true;
98
+ else if (a === '--fetch-images') out.fetchImages = true;
99
+ else if (a === '--help' || a === '-h') out._.push('help');
100
+ else out._.push(a);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ async function prompt(rl: ReturnType<typeof createInterface>, q: string, def?: string) {
106
+ const hint = def ? ` [${def}]` : '';
107
+ const ans = (await rl.question(`${q}${hint}: `)).trim();
108
+ return ans || def || '';
109
+ }
110
+
111
+ async function main() {
112
+ const args = parseArgs(process.argv.slice(2));
113
+ if (args._.includes('help')) {
114
+ console.log(`Usage: filepress import --source <url> [options]
115
+
116
+ Options:
117
+ --source <url> Site to import (required)
118
+ --inspire <url> Inspiration site (repeatable, up to 3; blended into one theme)
119
+ --out <path> Sibling output directory
120
+ --name <slug> Site package name (lowercase)
121
+ --title / --author / --url
122
+ --ollama <host> Default http://127.0.0.1:11434
123
+ --model <name> Default gemma4:12b
124
+ --no-llm Skip Ollama; token theme from source CSS / defaults
125
+ --dry-run Crawl + report only (no write)
126
+ --force Overwrite generated content in --out
127
+ --fetch-images Download Openverse CC covers + source portrait/logo into static/images/
128
+ --yes Skip confirmation prompt
129
+ `);
130
+ return;
131
+ }
132
+
133
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
134
+ const rl = interactive ? createInterface({ input, output }) : null;
135
+
136
+ try {
137
+ let source = args.source || '';
138
+ if (!source && rl) source = await prompt(rl, 'Source site URL');
139
+ if (!source) fail('`--source` is required');
140
+
141
+ let inspire = [...args.inspire];
142
+ if (!inspire.length && rl) {
143
+ for (let n = 1; n <= 3; n++) {
144
+ const q =
145
+ n === 1
146
+ ? 'Inspiration URL 1/3 (optional, blank to skip)'
147
+ : `Inspiration URL ${n}/3 (optional, blank to stop)`;
148
+ const one = await prompt(rl, q);
149
+ if (!one) break;
150
+ inspire.push(one);
151
+ }
152
+ }
153
+ if (inspire.length > 3) {
154
+ console.warn('import: using first 3 --inspire URLs (max 3)');
155
+ inspire = inspire.slice(0, 3);
156
+ }
157
+
158
+ const engineRoot = defaultEngineRoot();
159
+ const siteName = (args.name || siteNameFromUrl(source)).replace(/[^a-z0-9-]/g, '');
160
+ if (!siteName) fail('could not derive a site name; pass --name');
161
+
162
+ let out = args.out
163
+ ? resolveUserPath(args.out)
164
+ : defaultOutPath(engineRoot, siteName);
165
+ if (rl && !args.out) {
166
+ const picked = await prompt(rl, 'Output directory', out);
167
+ if (picked) out = resolveUserPath(picked);
168
+ }
169
+
170
+ const opts: ImportOptions = {
171
+ source,
172
+ inspire,
173
+ out,
174
+ siteName,
175
+ title: args.title,
176
+ author: args.author,
177
+ canonicalUrl: args.url,
178
+ ollamaHost: args.ollama,
179
+ ollamaModel: args.model,
180
+ noLlm: args.noLlm,
181
+ dryRun: args.dryRun,
182
+ force: args.force,
183
+ fetchImages: args.fetchImages,
184
+ engineRoot
185
+ };
186
+
187
+ console.log(`\nimport: discovering ${source} …`);
188
+ const discovered = await discoverSite(source);
189
+ console.log(
190
+ `import: found ${discovered.urls.length} URLs, ${discovered.rss.length} RSS items`
191
+ );
192
+
193
+ console.log('import: extracting content …');
194
+ const ir = await extractSite(discovered);
195
+ if (opts.title) ir.identity.title = opts.title;
196
+ if (opts.author) ir.identity.author = opts.author;
197
+ if (opts.canonicalUrl) ir.identity.canonicalUrl = opts.canonicalUrl.replace(/\/+$/, '');
198
+
199
+ console.log(
200
+ `import: ${ir.posts.length} posts, ${ir.pages.length} pages, title="${ir.identity.title}"`
201
+ );
202
+
203
+ let brief: DesignBrief | null = null;
204
+ const homeHtml = await fetchText(discovered.origin)
205
+ .then((r) => r.text)
206
+ .catch(() => '');
207
+
208
+ // Inspiration drives the look. Source site only fills gaps when no --inspire.
209
+ const inspireSignals: InspirationSignals[] = [];
210
+ const inspireSummaries: string[] = [];
211
+ for (const u of inspire) {
212
+ try {
213
+ console.log(`import: sampling inspiration ${u} …`);
214
+ const signals = await extractInspirationSignals(u);
215
+ inspireSignals.push(signals);
216
+ const { text } = await fetchText(u);
217
+ inspireSummaries.push(`${u}: ${summarizeHtmlForBrief(text)}`);
218
+ console.log(` → ${signals.paletteMode} · ${signals.notes.join(' · ')}`);
219
+ } catch (e) {
220
+ console.warn(
221
+ `import: inspiration fetch failed ${u}: ${e instanceof Error ? e.message : e}`
222
+ );
223
+ }
224
+ }
225
+
226
+ const seed =
227
+ inspireSignals.length > 0
228
+ ? briefFromInspiration(inspireSignals)
229
+ : {
230
+ ...DEFAULT_BRIEF,
231
+ tokens: { ...DEFAULT_BRIEF.tokens, ...tokensFromSourceCss(homeHtml) }
232
+ };
233
+
234
+ if (!opts.noLlm) {
235
+ const up = await ollamaAvailable(opts.ollamaHost);
236
+ if (!up) {
237
+ console.warn(`import: ${ollamaSetupHint(opts.ollamaHost)}`);
238
+ console.warn('import: continuing with extracted inspiration brief (--no-llm path)');
239
+ brief = seed;
240
+ } else {
241
+ console.log(`import: refining design brief with ${opts.ollamaModel} …`);
242
+ brief = await generateDesignBrief({
243
+ host: opts.ollamaHost,
244
+ model: opts.ollamaModel,
245
+ ir,
246
+ inspireSummaries,
247
+ inspireSignals,
248
+ seed
249
+ });
250
+ }
251
+ } else {
252
+ brief = seed;
253
+ }
254
+
255
+ console.log('import: harvesting image candidates …');
256
+ const harvested = [];
257
+ for (const u of [source, ...inspire]) {
258
+ try {
259
+ harvested.push(...(await harvestImagesFromPage(u, u)));
260
+ } catch (e) {
261
+ console.warn(
262
+ `import: image harvest failed ${u}: ${e instanceof Error ? e.message : e}`
263
+ );
264
+ }
265
+ }
266
+ let imagePlan: ImagePlan = planImages(ir, harvested, brief);
267
+ ir.notes.push(...imagePlan.notes);
268
+ console.log(
269
+ `import: ${harvested.length} image candidates · hero=${imagePlan.chosen.hero ? 'yes' : 'no'}`
270
+ );
271
+
272
+ if (opts.dryRun) {
273
+ console.log('\n--- dry-run SiteIR summary ---');
274
+ console.log(
275
+ JSON.stringify(
276
+ {
277
+ identity: ir.identity,
278
+ posts: ir.posts.map((p) => ({
279
+ slug: p.slug,
280
+ date: p.date,
281
+ title: p.title,
282
+ tags: p.tags
283
+ })),
284
+ pages: ir.pages.map((p) => ({ slug: p.slug, title: p.title })),
285
+ nav: ir.nav,
286
+ topics: ir.topics,
287
+ lede: ir.lede,
288
+ notes: ir.notes,
289
+ images: imagePlan.chosen,
290
+ unsplashQueries: imagePlan.unsplashQueries
291
+ },
292
+ null,
293
+ 2
294
+ )
295
+ );
296
+ if (brief) {
297
+ console.log('\n--- design brief ---');
298
+ console.log(JSON.stringify(brief, null, 2));
299
+ console.log('\n--- theme.css preview (first 60 lines) ---');
300
+ console.log(themeCssFromBrief(brief).split('\n').slice(0, 60).join('\n'));
301
+ }
302
+ console.log(`\nimport: dry-run complete (would write to ${out})`);
303
+ return;
304
+ }
305
+
306
+ if (rl && !args.yes) {
307
+ const ok = await prompt(rl, `Write site to ${out}?`, 'y');
308
+ if (!/^y(es)?$/i.test(ok)) {
309
+ console.log('import: aborted');
310
+ return;
311
+ }
312
+ }
313
+
314
+ if (opts.fetchImages) {
315
+ console.log('import: searching Openverse for free stock covers …');
316
+ const stock = await planStockCovers(brief, {
317
+ author: ir.identity.author,
318
+ includeHeader: true,
319
+ includeHero: false
320
+ });
321
+ ir.notes.push(...stock.notes);
322
+ const attr = formatAttributionMarkdown(stock.hits);
323
+ if (attr) ir.notes.push('Stock attribution:', attr);
324
+ // Covers from stock; portrait/logo from source harvest only.
325
+ brief = {
326
+ ...brief,
327
+ images: {
328
+ ...imagePlan.chosen,
329
+ ...stock.images,
330
+ portrait: imagePlan.chosen.portrait,
331
+ logo: imagePlan.chosen.logo
332
+ }
333
+ };
334
+ }
335
+
336
+ console.log(`import: writing ${out} …`);
337
+ const { reportPath } = await writeSite(ir, opts, brief);
338
+ console.log(`import: done.\n report: ${reportPath}\n next: cd ${out} && pnpm install && pnpm filepress dev`);
339
+ } finally {
340
+ rl?.close();
341
+ }
342
+ }
343
+
344
+ main().catch((e) => {
345
+ fail(e instanceof Error ? e.message : String(e));
346
+ });
@@ -0,0 +1,190 @@
1
+ import { XMLParser } from 'fast-xml-parser';
2
+ import { fetchText, originOf, resolveUrl, sameOrigin } from './fetch.ts';
3
+
4
+ export type DiscoveredUrl = {
5
+ url: string;
6
+ kind: 'home' | 'post' | 'page' | 'tag' | 'listing' | 'other';
7
+ };
8
+
9
+ const xmlParser = new XMLParser({
10
+ ignoreAttributes: false,
11
+ attributeNamePrefix: '@_'
12
+ });
13
+
14
+ function asArray<T>(v: T | T[] | undefined | null): T[] {
15
+ if (v === undefined || v === null) return [];
16
+ return Array.isArray(v) ? v : [v];
17
+ }
18
+
19
+ function classify(url: string, origin: string): DiscoveredUrl['kind'] {
20
+ const u = new URL(url);
21
+ if (u.origin !== new URL(origin).origin) return 'other';
22
+ const path = u.pathname.replace(/\/+$/, '') || '/';
23
+ if (path === '/') return 'home';
24
+ if (/\/tags?\//i.test(path) || /\/topics?\//i.test(path)) return 'tag';
25
+ if (/\/(writing|essays|posts|blog|articles)\/[^/]+/i.test(path)) return 'post';
26
+ if (/\/(writing|essays|posts|blog|articles)\/?$/i.test(path)) return 'listing';
27
+ if (/\/(about|contact|speaking|now|colophon|privacy|resume|cv)\/?$/i.test(path)) return 'page';
28
+ // Single-segment paths are likely pages; deeper unknown paths → other
29
+ const segs = path.split('/').filter(Boolean);
30
+ if (segs.length === 1) return 'page';
31
+ if (segs.length >= 2) return 'post';
32
+ return 'other';
33
+ }
34
+
35
+ async function readRobotsSitemaps(origin: string): Promise<string[]> {
36
+ try {
37
+ const { status, text } = await fetchText(`${origin}/robots.txt`);
38
+ if (status >= 400) return [`${origin}/sitemap.xml`, `${origin}/sitemap-0.xml`];
39
+ const maps: string[] = [];
40
+ for (const line of text.split(/\r?\n/)) {
41
+ const m = line.match(/^\s*sitemap:\s*(.+)$/i);
42
+ if (m) maps.push(m[1].trim());
43
+ }
44
+ if (maps.length) return maps;
45
+ } catch {
46
+ /* fall through */
47
+ }
48
+ return [`${origin}/sitemap.xml`, `${origin}/sitemap-0.xml`];
49
+ }
50
+
51
+ async function urlsFromSitemap(sitemapUrl: string, origin: string): Promise<string[]> {
52
+ const { status, text, contentType } = await fetchText(sitemapUrl);
53
+ if (status >= 400) return [];
54
+ if (!/xml|text\/plain/i.test(contentType) && !text.trimStart().startsWith('<?xml')) {
55
+ return [];
56
+ }
57
+
58
+ let doc: unknown;
59
+ try {
60
+ doc = xmlParser.parse(text);
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ const root = doc as Record<string, unknown>;
66
+ // sitemap index
67
+ const index = root.sitemapindex as { sitemap?: { loc?: string } | { loc?: string }[] } | undefined;
68
+ if (index?.sitemap) {
69
+ const nested = asArray(index.sitemap);
70
+ const out: string[] = [];
71
+ for (const s of nested) {
72
+ if (s.loc) out.push(...(await urlsFromSitemap(String(s.loc), origin)));
73
+ }
74
+ return out;
75
+ }
76
+
77
+ const urlset = root.urlset as { url?: { loc?: string } | { loc?: string }[] } | undefined;
78
+ if (!urlset?.url) return [];
79
+ return asArray(urlset.url)
80
+ .map((u) => (u.loc ? String(u.loc) : ''))
81
+ .filter((u) => u && sameOrigin(u, origin));
82
+ }
83
+
84
+ export type RssItem = {
85
+ title: string;
86
+ link: string;
87
+ description: string | null;
88
+ pubDate: string | null;
89
+ };
90
+
91
+ async function readRss(origin: string): Promise<{ channelTitle: string | null; items: RssItem[] }> {
92
+ const candidates = [`${origin}/rss.xml`, `${origin}/feed.xml`, `${origin}/atom.xml`, `${origin}/feed`];
93
+ for (const feedUrl of candidates) {
94
+ try {
95
+ const { status, text } = await fetchText(feedUrl);
96
+ if (status >= 400) continue;
97
+ if (!/<rss|<feed/i.test(text)) continue;
98
+ const doc = xmlParser.parse(text) as Record<string, unknown>;
99
+ if (doc.rss) {
100
+ const channel = (doc.rss as { channel?: Record<string, unknown> }).channel ?? {};
101
+ const items = asArray(channel.item as RssItem | RssItem[] | undefined).map((it) => ({
102
+ title: String((it as { title?: string }).title ?? '').trim(),
103
+ link: String((it as { link?: string }).link ?? '').trim(),
104
+ description: (it as { description?: string }).description
105
+ ? String((it as { description?: string }).description).trim()
106
+ : null,
107
+ pubDate: (it as { pubDate?: string }).pubDate
108
+ ? String((it as { pubDate?: string }).pubDate).trim()
109
+ : null
110
+ }));
111
+ return {
112
+ channelTitle: channel.title ? String(channel.title) : null,
113
+ items: items.filter((i) => i.link && i.title)
114
+ };
115
+ }
116
+ } catch {
117
+ /* try next */
118
+ }
119
+ }
120
+ return { channelTitle: null, items: [] };
121
+ }
122
+
123
+ export type DiscoverResult = {
124
+ origin: string;
125
+ urls: DiscoveredUrl[];
126
+ rss: RssItem[];
127
+ rssTitle: string | null;
128
+ };
129
+
130
+ /** Discover URLs via robots/sitemap + RSS. Deterministic. */
131
+ export async function discoverSite(sourceUrl: string): Promise<DiscoverResult> {
132
+ const origin = originOf(sourceUrl.endsWith('/') ? sourceUrl : `${sourceUrl}/`);
133
+ const sitemapUrls = await readRobotsSitemaps(origin);
134
+ const found = new Set<string>();
135
+ found.add(`${origin}/`);
136
+
137
+ for (const sm of sitemapUrls) {
138
+ for (const u of await urlsFromSitemap(sm, origin)) {
139
+ found.add(u.endsWith('/') || u.includes('.') ? u : `${u}/`);
140
+ // normalize without forcing trailing slash for later fetch
141
+ found.add(u.replace(/\/+$/, '') || `${origin}/`);
142
+ }
143
+ }
144
+
145
+ const { channelTitle, items } = await readRss(origin);
146
+ for (const item of items) {
147
+ const abs = resolveUrl(origin, item.link);
148
+ if (abs && sameOrigin(abs, origin)) found.add(abs);
149
+ }
150
+
151
+ // Always probe common pages
152
+ for (const p of ['/about', '/contact', '/writing', '/essays', '/blog', '/posts']) {
153
+ found.add(`${origin}${p}`);
154
+ }
155
+
156
+ const urls: DiscoveredUrl[] = [...found]
157
+ .map((url) => {
158
+ try {
159
+ const clean = new URL(url).href;
160
+ return { url: clean, kind: classify(clean, origin) };
161
+ } catch {
162
+ return null;
163
+ }
164
+ })
165
+ .filter((x): x is DiscoveredUrl => Boolean(x));
166
+
167
+ // Deduplicate by pathname
168
+ const byPath = new Map<string, DiscoveredUrl>();
169
+ for (const u of urls) {
170
+ const key = new URL(u.url).pathname.replace(/\/+$/, '') || '/';
171
+ const prev = byPath.get(key);
172
+ if (!prev || (prev.kind === 'other' && u.kind !== 'other')) byPath.set(key, u);
173
+ else if (!prev) byPath.set(key, u);
174
+ }
175
+
176
+ // Prefer RSS classification for posts
177
+ for (const item of items) {
178
+ const abs = resolveUrl(origin, item.link);
179
+ if (!abs) continue;
180
+ const key = new URL(abs).pathname.replace(/\/+$/, '') || '/';
181
+ byPath.set(key, { url: abs, kind: 'post' });
182
+ }
183
+
184
+ return {
185
+ origin,
186
+ urls: [...byPath.values()],
187
+ rss: items,
188
+ rssTitle: channelTitle
189
+ };
190
+ }