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,247 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseHTML } from 'linkedom';
|
|
4
|
+
import { fetchBuffer, fetchText, resolveUrl } from './fetch.ts';
|
|
5
|
+
import type { DesignBrief, ImageCandidate, SiteIR } from './ir.ts';
|
|
6
|
+
|
|
7
|
+
function isFaviconish(url: string, alt = ''): boolean {
|
|
8
|
+
return /favicon|apple-touch|android-chrome|icon-\d|\/icon\./i.test(`${url} ${alt}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isPortraitish(url: string, context = ''): boolean {
|
|
12
|
+
const s = `${url} ${context}`.toLowerCase();
|
|
13
|
+
return /portrait|headshot|avatar|profile|photo-of|author|face|example-portrait|head-shot/.test(
|
|
14
|
+
s
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isAtmosphereish(url: string, context = ''): boolean {
|
|
19
|
+
const s = `${url} ${context}`.toLowerCase();
|
|
20
|
+
return /board-?room|office|conference|texture|atmosphere|background|bg-|landscape|hero-bg|banner-bg|pipeline|dossier|contender/.test(
|
|
21
|
+
s
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isWordmarkish(url: string, context = ''): boolean {
|
|
26
|
+
const s = `${url} ${context}`.toLowerCase();
|
|
27
|
+
if (isFaviconish(url, context)) return false;
|
|
28
|
+
return /logo|wordmark|brand/.test(s) && !/favicon|icon/.test(s);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function classifyImage(url: string, context: string): ImageCandidate['role'] {
|
|
32
|
+
const u = url.toLowerCase();
|
|
33
|
+
const ctx = context.toLowerCase();
|
|
34
|
+
if (isPortraitish(u, ctx)) return 'portrait';
|
|
35
|
+
if (isWordmarkish(u, ctx)) return 'logo';
|
|
36
|
+
if (isFaviconish(u, ctx)) return 'logo'; // filtered later unless nothing else
|
|
37
|
+
if (isAtmosphereish(u, ctx) || /bg|background|texture|noise|pattern/.test(u + ctx)) {
|
|
38
|
+
return 'background';
|
|
39
|
+
}
|
|
40
|
+
if (/hero|banner|masthead|header/.test(u) || /hero|banner|masthead/.test(ctx)) {
|
|
41
|
+
return /header|nav|masthead/.test(ctx) ? 'header' : 'hero';
|
|
42
|
+
}
|
|
43
|
+
// og:image is often a portrait or card — never assume it's a CSS hero strip
|
|
44
|
+
if (/og:image|twitter:image|opengraph/.test(ctx)) {
|
|
45
|
+
return isPortraitish(u, ctx) ? 'portrait' : 'other';
|
|
46
|
+
}
|
|
47
|
+
return 'other';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pushUnique(list: ImageCandidate[], item: ImageCandidate) {
|
|
51
|
+
if (!item.url || item.url.startsWith('data:')) return;
|
|
52
|
+
if (list.some((x) => x.url === item.url)) return;
|
|
53
|
+
list.push(item);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Harvest image candidates from a page (meta + CSS urls + large imgs). */
|
|
57
|
+
export async function harvestImagesFromPage(
|
|
58
|
+
pageUrl: string,
|
|
59
|
+
label: string
|
|
60
|
+
): Promise<ImageCandidate[]> {
|
|
61
|
+
const { text: html, url: finalUrl } = await fetchText(pageUrl);
|
|
62
|
+
const { document } = parseHTML(html);
|
|
63
|
+
const out: ImageCandidate[] = [];
|
|
64
|
+
|
|
65
|
+
for (const sel of [
|
|
66
|
+
'meta[property="og:image"]',
|
|
67
|
+
'meta[name="twitter:image"]',
|
|
68
|
+
'meta[property="og:image:url"]'
|
|
69
|
+
]) {
|
|
70
|
+
const content = document.querySelector(sel)?.getAttribute('content');
|
|
71
|
+
if (!content) continue;
|
|
72
|
+
const abs = resolveUrl(finalUrl, content);
|
|
73
|
+
if (!abs) continue;
|
|
74
|
+
const role = classifyImage(abs, 'og:image');
|
|
75
|
+
pushUnique(out, { url: abs, role, source: label, alt: 'og:image' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const link of document.querySelectorAll('link[rel="apple-touch-icon"], link[rel="icon"]')) {
|
|
79
|
+
const href = link.getAttribute('href');
|
|
80
|
+
if (!href) continue;
|
|
81
|
+
const abs = resolveUrl(finalUrl, href);
|
|
82
|
+
if (abs && /\.(png|jpe?g|webp|svg)(\?|$)/i.test(abs)) {
|
|
83
|
+
pushUnique(out, { url: abs, role: 'logo', source: label, alt: 'icon' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const img of document.querySelectorAll('img[src]')) {
|
|
88
|
+
const src = img.getAttribute('src');
|
|
89
|
+
if (!src) continue;
|
|
90
|
+
const abs = resolveUrl(finalUrl, src);
|
|
91
|
+
if (!abs) continue;
|
|
92
|
+
const alt = img.getAttribute('alt') || '';
|
|
93
|
+
const w = Number(img.getAttribute('width') || 0);
|
|
94
|
+
const cls = img.getAttribute('class') || '';
|
|
95
|
+
const role = classifyImage(abs, `${alt} ${cls}`);
|
|
96
|
+
if (role === 'other' && w > 0 && w < 200) continue;
|
|
97
|
+
// Don't promote unknown small imgs to hero covers
|
|
98
|
+
pushUnique(out, { url: abs, role, source: label, alt });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const m of html.matchAll(/background-image:\s*url\((['"]?)([^'")]+)\1\)/gi)) {
|
|
102
|
+
const abs = resolveUrl(finalUrl, m[2]);
|
|
103
|
+
if (!abs || abs.startsWith('data:')) continue;
|
|
104
|
+
pushUnique(out, {
|
|
105
|
+
url: abs,
|
|
106
|
+
role: classifyImage(abs, 'background'),
|
|
107
|
+
source: label,
|
|
108
|
+
alt: 'css-background'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type ImagePlan = {
|
|
116
|
+
candidates: ImageCandidate[];
|
|
117
|
+
chosen: NonNullable<DesignBrief['images']>;
|
|
118
|
+
unsplashQueries: string[];
|
|
119
|
+
notes: string[];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pick site-owned assets from the crawl.
|
|
124
|
+
* CSS covers (hero/header/background) come from Openverse stock via planStockCovers —
|
|
125
|
+
* never from --inspire marketing photos.
|
|
126
|
+
*/
|
|
127
|
+
export function planImages(
|
|
128
|
+
ir: SiteIR,
|
|
129
|
+
harvested: ImageCandidate[],
|
|
130
|
+
brief: DesignBrief
|
|
131
|
+
): ImagePlan {
|
|
132
|
+
const sourceHost = (() => {
|
|
133
|
+
try {
|
|
134
|
+
return new URL(ir.source.url).hostname.replace(/^www\./, '');
|
|
135
|
+
} catch {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
const fromSource = (c: ImageCandidate) => {
|
|
141
|
+
try {
|
|
142
|
+
return new URL(c.url).hostname.replace(/^www\./, '') === sourceHost;
|
|
143
|
+
} catch {
|
|
144
|
+
return c.source.includes(sourceHost);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const byRole = (role: ImageCandidate['role']) => harvested.filter((c) => c.role === role);
|
|
149
|
+
|
|
150
|
+
const pick = (
|
|
151
|
+
roles: ImageCandidate['role'][],
|
|
152
|
+
opts?: { skipFavicon?: boolean; sourceOnly?: boolean }
|
|
153
|
+
) => {
|
|
154
|
+
for (const r of roles) {
|
|
155
|
+
for (const hit of byRole(r)) {
|
|
156
|
+
if (opts?.skipFavicon && isFaviconish(hit.url, hit.alt)) continue;
|
|
157
|
+
if (opts?.sourceOnly && !fromSource(hit)) continue;
|
|
158
|
+
return hit.url;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const portrait = pick(['portrait'], { skipFavicon: true });
|
|
165
|
+
const logo = pick(['logo'], { sourceOnly: true, skipFavicon: true });
|
|
166
|
+
|
|
167
|
+
// Covers filled later by Openverse when --fetch-images is set
|
|
168
|
+
const chosen = {
|
|
169
|
+
hero: null,
|
|
170
|
+
header: null,
|
|
171
|
+
background: null,
|
|
172
|
+
logo,
|
|
173
|
+
portrait
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const mood = brief.mood || 'editorial';
|
|
177
|
+
const mode = brief.paletteMode || 'dark';
|
|
178
|
+
const unsplashQueries = [
|
|
179
|
+
`${mode} abstract ${mood.split(/[—,:]/)[0]?.trim() || 'editorial'} texture`,
|
|
180
|
+
`minimal geometric background ${mode === 'dark' ? 'dark gold' : 'warm paper'}`,
|
|
181
|
+
`soft atmospheric gradient ${mode}`,
|
|
182
|
+
`${ir.identity.author || ir.identity.title} portrait professional (photo slot, not CSS cover)`
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const notes = [
|
|
186
|
+
`Source/inspire image candidates (logos & portraits only used from source): ${harvested.length}`,
|
|
187
|
+
...harvested.slice(0, 10).map((c) => `- [${c.role}] ${c.url} (${c.source})`),
|
|
188
|
+
harvested.length > 10 ? `- …and ${harvested.length - 10} more` : '',
|
|
189
|
+
'',
|
|
190
|
+
'CSS covers use Openverse Creative Commons stock (not inspiration-site photos).',
|
|
191
|
+
chosen.portrait
|
|
192
|
+
? `Portrait candidate (not a cover): ${chosen.portrait}`
|
|
193
|
+
: 'No portrait candidate found.',
|
|
194
|
+
!chosen.logo
|
|
195
|
+
? 'No source wordmark logo — keeping text site title.'
|
|
196
|
+
: `Logo: ${chosen.logo}`,
|
|
197
|
+
'',
|
|
198
|
+
'Manual stock search ideas:',
|
|
199
|
+
...unsplashQueries.map((q) => `- ${q}`),
|
|
200
|
+
'',
|
|
201
|
+
'Re-run with --fetch-images to download Openverse covers + source portrait/logo.'
|
|
202
|
+
].filter(Boolean);
|
|
203
|
+
|
|
204
|
+
return { candidates: harvested, chosen, unsplashQueries, notes };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function extOf(url: string, buf: Uint8Array): string {
|
|
208
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
209
|
+
if (path.endsWith('.png')) return '.png';
|
|
210
|
+
if (path.endsWith('.webp')) return '.webp';
|
|
211
|
+
if (path.endsWith('.svg')) return '.svg';
|
|
212
|
+
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return '.jpg';
|
|
213
|
+
if (buf[0] === 0x89 && buf[1] === 0x50) return '.png';
|
|
214
|
+
if (buf[0] === 0xff && buf[1] === 0xd8) return '.jpg';
|
|
215
|
+
return '.jpg';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Download chosen remote images into static/images/{role}.*
|
|
220
|
+
* Returns local public paths for the brief.
|
|
221
|
+
*/
|
|
222
|
+
export async function fetchChosenImages(
|
|
223
|
+
chosen: NonNullable<DesignBrief['images']>,
|
|
224
|
+
staticDir: string
|
|
225
|
+
): Promise<NonNullable<DesignBrief['images']>> {
|
|
226
|
+
const imgDir = join(staticDir, 'images');
|
|
227
|
+
mkdirSync(imgDir, { recursive: true });
|
|
228
|
+
const local: NonNullable<DesignBrief['images']> = {};
|
|
229
|
+
|
|
230
|
+
for (const role of ['hero', 'header', 'background', 'logo', 'portrait'] as const) {
|
|
231
|
+
const url = chosen[role];
|
|
232
|
+
if (!url) continue;
|
|
233
|
+
try {
|
|
234
|
+
const buf = await fetchBuffer(url);
|
|
235
|
+
const ext = extOf(url, buf);
|
|
236
|
+
const name = `${role}${ext}`;
|
|
237
|
+
writeFileSync(join(imgDir, name), buf);
|
|
238
|
+
local[role] = `/images/${name}`;
|
|
239
|
+
console.log(`import: saved ${role} → static/images/${name}`);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
console.warn(
|
|
242
|
+
`import: could not fetch ${role} image ${url}: ${e instanceof Error ? e.message : e}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return local;
|
|
247
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { fetchText, resolveUrl } from './fetch.ts';
|
|
2
|
+
import type { DesignBrief } from './ir.ts';
|
|
3
|
+
|
|
4
|
+
export type InspirationSignals = {
|
|
5
|
+
url: string;
|
|
6
|
+
/** Raw Google Fonts stylesheet hrefs. */
|
|
7
|
+
googleFontHrefs: string[];
|
|
8
|
+
fontSerif: string | null;
|
|
9
|
+
fontSans: string | null;
|
|
10
|
+
fontMono: string | null;
|
|
11
|
+
tokens: Partial<DesignBrief['tokens']> & {
|
|
12
|
+
surface?: string;
|
|
13
|
+
rule?: string;
|
|
14
|
+
ruleStrong?: string;
|
|
15
|
+
accentWash?: string;
|
|
16
|
+
};
|
|
17
|
+
paletteMode: 'dark' | 'light';
|
|
18
|
+
hasNoise: boolean;
|
|
19
|
+
notes: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function luminance(hex: string): number {
|
|
23
|
+
const h = hex.replace('#', '');
|
|
24
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
25
|
+
if (full.length !== 6) return 0.5;
|
|
26
|
+
const n = parseInt(full, 16);
|
|
27
|
+
const r = ((n >> 16) & 255) / 255;
|
|
28
|
+
const g = ((n >> 8) & 255) / 255;
|
|
29
|
+
const b = (n & 255) / 255;
|
|
30
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectCssVars(css: string): Map<string, string> {
|
|
34
|
+
const vars = new Map<string, string>();
|
|
35
|
+
for (const block of css.matchAll(/:root\s*\{([^}]+)\}/g)) {
|
|
36
|
+
for (const hit of block[1].matchAll(/--([a-zA-Z0-9-_]+)\s*:\s*([^;}]+)/g)) {
|
|
37
|
+
vars.set(hit[1], hit[2].trim());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return vars;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveVar(vars: Map<string, string>, name: string, depth = 0): string | undefined {
|
|
44
|
+
if (depth > 6) return undefined;
|
|
45
|
+
const raw = vars.get(name);
|
|
46
|
+
if (!raw) return undefined;
|
|
47
|
+
const ref = raw.match(/^var\(\s*--([a-zA-Z0-9-_]+)\s*\)$/);
|
|
48
|
+
if (ref) return resolveVar(vars, ref[1], depth + 1);
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function firstHex(vars: Map<string, string>, ...names: string[]): string | undefined {
|
|
53
|
+
for (const n of names) {
|
|
54
|
+
const v = resolveVar(vars, n);
|
|
55
|
+
if (!v) continue;
|
|
56
|
+
const hex = v.match(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
|
|
57
|
+
if (hex) return hex[0];
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseGoogleFonts(href: string): { serif?: string; sans?: string; mono?: string } {
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(href);
|
|
65
|
+
const family = u.searchParams.get('family') || '';
|
|
66
|
+
const names = family.split('|').map((p) => decodeURIComponent(p.split(':')[0].replace(/\+/g, ' ')));
|
|
67
|
+
const out: { serif?: string; sans?: string; mono?: string } = {};
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const lower = name.toLowerCase();
|
|
70
|
+
if (!out.serif && /serif|fraunces|newsreader|instrument|display|playfair|libre/.test(lower)) {
|
|
71
|
+
out.serif = name;
|
|
72
|
+
} else if (!out.mono && /mono|jetbrains|fira code|source code/.test(lower)) {
|
|
73
|
+
out.mono = name;
|
|
74
|
+
} else if (!out.sans && /sans|inter|dm sans|sen|geist|helvetica|work sans/.test(lower)) {
|
|
75
|
+
out.sans = name;
|
|
76
|
+
} else if (!out.sans) {
|
|
77
|
+
out.sans = name;
|
|
78
|
+
} else if (!out.serif) {
|
|
79
|
+
out.serif = name;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchLinkedStylesheets(html: string, pageUrl: string, limit = 4): Promise<string> {
|
|
89
|
+
const hrefs = [...html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/gi)]
|
|
90
|
+
.map((m) => {
|
|
91
|
+
const href = m[0].match(/href=["']([^"']+)["']/i)?.[1];
|
|
92
|
+
return href ? resolveUrl(pageUrl, href) : null;
|
|
93
|
+
})
|
|
94
|
+
.filter((u): u is string => Boolean(u))
|
|
95
|
+
.slice(0, limit);
|
|
96
|
+
|
|
97
|
+
const chunks: string[] = [];
|
|
98
|
+
for (const href of hrefs) {
|
|
99
|
+
try {
|
|
100
|
+
const { status, text } = await fetchText(href);
|
|
101
|
+
if (status < 400 && text.length < 800_000) chunks.push(text);
|
|
102
|
+
} catch {
|
|
103
|
+
/* skip */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return chunks.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Pull fonts + palette + atmosphere cues from an inspiration homepage. */
|
|
110
|
+
export async function extractInspirationSignals(url: string): Promise<InspirationSignals> {
|
|
111
|
+
const { text: html, url: finalUrl } = await fetchText(url);
|
|
112
|
+
const notes: string[] = [];
|
|
113
|
+
const googleFontHrefs = [...html.matchAll(/href=["'](https:\/\/fonts\.googleapis\.com\/css2\?[^"']+)["']/gi)].map(
|
|
114
|
+
(m) => m[1]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
let fontSerif: string | null = null;
|
|
118
|
+
let fontSans: string | null = null;
|
|
119
|
+
let fontMono: string | null = null;
|
|
120
|
+
for (const href of googleFontHrefs) {
|
|
121
|
+
const parsed = parseGoogleFonts(href);
|
|
122
|
+
fontSerif ||= parsed.serif ?? null;
|
|
123
|
+
fontSans ||= parsed.sans ?? null;
|
|
124
|
+
fontMono ||= parsed.mono ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const css = `${html}\n${await fetchLinkedStylesheets(html, finalUrl)}`;
|
|
128
|
+
const vars = collectCssVars(css);
|
|
129
|
+
|
|
130
|
+
const bg = firstHex(vars, 'bg', 'background', 'bg-color', 'color-bg');
|
|
131
|
+
const ink = firstHex(vars, 'text', 'ink', 'text-color', 'color-text', 'foreground');
|
|
132
|
+
const accent = firstHex(vars, 'accent', 'accent-color', 'color-accent', 'primary');
|
|
133
|
+
const accentStrong = firstHex(vars, 'accent-dim', 'accent-strong', 'hot', 'color-hot') || accent;
|
|
134
|
+
const surface = firstHex(vars, 'surface', 'bg-card', 'bg-elevated', 'color-surface');
|
|
135
|
+
const rule = firstHex(vars, 'border', 'rule', 'color-border');
|
|
136
|
+
const ruleStrong = firstHex(vars, 'border-glow', 'rule-strong');
|
|
137
|
+
const inkSoft = firstHex(vars, 'text-muted', 'ink-soft', 'color-muted');
|
|
138
|
+
|
|
139
|
+
// CSS var font families when Google parse missed
|
|
140
|
+
const serifVar = resolveVar(vars, 'serif') || resolveVar(vars, 'font-serif');
|
|
141
|
+
const sansVar = resolveVar(vars, 'sans') || resolveVar(vars, 'font-sans');
|
|
142
|
+
const monoVar = resolveVar(vars, 'mono') || resolveVar(vars, 'font-mono');
|
|
143
|
+
const named = (v?: string) => v?.match(/"([^"]+)"/)?.[1] || null;
|
|
144
|
+
fontSerif ||= named(serifVar);
|
|
145
|
+
fontSans ||= named(sansVar);
|
|
146
|
+
fontMono ||= named(monoVar);
|
|
147
|
+
|
|
148
|
+
// When theme tokens are CSS-var indirection (Tailwind @theme), fall back to
|
|
149
|
+
// frequent warm/dark hexes in the stylesheet.
|
|
150
|
+
const hexFallback = inferHexPalette(css);
|
|
151
|
+
const accentResolved = accent || hexFallback.accent;
|
|
152
|
+
const accentStrongResolved = accentStrong || hexFallback.accentStrong || accentResolved;
|
|
153
|
+
const bgResolved = bg || hexFallback.bg;
|
|
154
|
+
const inkResolved = ink || hexFallback.ink;
|
|
155
|
+
|
|
156
|
+
const paletteMode: 'dark' | 'light' =
|
|
157
|
+
bgResolved && luminance(bgResolved) < 0.35
|
|
158
|
+
? 'dark'
|
|
159
|
+
: inkResolved && luminance(inkResolved) > 0.6
|
|
160
|
+
? 'dark'
|
|
161
|
+
: 'light';
|
|
162
|
+
|
|
163
|
+
const hasNoise = /feTurbulence|noiseFilter|fractalNoise|opacity=['"]0\.0[2-5]/.test(css);
|
|
164
|
+
|
|
165
|
+
if (accentResolved) notes.push(`Inspiration accent ${accentResolved}`);
|
|
166
|
+
if (fontSerif || fontSans) notes.push(`Fonts: ${[fontSerif, fontSans, fontMono].filter(Boolean).join(' / ')}`);
|
|
167
|
+
notes.push(`Palette mode: ${paletteMode}`);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
url: finalUrl,
|
|
171
|
+
googleFontHrefs,
|
|
172
|
+
fontSerif,
|
|
173
|
+
fontSans,
|
|
174
|
+
fontMono,
|
|
175
|
+
tokens: {
|
|
176
|
+
accent: accentResolved || undefined,
|
|
177
|
+
accentStrong: accentStrongResolved || undefined,
|
|
178
|
+
bg: bgResolved || undefined,
|
|
179
|
+
ink: inkResolved || undefined,
|
|
180
|
+
inkSoft: inkSoft || undefined,
|
|
181
|
+
surface: surface || hexFallback.surface || undefined,
|
|
182
|
+
rule: rule || undefined,
|
|
183
|
+
ruleStrong: ruleStrong || undefined,
|
|
184
|
+
accentWash: undefined
|
|
185
|
+
},
|
|
186
|
+
paletteMode,
|
|
187
|
+
hasNoise,
|
|
188
|
+
notes
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isWarmAccent(hex: string): boolean {
|
|
193
|
+
const n = parseInt(hex.slice(1), 16);
|
|
194
|
+
const r = (n >> 16) & 255;
|
|
195
|
+
const g = (n >> 8) & 255;
|
|
196
|
+
const b = n & 255;
|
|
197
|
+
return r > 160 && g > 80 && g < 220 && b < 120 && r >= g;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Near-neutral text colors only — never treat gold/amber accents as ink. */
|
|
201
|
+
function isInkCandidate(hex: string): boolean {
|
|
202
|
+
if (isWarmAccent(hex)) return false;
|
|
203
|
+
const n = parseInt(hex.slice(1), 16);
|
|
204
|
+
const r = (n >> 16) & 255;
|
|
205
|
+
const g = (n >> 8) & 255;
|
|
206
|
+
const b = n & 255;
|
|
207
|
+
const max = Math.max(r, g, b);
|
|
208
|
+
const min = Math.min(r, g, b);
|
|
209
|
+
return max - min < 40; // low chroma
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Guess accent/bg/ink from hex frequency when CSS vars don't resolve. */
|
|
213
|
+
export function inferHexPalette(css: string): Partial<DesignBrief['tokens']> {
|
|
214
|
+
const counts = new Map<string, number>();
|
|
215
|
+
for (const m of css.matchAll(/#([0-9a-fA-F]{6})\b/g)) {
|
|
216
|
+
const hex = `#${m[1].toLowerCase()}`;
|
|
217
|
+
if (/^#(000000|ffffff)$/i.test(hex)) continue;
|
|
218
|
+
counts.set(hex, (counts.get(hex) ?? 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
221
|
+
const warm = ranked.find(([h]) => isWarmAccent(h))?.[0];
|
|
222
|
+
const darkBg = ranked.find(([h]) => luminance(h) < 0.2 && !isWarmAccent(h))?.[0];
|
|
223
|
+
const lightInk = ranked.find(([h]) => luminance(h) > 0.75 && isInkCandidate(h))?.[0];
|
|
224
|
+
const darkInk = ranked.find(
|
|
225
|
+
([h]) => luminance(h) < 0.28 && h !== darkBg && isInkCandidate(h)
|
|
226
|
+
)?.[0];
|
|
227
|
+
const out: Partial<DesignBrief['tokens']> = {};
|
|
228
|
+
if (warm) {
|
|
229
|
+
out.accent = warm;
|
|
230
|
+
out.accentStrong = warm;
|
|
231
|
+
}
|
|
232
|
+
if (darkBg) out.bg = darkBg;
|
|
233
|
+
if (darkBg && lightInk) out.ink = lightInk;
|
|
234
|
+
else if (darkInk) out.ink = darkInk;
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pickFont(
|
|
239
|
+
signals: InspirationSignals[],
|
|
240
|
+
key: 'fontSerif' | 'fontSans' | 'fontMono',
|
|
241
|
+
fallback: string
|
|
242
|
+
): string {
|
|
243
|
+
// First inspiration URL wins font roles; later URLs mainly retint accent.
|
|
244
|
+
for (const s of signals) {
|
|
245
|
+
const v = s[key];
|
|
246
|
+
if (v) return v;
|
|
247
|
+
}
|
|
248
|
+
return fallback;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function mergeGoogleHref(signals: InspirationSignals[]): string | null {
|
|
252
|
+
const families = new Set<string>();
|
|
253
|
+
for (const s of signals) {
|
|
254
|
+
for (const href of s.googleFontHrefs) {
|
|
255
|
+
try {
|
|
256
|
+
const u = new URL(href.replace(/&/g, '&'));
|
|
257
|
+
const family = u.searchParams.get('family') || '';
|
|
258
|
+
for (const part of family.split('|')) {
|
|
259
|
+
const name = part.split(':')[0];
|
|
260
|
+
if (name) families.add(name);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
/* skip */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Keep the mix small — display + UI + mono/serif for essays
|
|
268
|
+
const preferred = [
|
|
269
|
+
'Instrument+Serif',
|
|
270
|
+
'Syne',
|
|
271
|
+
'Fraunces',
|
|
272
|
+
'Source+Serif+4',
|
|
273
|
+
'DM+Sans',
|
|
274
|
+
'Outfit',
|
|
275
|
+
'Manrope',
|
|
276
|
+
'JetBrains+Mono'
|
|
277
|
+
];
|
|
278
|
+
const chosen: string[] = [];
|
|
279
|
+
for (const p of preferred) {
|
|
280
|
+
const hit = [...families].find((f) => f.replace(/ /g, '+') === p || f === p.replace(/\+/g, ' '));
|
|
281
|
+
if (hit) chosen.push(hit.includes(':') ? hit : `${hit.replace(/ /g, '+')}:wght@400;500;600;700`);
|
|
282
|
+
if (chosen.length >= 4) break;
|
|
283
|
+
}
|
|
284
|
+
if (!chosen.length && signals[0]?.googleFontHrefs[0]) {
|
|
285
|
+
return signals[0].googleFontHrefs[0].replace(/&/g, '&');
|
|
286
|
+
}
|
|
287
|
+
if (!chosen.length) return null;
|
|
288
|
+
return `https://fonts.googleapis.com/css2?${chosen.map((f) => `family=${f}`).join('&')}&display=swap`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Merge up to 3 inspiration signals into one brief.
|
|
293
|
+
* First URL = structure/mood bias; later URLs contribute accent/fonts/atmosphere.
|
|
294
|
+
*/
|
|
295
|
+
export function briefFromInspiration(signals: InspirationSignals[]): DesignBrief {
|
|
296
|
+
const list = signals.slice(0, 3);
|
|
297
|
+
if (!list.length) {
|
|
298
|
+
return {
|
|
299
|
+
mood: 'Modern editorial personal site',
|
|
300
|
+
do: ['Strong display type', 'Clear accent', 'Atmospheric background'],
|
|
301
|
+
dont: ['Flat purple gradients', 'Marketing stat strips'],
|
|
302
|
+
tokens: {
|
|
303
|
+
accent: '#f0c040',
|
|
304
|
+
accentStrong: '#b8922e',
|
|
305
|
+
bg: '#0a0a0c',
|
|
306
|
+
ink: '#e8e6e3',
|
|
307
|
+
inkSoft: '#9a9898',
|
|
308
|
+
surface: '#16161a',
|
|
309
|
+
rule: '#2a2a33'
|
|
310
|
+
},
|
|
311
|
+
density: 'balanced',
|
|
312
|
+
paletteMode: 'dark',
|
|
313
|
+
fonts: {
|
|
314
|
+
serif: 'Instrument Serif',
|
|
315
|
+
sans: 'DM Sans',
|
|
316
|
+
mono: 'JetBrains Mono',
|
|
317
|
+
googleHref:
|
|
318
|
+
'https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&display=swap'
|
|
319
|
+
},
|
|
320
|
+
hero: 'bold',
|
|
321
|
+
atmosphere: 'noise',
|
|
322
|
+
navStyle: 'uppercase-tracked',
|
|
323
|
+
elevatedCards: true,
|
|
324
|
+
cssNotes: ['Fallback forge-inspired dark theme']
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const primary = list[0];
|
|
329
|
+
const secondary = list[1];
|
|
330
|
+
const tertiary = list[2];
|
|
331
|
+
|
|
332
|
+
// Dark wins if any inspiration is dark (personal sites look sharper that way
|
|
333
|
+
// when mixing a marketing dark site with a product light site).
|
|
334
|
+
const darkCount = list.filter((s) => s.paletteMode === 'dark').length;
|
|
335
|
+
const paletteMode: 'dark' | 'light' = darkCount > 0 ? 'dark' : 'light';
|
|
336
|
+
|
|
337
|
+
// Accent: prefer a later site's accent when it differs (keeps the blend visible).
|
|
338
|
+
const accents = list.map((s) => s.tokens.accent).filter(Boolean) as string[];
|
|
339
|
+
const accent =
|
|
340
|
+
(secondary?.tokens.accent && secondary.tokens.accent !== primary.tokens.accent
|
|
341
|
+
? secondary.tokens.accent
|
|
342
|
+
: null) ||
|
|
343
|
+
accents[0] ||
|
|
344
|
+
(paletteMode === 'dark' ? '#f0c040' : '#1e4d6b');
|
|
345
|
+
const accentStrong =
|
|
346
|
+
secondary?.tokens.accentStrong ||
|
|
347
|
+
primary.tokens.accentStrong ||
|
|
348
|
+
tertiary?.tokens.accentStrong ||
|
|
349
|
+
accent;
|
|
350
|
+
|
|
351
|
+
const bg =
|
|
352
|
+
list.find((s) => s.paletteMode === paletteMode)?.tokens.bg ||
|
|
353
|
+
primary.tokens.bg ||
|
|
354
|
+
(paletteMode === 'dark' ? '#0a0a0c' : '#f7f5f1');
|
|
355
|
+
const ink =
|
|
356
|
+
list.find((s) => s.paletteMode === paletteMode)?.tokens.ink ||
|
|
357
|
+
primary.tokens.ink ||
|
|
358
|
+
(paletteMode === 'dark' ? '#e8e6e3' : '#1a1917');
|
|
359
|
+
const surface =
|
|
360
|
+
list.find((s) => s.tokens.surface)?.tokens.surface ||
|
|
361
|
+
(paletteMode === 'dark' ? '#16161a' : '#ffffff');
|
|
362
|
+
const rule =
|
|
363
|
+
list.find((s) => s.tokens.rule)?.tokens.rule ||
|
|
364
|
+
(paletteMode === 'dark' ? '#2a2a33' : '#e7e2d8');
|
|
365
|
+
const inkSoft =
|
|
366
|
+
list.find((s) => s.tokens.inkSoft)?.tokens.inkSoft ||
|
|
367
|
+
(paletteMode === 'dark' ? '#9a9898' : '#4d4a44');
|
|
368
|
+
|
|
369
|
+
const hasNoise = list.some((s) => s.hasNoise);
|
|
370
|
+
const sources = list.map((s) => s.url).join(' · ');
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
mood:
|
|
374
|
+
list.length > 1
|
|
375
|
+
? `Blend of ${list.length} inspiration sites — ${paletteMode} editorial with a product-grade accent`
|
|
376
|
+
: paletteMode === 'dark'
|
|
377
|
+
? 'Dark modern forge — sharp type, gold accent, atmospheric depth'
|
|
378
|
+
: 'Bright modern editorial with confident display type',
|
|
379
|
+
do: [
|
|
380
|
+
'Blend fonts and accent across inspiration URLs',
|
|
381
|
+
'Punchy display titles with tight tracking',
|
|
382
|
+
'Uppercase tracked nav',
|
|
383
|
+
hasNoise || paletteMode === 'dark' ? 'Subtle noise atmosphere' : 'Clean surfaces'
|
|
384
|
+
],
|
|
385
|
+
dont: [
|
|
386
|
+
'Do not clone multi-section marketing grids onto the essay index',
|
|
387
|
+
'Do not keep the quiet parchment Essay look when inspiration is dark/modern'
|
|
388
|
+
],
|
|
389
|
+
tokens: {
|
|
390
|
+
accent,
|
|
391
|
+
accentStrong,
|
|
392
|
+
bg,
|
|
393
|
+
ink,
|
|
394
|
+
inkSoft,
|
|
395
|
+
surface,
|
|
396
|
+
rule,
|
|
397
|
+
ruleStrong:
|
|
398
|
+
list.find((s) => s.tokens.ruleStrong)?.tokens.ruleStrong ||
|
|
399
|
+
(paletteMode === 'dark' ? '#3d3d4a' : '#d6cfc1'),
|
|
400
|
+
accentWash:
|
|
401
|
+
paletteMode === 'dark' ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : undefined
|
|
402
|
+
},
|
|
403
|
+
density: 'balanced',
|
|
404
|
+
paletteMode,
|
|
405
|
+
fonts: {
|
|
406
|
+
serif: pickFont(list, 'fontSerif', 'Instrument Serif'),
|
|
407
|
+
sans: pickFont(list, 'fontSans', 'DM Sans'),
|
|
408
|
+
mono: pickFont(list, 'fontMono', 'JetBrains Mono'),
|
|
409
|
+
googleHref: mergeGoogleHref(list)
|
|
410
|
+
},
|
|
411
|
+
hero: 'bold',
|
|
412
|
+
atmosphere: hasNoise || paletteMode === 'dark' ? 'noise' : 'none',
|
|
413
|
+
navStyle: 'uppercase-tracked',
|
|
414
|
+
elevatedCards: paletteMode === 'dark',
|
|
415
|
+
cssNotes: [`Blended from: ${sources}`, ...list.flatMap((s) => s.notes)]
|
|
416
|
+
};
|
|
417
|
+
}
|