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.
- 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,378 @@
|
|
|
1
|
+
import { parseHTML } from 'linkedom';
|
|
2
|
+
import type { DiscoverResult } from './discover.ts';
|
|
3
|
+
import { fetchText, resolveUrl, sameOrigin } from './fetch.ts';
|
|
4
|
+
import { htmlToMarkdown } from './html-to-md.ts';
|
|
5
|
+
import type { SiteIR, SiteIRPage, SiteIRPost } from './ir.ts';
|
|
6
|
+
|
|
7
|
+
const RESERVED = new Set([
|
|
8
|
+
'posts',
|
|
9
|
+
'tags',
|
|
10
|
+
'topics',
|
|
11
|
+
'page',
|
|
12
|
+
'rss.xml',
|
|
13
|
+
'sitemap.xml',
|
|
14
|
+
'robots.txt'
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function slugFromUrl(url: string): string {
|
|
18
|
+
const path = new URL(url).pathname.replace(/\/+$/, '');
|
|
19
|
+
const seg = path.split('/').filter(Boolean).pop() ?? 'page';
|
|
20
|
+
return seg
|
|
21
|
+
.normalize('NFKC')
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[\s_]+/g, '-')
|
|
24
|
+
.replace(/[^\p{L}\p{N}-]+/gu, '')
|
|
25
|
+
.replace(/-{2,}/g, '-')
|
|
26
|
+
.replace(/^-+|-+$/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rfc822ToIso(pubDate: string | null): string | null {
|
|
30
|
+
if (!pubDate) return null;
|
|
31
|
+
const d = new Date(pubDate);
|
|
32
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
33
|
+
return d.toISOString().slice(0, 10);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function textContent(el: Element | null | undefined): string {
|
|
37
|
+
return (el?.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pickMain(document: Document): Element {
|
|
41
|
+
return (
|
|
42
|
+
document.querySelector('article') ||
|
|
43
|
+
document.querySelector('[role="main"]') ||
|
|
44
|
+
document.querySelector('main') ||
|
|
45
|
+
document.body
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripChrome(root: Element): void {
|
|
50
|
+
for (const sel of ['nav', 'header', 'footer', 'aside', '.nav', '.header', '.footer', 'script', 'style']) {
|
|
51
|
+
root.querySelectorAll(sel).forEach((n) => n.remove());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractImages(root: Element, pageUrl: string, origin: string): string[] {
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
for (const img of root.querySelectorAll('img[src]')) {
|
|
58
|
+
const src = img.getAttribute('src');
|
|
59
|
+
if (!src) continue;
|
|
60
|
+
const abs = resolveUrl(pageUrl, src);
|
|
61
|
+
if (abs && sameOrigin(abs, origin) && !abs.startsWith('data:')) out.push(abs);
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(out)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rewrite same-origin article/listing links to filepress routes before HTML→MD
|
|
68
|
+
* so prerender does not crawl dead `/writing/…` paths.
|
|
69
|
+
*/
|
|
70
|
+
function rewriteInternalLinks(root: Element, pageUrl: string, origin: string): void {
|
|
71
|
+
for (const a of root.querySelectorAll('a[href]')) {
|
|
72
|
+
const href = a.getAttribute('href');
|
|
73
|
+
if (!href || href.startsWith('#') || href.startsWith('mailto:')) continue;
|
|
74
|
+
const abs = resolveUrl(pageUrl, href);
|
|
75
|
+
if (!abs || !sameOrigin(abs, origin)) continue;
|
|
76
|
+
const path = new URL(abs).pathname.replace(/\/+$/, '') || '/';
|
|
77
|
+
const writing = path.match(/^\/(?:writing|essays|blog|articles)\/([^/]+)$/i);
|
|
78
|
+
if (writing) {
|
|
79
|
+
a.setAttribute('href', `/posts/${writing[1]}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (/^\/(?:writing|essays|blog|articles)$/i.test(path)) {
|
|
83
|
+
a.setAttribute('href', '/');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const tag = path.match(/^\/tags?\/([^/]+)$/i);
|
|
87
|
+
if (tag) {
|
|
88
|
+
a.setAttribute('href', `/tags/${normalizeTagSlug(decodeURIComponent(tag[1]))}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function metaContent(document: Document, sel: string): string | null {
|
|
94
|
+
const el = document.querySelector(sel);
|
|
95
|
+
const v = el?.getAttribute('content')?.trim();
|
|
96
|
+
return v || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Prefer brand / logo over page chrome like "Home · …". */
|
|
100
|
+
function siteTitleFromDoc(document: Document): string | null {
|
|
101
|
+
const logo = textContent(document.querySelector('.logo-text, .site-title, header .logo'));
|
|
102
|
+
if (logo && !/^(home|index)$/i.test(logo)) return logo;
|
|
103
|
+
const author = metaContent(document, 'meta[name="author"]');
|
|
104
|
+
if (author) return author;
|
|
105
|
+
const title = textContent(document.querySelector('title'));
|
|
106
|
+
// "Home · Example Author" / "Writing · Example Author" → brand side
|
|
107
|
+
const parts = title.split(/\s*[·|—–-]\s*/).map((p) => p.trim()).filter(Boolean);
|
|
108
|
+
if (parts.length >= 2) {
|
|
109
|
+
const last = parts[parts.length - 1];
|
|
110
|
+
if (last && !/^(home|index|writing|essays|blog)$/i.test(last)) return last;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function titleFromDoc(document: Document): string {
|
|
116
|
+
const h1 = textContent(document.querySelector('h1'));
|
|
117
|
+
if (h1) return h1;
|
|
118
|
+
const og = metaContent(document, 'meta[property="og:title"]');
|
|
119
|
+
if (og) {
|
|
120
|
+
const parts = og.split(/\s*[·|—–-]\s*/).map((p) => p.trim()).filter(Boolean);
|
|
121
|
+
if (parts.length >= 2) return parts[0];
|
|
122
|
+
return og.trim();
|
|
123
|
+
}
|
|
124
|
+
return textContent(document.querySelector('title')).replace(/\s*[·|].*$/, '').trim() || 'Untitled';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeTagSlug(raw: string): string {
|
|
128
|
+
return raw
|
|
129
|
+
.normalize('NFKC')
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.trim()
|
|
132
|
+
.replace(/[\s_]+/g, '-')
|
|
133
|
+
.replace(/[^\p{L}\p{N}-]+/gu, '')
|
|
134
|
+
.replace(/-{2,}/g, '-')
|
|
135
|
+
.replace(/^-+|-+$/g, '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function tagsFromDoc(document: Document): string[] {
|
|
139
|
+
const tags = new Set<string>();
|
|
140
|
+
for (const a of document.querySelectorAll('a[href*="/tag"], a[href*="/tags/"]')) {
|
|
141
|
+
const href = a.getAttribute('href') ?? '';
|
|
142
|
+
const m = href.match(/\/tags?\/([^/]+)/i);
|
|
143
|
+
if (m) {
|
|
144
|
+
const t = normalizeTagSlug(decodeURIComponent(m[1]));
|
|
145
|
+
if (t) tags.add(t);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const chip of document.querySelectorAll('.tag-chip, .tag, [rel="tag"]')) {
|
|
149
|
+
const t = normalizeTagSlug(textContent(chip));
|
|
150
|
+
if (t && t.length < 40) tags.add(t);
|
|
151
|
+
}
|
|
152
|
+
return [...tags];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function dateFromDoc(document: Document): string | null {
|
|
156
|
+
const time = document.querySelector('time[datetime]');
|
|
157
|
+
const dt = time?.getAttribute('datetime');
|
|
158
|
+
if (dt) {
|
|
159
|
+
const iso = dt.slice(0, 10);
|
|
160
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadDoc(url: string): Promise<{ document: Document; finalUrl: string } | null> {
|
|
166
|
+
try {
|
|
167
|
+
const { status, text, url: finalUrl } = await fetchText(url);
|
|
168
|
+
if (status >= 400) return null;
|
|
169
|
+
const { document } = parseHTML(text);
|
|
170
|
+
return { document, finalUrl };
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.warn(`import: skip ${url}: ${e instanceof Error ? e.message : e}`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function uniqueSlug(base: string, used: Set<string>): string {
|
|
178
|
+
let slug = base || 'page';
|
|
179
|
+
if (RESERVED.has(slug)) slug = `page-${slug}`;
|
|
180
|
+
let n = 2;
|
|
181
|
+
let candidate = slug;
|
|
182
|
+
while (used.has(candidate)) {
|
|
183
|
+
candidate = `${slug}-${n++}`;
|
|
184
|
+
}
|
|
185
|
+
used.add(candidate);
|
|
186
|
+
return candidate;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Build SiteIR from discovery + HTML extraction. */
|
|
190
|
+
export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
|
|
191
|
+
const { origin, urls, rss, rssTitle } = discovered;
|
|
192
|
+
const notes: string[] = [];
|
|
193
|
+
const usedSlugs = new Set<string>();
|
|
194
|
+
|
|
195
|
+
const homeUrl = urls.find((u) => u.kind === 'home')?.url ?? `${origin}/`;
|
|
196
|
+
const homeDoc = await loadDoc(homeUrl);
|
|
197
|
+
|
|
198
|
+
let title = rssTitle?.replace(/\s*[—–-]\s*.*$/, '').trim() || 'Imported site';
|
|
199
|
+
let description = '';
|
|
200
|
+
let author = title;
|
|
201
|
+
let lede: string | null = null;
|
|
202
|
+
let generator: string | null = null;
|
|
203
|
+
|
|
204
|
+
if (homeDoc) {
|
|
205
|
+
const { document } = homeDoc;
|
|
206
|
+
const gen = document.querySelector('meta[name="generator"]')?.getAttribute('content');
|
|
207
|
+
generator = gen ?? (document.documentElement.outerHTML.includes('astro') ? 'Astro' : null);
|
|
208
|
+
title = siteTitleFromDoc(document) || titleFromDoc(document) || title;
|
|
209
|
+
if (/^(home|index)$/i.test(title)) {
|
|
210
|
+
title = siteTitleFromDoc(document) || rssTitle?.replace(/\s*[—–-]\s*.*$/, '').trim() || title;
|
|
211
|
+
}
|
|
212
|
+
description =
|
|
213
|
+
metaContent(document, 'meta[name="description"]') ||
|
|
214
|
+
metaContent(document, 'meta[property="og:description"]') ||
|
|
215
|
+
'';
|
|
216
|
+
author = metaContent(document, 'meta[name="author"]') || title;
|
|
217
|
+
|
|
218
|
+
const bioRoot = document.querySelector('.intro-bio, .bio, [class*="intro"]') || pickMain(document);
|
|
219
|
+
const clone = bioRoot.cloneNode(true) as Element;
|
|
220
|
+
stripChrome(clone);
|
|
221
|
+
const paragraphs = [...clone.querySelectorAll('p')]
|
|
222
|
+
.map((p) => textContent(p))
|
|
223
|
+
.filter((t) => t.length > 40);
|
|
224
|
+
if (paragraphs[0]) {
|
|
225
|
+
const raw = paragraphs[0];
|
|
226
|
+
if (raw.length <= 280) lede = raw;
|
|
227
|
+
else {
|
|
228
|
+
const cut = raw.slice(0, 280);
|
|
229
|
+
const sentence = cut.match(/^[\s\S]*?[.!?]\s/)?.[0]?.trim();
|
|
230
|
+
lede = sentence && sentence.length > 80 ? sentence : `${cut.replace(/\s+\S*$/, '').trim()}…`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
notes.push('Home bio mapped to config `lede` (posts remain the index). Long bio → pages/about.md when present.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const posts: SiteIRPost[] = [];
|
|
237
|
+
const rssByPath = new Map(rss.map((r) => [new URL(r.link, origin).pathname.replace(/\/+$/, ''), r]));
|
|
238
|
+
|
|
239
|
+
const postUrls = [
|
|
240
|
+
...new Set([
|
|
241
|
+
...urls.filter((u) => u.kind === 'post').map((u) => u.url),
|
|
242
|
+
...rss.map((r) => resolveUrl(origin, r.link)).filter((u): u is string => Boolean(u))
|
|
243
|
+
])
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
for (const url of postUrls) {
|
|
247
|
+
const loaded = await loadDoc(url);
|
|
248
|
+
if (!loaded) continue;
|
|
249
|
+
const { document, finalUrl } = loaded;
|
|
250
|
+
const pathKey = new URL(finalUrl).pathname.replace(/\/+$/, '');
|
|
251
|
+
const rssItem = rssByPath.get(pathKey);
|
|
252
|
+
const main = pickMain(document);
|
|
253
|
+
const clone = main.cloneNode(true) as Element;
|
|
254
|
+
// Drop title heading from body if present
|
|
255
|
+
const h1 = clone.querySelector('h1');
|
|
256
|
+
h1?.remove();
|
|
257
|
+
stripChrome(clone);
|
|
258
|
+
rewriteInternalLinks(clone, finalUrl, origin);
|
|
259
|
+
const subtitle = textContent(document.querySelector('.article-subtitle, .subtitle, .lede'));
|
|
260
|
+
const markdown = htmlToMarkdown(clone.innerHTML);
|
|
261
|
+
if (markdown.length < 40) {
|
|
262
|
+
notes.push(`Skipped thin post body: ${finalUrl}`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const slug = uniqueSlug(slugFromUrl(finalUrl), usedSlugs);
|
|
266
|
+
const date =
|
|
267
|
+
rfc822ToIso(rssItem?.pubDate ?? null) ||
|
|
268
|
+
dateFromDoc(document) ||
|
|
269
|
+
new Date().toISOString().slice(0, 10);
|
|
270
|
+
posts.push({
|
|
271
|
+
slug,
|
|
272
|
+
title: rssItem?.title || titleFromDoc(document),
|
|
273
|
+
date,
|
|
274
|
+
tags: tagsFromDoc(document),
|
|
275
|
+
description: rssItem?.description || subtitle || metaContent(document, 'meta[name="description"]'),
|
|
276
|
+
markdown,
|
|
277
|
+
sourceUrl: finalUrl,
|
|
278
|
+
imageUrls: extractImages(clone, finalUrl, origin)
|
|
279
|
+
});
|
|
280
|
+
notes.push(`Post ${finalUrl} → /posts/${slug}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
posts.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));
|
|
284
|
+
|
|
285
|
+
const pages: SiteIRPage[] = [];
|
|
286
|
+
const pageUrls = urls.filter((u) => u.kind === 'page');
|
|
287
|
+
let order = 1;
|
|
288
|
+
for (const { url } of pageUrls) {
|
|
289
|
+
const loaded = await loadDoc(url);
|
|
290
|
+
if (!loaded) continue;
|
|
291
|
+
const { document, finalUrl } = loaded;
|
|
292
|
+
const path = new URL(finalUrl).pathname.replace(/\/+$/, '') || '/';
|
|
293
|
+
if (path === '/') continue;
|
|
294
|
+
const main = pickMain(document);
|
|
295
|
+
const clone = main.cloneNode(true) as Element;
|
|
296
|
+
clone.querySelector('h1')?.remove();
|
|
297
|
+
stripChrome(clone);
|
|
298
|
+
rewriteInternalLinks(clone, finalUrl, origin);
|
|
299
|
+
const markdown = htmlToMarkdown(clone.innerHTML);
|
|
300
|
+
if (markdown.length < 20) {
|
|
301
|
+
notes.push(`Skipped thin page: ${finalUrl}`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const slug = uniqueSlug(slugFromUrl(finalUrl), usedSlugs);
|
|
305
|
+
const pageTitle = titleFromDoc(document);
|
|
306
|
+
// Prefer path-derived label when H1 is a personal name / brand duplicate
|
|
307
|
+
const prettySlug = slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
308
|
+
const useSlugTitle =
|
|
309
|
+
slug === 'about' ||
|
|
310
|
+
slug === 'contact' ||
|
|
311
|
+
(pageTitle.length > 0 && pageTitle === title);
|
|
312
|
+
pages.push({
|
|
313
|
+
slug,
|
|
314
|
+
title: useSlugTitle ? prettySlug : pageTitle,
|
|
315
|
+
description: metaContent(document, 'meta[name="description"]'),
|
|
316
|
+
markdown,
|
|
317
|
+
sourceUrl: finalUrl,
|
|
318
|
+
order: order++,
|
|
319
|
+
imageUrls: extractImages(clone, finalUrl, origin)
|
|
320
|
+
});
|
|
321
|
+
notes.push(`Page ${finalUrl} → /${slug}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Topics from tags
|
|
325
|
+
const tagCounts = new Map<string, number>();
|
|
326
|
+
for (const p of posts) {
|
|
327
|
+
for (const t of p.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
|
|
328
|
+
}
|
|
329
|
+
const topics = [...tagCounts.entries()]
|
|
330
|
+
.sort((a, b) => b[1] - a[1])
|
|
331
|
+
.slice(0, 12)
|
|
332
|
+
.map(([tag]) => ({
|
|
333
|
+
label: tag.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
334
|
+
tag
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
const nav: Array<{ label: string; href: string }> = [{ label: 'Posts', href: '/' }];
|
|
338
|
+
for (const page of pages) {
|
|
339
|
+
nav.push({
|
|
340
|
+
label: page.title,
|
|
341
|
+
href: `/${page.slug}`
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (topics.length) nav.push({ label: 'Topics', href: '/topics' });
|
|
345
|
+
|
|
346
|
+
const assets: string[] = [];
|
|
347
|
+
if (homeDoc) {
|
|
348
|
+
for (const link of homeDoc.document.querySelectorAll(
|
|
349
|
+
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
|
|
350
|
+
)) {
|
|
351
|
+
const href = link.getAttribute('href');
|
|
352
|
+
if (!href) continue;
|
|
353
|
+
const abs = resolveUrl(homeUrl, href);
|
|
354
|
+
if (abs && sameOrigin(abs, origin)) assets.push(abs);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Common fallbacks often linked from HTML even when not in <link>
|
|
358
|
+
for (const path of ['/favicon.ico', '/favicon.svg', '/favicon-64.png', '/apple-touch-icon.png']) {
|
|
359
|
+
assets.push(`${origin.replace(/\/+$/, '')}${path}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
source: { url: origin, generator },
|
|
364
|
+
identity: {
|
|
365
|
+
title,
|
|
366
|
+
description: description || `${title} — imported into filepress.`,
|
|
367
|
+
author,
|
|
368
|
+
canonicalUrl: origin.replace(/\/+$/, '')
|
|
369
|
+
},
|
|
370
|
+
posts,
|
|
371
|
+
pages,
|
|
372
|
+
nav,
|
|
373
|
+
topics,
|
|
374
|
+
lede,
|
|
375
|
+
notes,
|
|
376
|
+
assets: [...new Set(assets)]
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULT_UA = 'filepressImport/0.1 (+https://github.com/Catalyst-Forge-LLC/filepress)';
|
|
2
|
+
|
|
3
|
+
export async function fetchText(
|
|
4
|
+
url: string,
|
|
5
|
+
opts: { timeoutMs?: number; headers?: Record<string, string> } = {}
|
|
6
|
+
): Promise<{ url: string; status: number; text: string; contentType: string }> {
|
|
7
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
signal: ctrl.signal,
|
|
13
|
+
headers: {
|
|
14
|
+
'user-agent': DEFAULT_UA,
|
|
15
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
16
|
+
...opts.headers
|
|
17
|
+
},
|
|
18
|
+
redirect: 'follow'
|
|
19
|
+
});
|
|
20
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
return { url: res.url, status: res.status, text, contentType };
|
|
23
|
+
} finally {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchBuffer(url: string, timeoutMs = 30_000): Promise<Uint8Array> {
|
|
29
|
+
const ctrl = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
signal: ctrl.signal,
|
|
34
|
+
headers: { 'user-agent': DEFAULT_UA },
|
|
35
|
+
redirect: 'follow'
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
|
|
38
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function originOf(url: string): string {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
return `${u.protocol}//${u.host}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveUrl(base: string, href: string): string | null {
|
|
50
|
+
try {
|
|
51
|
+
return new URL(href, base).href;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sameOrigin(a: string, b: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(a).origin === new URL(b).origin;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import TurndownService from 'turndown';
|
|
2
|
+
|
|
3
|
+
const turndown = new TurndownService({
|
|
4
|
+
headingStyle: 'atx',
|
|
5
|
+
codeBlockStyle: 'fenced',
|
|
6
|
+
bulletListMarker: '-'
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
turndown.addRule('dropScriptStyle', {
|
|
10
|
+
filter: ['script', 'style', 'noscript', 'iframe'],
|
|
11
|
+
replacement: () => ''
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Convert an HTML fragment to Markdown. Deterministic — no LLM. */
|
|
15
|
+
export function htmlToMarkdown(html: string): string {
|
|
16
|
+
const md = turndown.turndown(html);
|
|
17
|
+
return md
|
|
18
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
19
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
20
|
+
.trim();
|
|
21
|
+
}
|