getfilepress 0.0.0 → 0.1.1
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 +178 -4
- package/package.json +89 -7
- package/packages/app/package.json +31 -0
- package/packages/app/src/app.d.ts +12 -0
- package/packages/app/src/app.html +12 -0
- package/packages/app/src/critical-theme.d.ts +4 -0
- package/packages/app/src/lib/content.server.ts +8 -0
- package/packages/app/src/lib/empty-theme.css +1 -0
- package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
- package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
- package/packages/app/src/lib/genie/ops.ts +237 -0
- package/packages/app/src/lib/genie/store.ts +217 -0
- package/packages/app/src/lib/genie/types.ts +61 -0
- package/packages/app/src/lib/pages.server.ts +7 -0
- package/packages/app/src/lib/site.server.ts +52 -0
- package/packages/app/src/lib/theme-entry.ts +7 -0
- package/packages/app/src/routes/+layout.svelte +29 -0
- package/packages/app/src/routes/+layout.ts +10 -0
- package/packages/app/src/routes/+page.server.ts +28 -0
- package/packages/app/src/routes/+page.svelte +67 -0
- package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
- package/packages/app/src/routes/[slug]/+page.svelte +47 -0
- package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
- package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
- package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
- package/packages/app/src/routes/robots.txt/+server.ts +11 -0
- package/packages/app/src/routes/rss.xml/+server.ts +13 -0
- package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
- package/packages/app/src/routes/tags/+page.server.ts +4 -0
- package/packages/app/src/routes/tags/+page.svelte +26 -0
- package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
- package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
- package/packages/app/src/routes/topics/+page.server.ts +28 -0
- package/packages/app/src/routes/topics/+page.svelte +47 -0
- package/packages/app/src/routes/writing/+page.server.ts +12 -0
- package/packages/app/src/routes/writing/+page.svelte +38 -0
- package/packages/app/src/site-theme.d.ts +2 -0
- package/packages/app/static/.gitkeep +0 -0
- package/packages/app/tsconfig.json +15 -0
- package/packages/app/vite-plugin-critical-theme.ts +70 -0
- package/packages/app/vite-plugin-genie.ts +114 -0
- package/packages/app/vite.config.ts +141 -0
- package/packages/core/package.json +51 -0
- package/packages/core/src/lib/assets/favicon.svg +1 -0
- package/packages/core/src/lib/components/Newsletter.svelte +13 -0
- package/packages/core/src/lib/components/PostCard.svelte +34 -0
- package/packages/core/src/lib/components/PostIndex.svelte +86 -0
- package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
- package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
- package/packages/core/src/lib/config.ts +154 -0
- package/packages/core/src/lib/content/content.ts +193 -0
- package/packages/core/src/lib/content/feeds.ts +102 -0
- package/packages/core/src/lib/content/markdown.ts +78 -0
- package/packages/core/src/lib/content/pages.ts +103 -0
- package/packages/core/src/lib/content/parse.ts +214 -0
- package/packages/core/src/lib/content/rehype-figure.ts +79 -0
- package/packages/core/src/lib/content/types.ts +88 -0
- package/packages/core/src/lib/format.ts +25 -0
- package/packages/core/src/lib/index.ts +27 -0
- package/packages/core/src/lib/server.ts +33 -0
- package/packages/core/src/lib/styles/fonts.css +72 -0
- package/packages/core/src/lib/styles/theme.css +762 -0
- package/packages/core/src/lib/theme.ts +5 -0
- package/packages/import/package.json +27 -0
- package/packages/import/src/cli.ts +346 -0
- package/packages/import/src/discover.ts +190 -0
- package/packages/import/src/extract.ts +378 -0
- package/packages/import/src/fetch.ts +63 -0
- package/packages/import/src/html-to-md.ts +21 -0
- package/packages/import/src/images.ts +247 -0
- package/packages/import/src/inspire.ts +417 -0
- package/packages/import/src/ir.ts +109 -0
- package/packages/import/src/ollama.ts +145 -0
- package/packages/import/src/stock.ts +218 -0
- package/packages/import/src/theme.ts +478 -0
- package/packages/import/src/write-site.ts +371 -0
- package/packages/import/tsconfig.json +13 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/create-site.mjs +260 -0
- package/scripts/filepress.mjs +273 -0
- package/scripts/link-embedded-packages.mjs +40 -0
- package/scripts/postinstall.mjs +56 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@filepress/import",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|