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,109 @@
1
+ /** Intermediate representation for a crawled site → filepress write. */
2
+
3
+ export type SiteIRPost = {
4
+ slug: string;
5
+ title: string;
6
+ date: string;
7
+ tags: string[];
8
+ description: string | null;
9
+ markdown: string;
10
+ sourceUrl: string;
11
+ /** Absolute image URLs discovered in the body (same-origin preferred). */
12
+ imageUrls: string[];
13
+ };
14
+
15
+ export type SiteIRPage = {
16
+ slug: string;
17
+ title: string;
18
+ description: string | null;
19
+ markdown: string;
20
+ sourceUrl: string;
21
+ order: number;
22
+ imageUrls: string[];
23
+ };
24
+
25
+ export type SiteIR = {
26
+ source: { url: string; generator: string | null };
27
+ identity: {
28
+ title: string;
29
+ description: string;
30
+ author: string;
31
+ canonicalUrl: string;
32
+ };
33
+ posts: SiteIRPost[];
34
+ pages: SiteIRPage[];
35
+ nav: Array<{ label: string; href: string }>;
36
+ topics: Array<{ label: string; tag: string }>;
37
+ /** Suggested lede for the filepress index (from home bio), or null. */
38
+ lede: string | null;
39
+ /** Notes for the import report (URL remaps, skips). */
40
+ notes: string[];
41
+ /** Same-origin chrome assets to copy into static/ (favicons, etc.). */
42
+ assets: string[];
43
+ };
44
+
45
+ export type DesignBrief = {
46
+ mood: string;
47
+ do: string[];
48
+ dont: string[];
49
+ tokens: {
50
+ accent: string;
51
+ accentStrong: string;
52
+ bg?: string;
53
+ ink?: string;
54
+ inkSoft?: string;
55
+ surface?: string;
56
+ rule?: string;
57
+ ruleStrong?: string;
58
+ accentWash?: string;
59
+ };
60
+ density: 'sparse' | 'balanced' | 'dense';
61
+ /** When set, theme generator punches up structure — not just token swaps. */
62
+ paletteMode?: 'dark' | 'light';
63
+ fonts?: {
64
+ serif: string;
65
+ sans: string;
66
+ mono: string;
67
+ /** Full Google Fonts CSS2 URL, or null to skip remote fonts. */
68
+ googleHref: string | null;
69
+ };
70
+ hero?: 'editorial' | 'bold';
71
+ atmosphere?: 'none' | 'noise';
72
+ navStyle?: 'soft' | 'uppercase-tracked';
73
+ elevatedCards?: boolean;
74
+ /** Local static paths after fetch (e.g. /images/hero.jpg). Portraits are never CSS covers. */
75
+ images?: {
76
+ hero?: string | null;
77
+ header?: string | null;
78
+ background?: string | null;
79
+ logo?: string | null;
80
+ /** Author/headshot — kept for authors; not applied as a background. */
81
+ portrait?: string | null;
82
+ };
83
+ cssNotes: string[];
84
+ };
85
+
86
+ export type ImageCandidate = {
87
+ url: string;
88
+ role: 'hero' | 'header' | 'background' | 'logo' | 'portrait' | 'other';
89
+ source: string;
90
+ alt?: string;
91
+ };
92
+
93
+ export type ImportOptions = {
94
+ source: string;
95
+ inspire: string[];
96
+ out: string;
97
+ siteName: string;
98
+ title?: string;
99
+ author?: string;
100
+ canonicalUrl?: string;
101
+ ollamaHost: string;
102
+ ollamaModel: string;
103
+ noLlm: boolean;
104
+ dryRun: boolean;
105
+ force: boolean;
106
+ /** Download suggested hero/header/bg images into static/images/. */
107
+ fetchImages: boolean;
108
+ engineRoot: string;
109
+ };
@@ -0,0 +1,145 @@
1
+ import type { DesignBrief, SiteIR } from './ir.ts';
2
+ import type { InspirationSignals } from './inspire.ts';
3
+ import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
4
+
5
+ export async function ollamaAvailable(host: string): Promise<boolean> {
6
+ try {
7
+ const res = await fetch(`${host.replace(/\/+$/, '')}/api/tags`, {
8
+ signal: AbortSignal.timeout(4000)
9
+ });
10
+ return res.ok;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ /** Shared copy for import + Genie Mode when Ollama is missing or unused. */
17
+ export function ollamaSetupHint(host: string): string {
18
+ const h = host.replace(/\/+$/, '') || 'http://127.0.0.1:11434';
19
+ return [
20
+ `Ollama not reachable at ${h}.`,
21
+ `Install from https://ollama.com then pull a model (e.g. ollama pull gemma4:12b).`,
22
+ `For a GPU-tuned named variant, use Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna`,
23
+ `Then set FILEPRESS_OLLAMA_MODEL to that name (and OLLAMA_HOST if remote).`
24
+ ].join(' ');
25
+ }
26
+
27
+ export async function generateDesignBrief(opts: {
28
+ host: string;
29
+ model: string;
30
+ ir: SiteIR;
31
+ inspireSummaries: string[];
32
+ inspireSignals: InspirationSignals[];
33
+ seed: DesignBrief;
34
+ }): Promise<DesignBrief> {
35
+ const host = opts.host.replace(/\/+$/, '');
36
+ const prompt = `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
37
+ The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
38
+ Return ONLY JSON (no fences) with this shape:
39
+ {
40
+ "mood": "short phrase",
41
+ "do": ["..."],
42
+ "dont": ["..."],
43
+ "tokens": {
44
+ "accent": "#rrggbb",
45
+ "accentStrong": "#rrggbb",
46
+ "bg": "#rrggbb",
47
+ "ink": "#rrggbb",
48
+ "inkSoft": "#rrggbb",
49
+ "surface": "#rrggbb",
50
+ "rule": "#rrggbb",
51
+ "ruleStrong": "#rrggbb"
52
+ },
53
+ "density": "sparse" | "balanced" | "dense",
54
+ "paletteMode": "dark" | "light",
55
+ "fonts": {
56
+ "serif": "Font Name",
57
+ "sans": "Font Name",
58
+ "mono": "Font Name",
59
+ "googleHref": "https://fonts.googleapis.com/css2?..." or null
60
+ },
61
+ "hero": "bold" | "editorial",
62
+ "atmosphere": "noise" | "none",
63
+ "navStyle": "uppercase-tracked" | "soft",
64
+ "elevatedCards": true | false,
65
+ "cssNotes": ["..."]
66
+ }
67
+
68
+ Hard rules:
69
+ - If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
70
+ - Personal essay site: do NOT invent marketing section layouts.
71
+ - Prefer inspiration fonts/colors over the source site's cream-editorial look.
72
+ - Avoid purple-on-white clichés.
73
+
74
+ Seed brief (from inspiration extraction):
75
+ ${JSON.stringify(opts.seed, null, 2)}
76
+
77
+ Site identity (content only — do not force its old palette):
78
+ ${JSON.stringify(opts.ir.identity, null, 2)}
79
+
80
+ Inspiration notes:
81
+ ${opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)'}
82
+
83
+ Inspiration text snippets:
84
+ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
85
+ `;
86
+
87
+ const res = await fetch(`${host}/api/chat`, {
88
+ method: 'POST',
89
+ headers: { 'content-type': 'application/json' },
90
+ body: JSON.stringify({
91
+ model: opts.model,
92
+ stream: false,
93
+ format: 'json',
94
+ options: { temperature: 0.35 },
95
+ messages: [
96
+ {
97
+ role: 'system',
98
+ content:
99
+ 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
100
+ },
101
+ { role: 'user', content: prompt }
102
+ ]
103
+ }),
104
+ signal: AbortSignal.timeout(180_000)
105
+ });
106
+
107
+ if (!res.ok) {
108
+ const body = await res.text().catch(() => '');
109
+ throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
110
+ }
111
+
112
+ const data = (await res.json()) as { message?: { content?: string } };
113
+ const content = data.message?.content ?? '';
114
+ try {
115
+ const refined = parseBriefJson(content);
116
+ // Never let the model drop fonts/googleHref from a rich seed
117
+ return {
118
+ ...opts.seed,
119
+ ...refined,
120
+ tokens: { ...opts.seed.tokens, ...refined.tokens },
121
+ fonts: refined.fonts || opts.seed.fonts,
122
+ paletteMode: refined.paletteMode || opts.seed.paletteMode,
123
+ hero: refined.hero || opts.seed.hero,
124
+ atmosphere: refined.atmosphere || opts.seed.atmosphere,
125
+ navStyle: refined.navStyle || opts.seed.navStyle,
126
+ elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
127
+ };
128
+ } catch (e) {
129
+ console.warn(`import: brief parse failed (${e}); using seed brief`);
130
+ return opts.seed;
131
+ }
132
+ }
133
+
134
+ /** Short text summary of an inspiration homepage for the brief prompt. */
135
+ export function summarizeHtmlForBrief(html: string, max = 1200): string {
136
+ const text = html
137
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
138
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
139
+ .replace(/<[^>]+>/g, ' ')
140
+ .replace(/\s+/g, ' ')
141
+ .trim();
142
+ return text.slice(0, max);
143
+ }
144
+
145
+ export { DEFAULT_BRIEF };
@@ -0,0 +1,218 @@
1
+ import type { DesignBrief } from './ir.ts';
2
+
3
+ const OPENVERSE = 'https://api.openverse.org/v1/images/';
4
+ const UA = 'filepressImport/0.1 (+https://github.com/Catalyst-Forge-LLC/filepress)';
5
+
6
+ export type StockHit = {
7
+ url: string;
8
+ title: string;
9
+ creator: string | null;
10
+ license: string;
11
+ licenseUrl: string;
12
+ foreignLandingUrl: string;
13
+ attribution: string;
14
+ query: string;
15
+ };
16
+
17
+ type OpenverseResult = {
18
+ url?: string;
19
+ title?: string;
20
+ creator?: string | null;
21
+ license?: string;
22
+ license_url?: string;
23
+ foreign_landing_url?: string;
24
+ attribution?: string;
25
+ width?: number;
26
+ height?: number;
27
+ mature?: boolean;
28
+ };
29
+
30
+ /** Build stock search queries from the design brief (not inspiration site assets). */
31
+ export function stockQueriesForBrief(brief: DesignBrief, _author?: string): {
32
+ background: string;
33
+ header: string;
34
+ hero: string;
35
+ } {
36
+ const mode = brief.paletteMode || 'dark';
37
+ // Keep queries short — Openverse ranks better on simple visual terms than mood essays.
38
+ if (mode === 'dark') {
39
+ return {
40
+ background: 'abstract dark texture',
41
+ header: 'dark abstract texture background',
42
+ hero: 'dark abstract background'
43
+ };
44
+ }
45
+ return {
46
+ background: 'warm paper texture',
47
+ header: 'soft light abstract',
48
+ hero: 'neutral abstract background'
49
+ };
50
+ }
51
+
52
+ function isUsableCover(r: OpenverseResult): boolean {
53
+ if (!r.url || r.mature) return false;
54
+ if (!/^https?:\/\//i.test(r.url)) return false;
55
+ const blob = `${r.title || ''} ${r.url}`.toLowerCase();
56
+ // Openverse is a mixed bag — reject obvious people/pop-culture dumps.
57
+ if (
58
+ /\b(spidey|spider-?man|thor|cosplay|comic-?con|wedding|selfie|portrait|headshot|meme|cartoon|anime|pokemon|marvel|disney)\b/.test(
59
+ blob
60
+ )
61
+ ) {
62
+ return false;
63
+ }
64
+ // Prefer texture/atmosphere language in the title when present.
65
+ return true;
66
+ }
67
+
68
+ function scoreCover(r: OpenverseResult): number {
69
+ let score = 0;
70
+ const title = (r.title || '').toLowerCase();
71
+ const lic = (r.license || '').toLowerCase();
72
+ if (lic === 'cc0' || lic === 'pdm') score += 50;
73
+ else if (lic === 'by') score += 20;
74
+ else if (lic.includes('sa')) score -= 30; // share-alike is awkward for site chrome
75
+ if (/texture|abstract|background|gradient|bokeh|paper|grain|noise|dark/.test(title)) score += 25;
76
+ const ratio = (r.width ?? 0) / Math.max(r.height ?? 1, 1);
77
+ if (ratio >= 1.3) score += 15;
78
+ if (/rawpixel|wikimedia|stocksnap/i.test(r.url || '')) score += 10;
79
+ return score;
80
+ }
81
+
82
+ /** Public: search Openverse for a single cover-quality image. */
83
+ export async function searchStockImage(query: string): Promise<StockHit | null> {
84
+ return searchOpenverse(query);
85
+ }
86
+
87
+ async function searchOpenverse(query: string): Promise<StockHit | null> {
88
+ const trySearch = async (params: URLSearchParams) => {
89
+ const res = await fetch(`${OPENVERSE}?${params}`, {
90
+ headers: {
91
+ 'user-agent': UA,
92
+ accept: 'application/json'
93
+ }
94
+ });
95
+ if (!res.ok) {
96
+ throw new Error(`Openverse search failed (${res.status}) for “${query}”`);
97
+ }
98
+ return (await res.json()) as { results?: OpenverseResult[] };
99
+ };
100
+
101
+ const attempts: URLSearchParams[] = [
102
+ new URLSearchParams({
103
+ q: query,
104
+ page_size: '20',
105
+ license: 'cc0,pdm',
106
+ mature: 'false',
107
+ category: 'photograph'
108
+ }),
109
+ new URLSearchParams({
110
+ q: query,
111
+ page_size: '20',
112
+ license: 'cc0,pdm',
113
+ mature: 'false'
114
+ }),
115
+ new URLSearchParams({
116
+ q: query,
117
+ page_size: '20',
118
+ license_type: 'commercial',
119
+ mature: 'false',
120
+ category: 'photograph'
121
+ })
122
+ ];
123
+
124
+ const pool: OpenverseResult[] = [];
125
+ for (const params of attempts) {
126
+ try {
127
+ const data = await trySearch(params);
128
+ for (const r of data.results ?? []) {
129
+ if (isUsableCover(r)) pool.push(r);
130
+ }
131
+ if (pool.length >= 4) break;
132
+ } catch {
133
+ /* try next */
134
+ }
135
+ }
136
+
137
+ pool.sort((a, b) => scoreCover(b) - scoreCover(a));
138
+ const best = pool[0];
139
+ if (!best?.url) return null;
140
+
141
+ return {
142
+ url: best.url,
143
+ title: best.title || 'Untitled',
144
+ creator: best.creator ?? null,
145
+ license: best.license || 'unknown',
146
+ licenseUrl: best.license_url || '',
147
+ foreignLandingUrl: best.foreign_landing_url || best.url,
148
+ attribution:
149
+ best.attribution ||
150
+ `“${best.title || 'Untitled'}”${best.creator ? ` by ${best.creator}` : ''} (${best.license || 'license unknown'})`,
151
+ query
152
+ };
153
+ }
154
+
155
+ export type StockPlan = {
156
+ images: NonNullable<DesignBrief['images']>;
157
+ hits: Partial<Record<'background' | 'header' | 'hero', StockHit>>;
158
+ notes: string[];
159
+ };
160
+
161
+ /**
162
+ * Resolve CSS-cover slots from Openverse (CC commercial-friendly), not from --inspire pages.
163
+ * Portrait/logo stay with the source-site harvest.
164
+ */
165
+ export async function planStockCovers(
166
+ brief: DesignBrief,
167
+ opts?: { author?: string; includeHeader?: boolean; includeHero?: boolean }
168
+ ): Promise<StockPlan> {
169
+ const queries = stockQueriesForBrief(brief, opts?.author);
170
+ const hits: StockPlan['hits'] = {};
171
+ const notes: string[] = [
172
+ 'Chrome covers from Openverse (Creative Commons, commercial-friendly), not inspiration sites.'
173
+ ];
174
+ const used = new Set<string>();
175
+
176
+ const take = async (role: 'background' | 'header' | 'hero', query: string) => {
177
+ try {
178
+ const hit = await searchOpenverse(query);
179
+ if (!hit || used.has(hit.url)) {
180
+ notes.push(`- ${role}: no distinct stock hit for “${query}”`);
181
+ return;
182
+ }
183
+ used.add(hit.url);
184
+ hits[role] = hit;
185
+ notes.push(
186
+ `- ${role}: “${hit.title}” (${hit.license}) via Openverse — ${hit.foreignLandingUrl}`
187
+ );
188
+ } catch (e) {
189
+ notes.push(
190
+ `- ${role}: stock search failed — ${e instanceof Error ? e.message : String(e)}`
191
+ );
192
+ }
193
+ };
194
+
195
+ await take('background', queries.background);
196
+ if (opts?.includeHeader !== false) await take('header', queries.header);
197
+ if (opts?.includeHero) await take('hero', queries.hero);
198
+
199
+ return {
200
+ images: {
201
+ background: hits.background?.url ?? null,
202
+ header: hits.header?.url ?? null,
203
+ hero: hits.hero?.url ?? null
204
+ },
205
+ hits,
206
+ notes
207
+ };
208
+ }
209
+
210
+ export function formatAttributionMarkdown(
211
+ hits: StockPlan['hits']
212
+ ): string {
213
+ const lines = Object.entries(hits).map(([role, hit]) => {
214
+ if (!hit) return '';
215
+ return `- **${role}:** ${hit.attribution} — [source](${hit.foreignLandingUrl}) · [license](${hit.licenseUrl || hit.foreignLandingUrl})`;
216
+ });
217
+ return lines.filter(Boolean).join('\n');
218
+ }