Link previews for in-page anchors: each /#section link gets its own shot scrolled to that section; nav hash links no longer show the home hero
Changes
BBinary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differBBinary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__contact.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__experience.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__metrics.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__network.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__skills.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home__work.jpg differBBinary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differBBinary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differBBinary files a/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differBBinary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differBBinary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differBBinary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differBBinary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differBBinary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differBBinary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differBBinary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differBBinary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differBBinary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-sites.jpg and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differBBinary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differBBinary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differBBinary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differBBinary files a/public/assets/img/previews/local_portfolio_pytools-workshop.jpg and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differBBinary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differBBinary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differBBinary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differBBinary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differBBinary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differBBinary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differBBinary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differBBinary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differBBinary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differBBinary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differ@@@ -19,6 +19,21 @@ const external = [];const internal = ['/', '/resume/', '/portfolio/', ...readdirSync(`${ROOT}dist/portfolio`).filter((d) => existsSync(`${ROOT}dist/portfolio/${d}/index.html`)).map((d) => `/portfolio/${d}/`)];++// Every in-page anchor the built site links to (nav items, "See the network", skip links...) gets++// its own shot, scrolled to that section, so hovering /#experience previews the Experience++// section rather than the top of the home page.++const hashTargets = new Map(); // "path#hash" -> { path, hash }++const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(`${dir}/${e.name}`) : e.name.endsWith('.html') ? [`${dir}/${e.name}`] : []);++for (const file of walk(`${ROOT}dist`)) {++ const pagePath = file.replace(`${ROOT}dist`, '').replace(/index\.html$/, '') || '/';++ const html = readFileSync(file, 'utf8');++ for (const m of html.matchAll(/href="((?:\/[a-z0-9\-\/]*)?)#([a-z0-9\-]+)"/gi)) {++ const path = m[1] ? (m[1].endsWith('/') ? m[1] : m[1] + '/') : pagePath;++ if (m[2] === 'main' || !existsSync(`${ROOT}dist${path}index.html`)) continue; // #main is the skip link++ hashTargets.set(`${path}#${m[2]}`, { path, hash: m[2] });++ }++}++const server = spawn('python3', ['-m', 'http.server', '4399'], { cwd: `${ROOT}dist`, stdio: 'ignore' });await new Promise((r) => setTimeout(r, 800));@@@ -26,12 +41,22 @@ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome'const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: 'dark',userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' });const map = {};--const shoot = async (url, key, label, file) => {++const shoot = async (url, key, label, file, hash) => {const page = await ctx.newPage();try {await page.addInitScript(() => { try { localStorage.setItem('ov_theme_asked', '1'); } catch {} });await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 }).catch(() => {});await page.waitForTimeout(url.startsWith('http://localhost') ? 2500 : 1500);++ if (hash) {++ const found = await page.evaluate((id) => {++ const el = document.getElementById(id); if (!el) return false;++ const y = el.getBoundingClientRect().top + scrollY - 84;++ if (window.__lenis) window.__lenis.scrollTo(y, { immediate: true }); else scrollTo(0, y);++ return true;++ }, hash);++ if (!found) throw new Error('no #' + hash);++ await page.waitForTimeout(1600); // let reveal animations finish++ }await page.screenshot({ path: `${OUT}/${file}.jpg`, type: 'jpeg', quality: 74, clip: { x: 0, y: 0, width: W, height: H } });map[key] = { img: `/assets/img/previews/${file}.jpg`, label };console.log('ok', key);@@@ -46,6 +71,10 @@ for (const p of internal) {const label = p === '/' ? 'alexfrison.net' : `alexfrison.net${p.replace(/\/$/, '')}`;await shoot(`http://localhost:4399${p}`, `path:${p.replace(/\/$/, '') || '/'}`, label, 'local' + (p.replace(/\/$/, '') || '/home').replace(/\//g, '_'));}++for (const { path, hash } of hashTargets.values()) {++ const short = path.replace(/\/$/, '') || '/';++ await shoot(`http://localhost:4399${path}`, `path:${short}#${hash}`, `alexfrison.net${short === '/' ? '' : short}#${hash}`, 'local' + (short === '/' ? '/home' : short).replace(/\//g, '_') + '__' + hash, hash);++}await browser.close();server.kill();writeFileSync(`${ROOT}src/data/link-previews.json`, JSON.stringify(map, null, 2) + '\n');@@@ -150,5 +150,29 @@"path:/portfolio/youtube-desktop-client": {"img": "/assets/img/previews/local_portfolio_youtube-desktop-client.jpg","label": "alexfrison.net/portfolio/youtube-desktop-client"++ },++ "path:/#network": {++ "img": "/assets/img/previews/local_home__network.jpg",++ "label": "alexfrison.net#network"++ },++ "path:/#work": {++ "img": "/assets/img/previews/local_home__work.jpg",++ "label": "alexfrison.net#work"++ },++ "path:/#experience": {++ "img": "/assets/img/previews/local_home__experience.jpg",++ "label": "alexfrison.net#experience"++ },++ "path:/#skills": {++ "img": "/assets/img/previews/local_home__skills.jpg",++ "label": "alexfrison.net#skills"++ },++ "path:/#contact": {++ "img": "/assets/img/previews/local_home__contact.jpg",++ "label": "alexfrison.net#contact"++ },++ "path:/#metrics": {++ "img": "/assets/img/previews/local_home__metrics.jpg",++ "label": "alexfrison.net#metrics"}}@@@ -383,7 +383,13 @@ const imageUrl = new URL(image, site).toString();const keyFor = (a: HTMLAnchorElement) => {try {const u = new URL(a.href, location.href);-- if (u.origin === location.origin) { const p = u.pathname.replace(/\/$/, '') || '/'; return previews['path:' + p] ? 'path:' + p : null; }++ if (u.origin === location.origin) {++ const p = u.pathname.replace(/\/$/, '') || '/';++ const h = u.hash.replace(/^#/, '');++ if (h && previews['path:' + p + '#' + h]) return 'path:' + p + '#' + h;++ if (h && p === (location.pathname.replace(/\/$/, '') || '/')) return null; // same-page anchor with no shot: nothing useful to show++ return previews['path:' + p] ? 'path:' + p : null;++ }const h = u.host.replace(/^www\./, '');return previews[h] ? h : (h.includes('linkedin') ? 'linkedin.com' : null);} catch { return null; }
Binary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differ
Binary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differ
Binary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differ
Binary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__contact.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__contact.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__experience.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__experience.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__metrics.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__metrics.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__network.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__network.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__skills.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__skills.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home__work.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home__work.jpg differ
Binary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differ
Binary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-sites.jpg and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-sites.jpg and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_pytools-workshop.jpg and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_pytools-workshop.jpg and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
Binary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differ
Binary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differ
Binary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differ
Binary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differ
Binary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differ
Binary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differ
Binary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differ
Binary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differ
Binary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differ
Binary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differ
Binary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differ
Binary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differ
Binary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differ
Binary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differ
Binary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differ
Binary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differ
@@ -19,6 +19,21 @@ const external = [
@@ -19,6 +19,21 @@ const external = [
];
];
const internal = ['/', '/resume/', '/portfolio/', ...readdirSync(`${ROOT}dist/portfolio`).filter((d) => existsSync(`${ROOT}dist/portfolio/${d}/index.html`)).map((d) => `/portfolio/${d}/`)];
const internal = ['/', '/resume/', '/portfolio/', ...readdirSync(`${ROOT}dist/portfolio`).filter((d) => existsSync(`${ROOT}dist/portfolio/${d}/index.html`)).map((d) => `/portfolio/${d}/`)];
+// Every in-page anchor the built site links to (nav items, "See the network", skip links...) gets
+// its own shot, scrolled to that section, so hovering /#experience previews the Experience
+// section rather than the top of the home page.
+const hashTargets = new Map(); // "path#hash" -> { path, hash }
+const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(`${dir}/${e.name}`) : e.name.endsWith('.html') ? [`${dir}/${e.name}`] : []);
+for (const file of walk(`${ROOT}dist`)) {
+ const pagePath = file.replace(`${ROOT}dist`, '').replace(/index\.html$/, '') || '/';
+ const html = readFileSync(file, 'utf8');
+ for (const m of html.matchAll(/href="((?:\/[a-z0-9\-\/]*)?)#([a-z0-9\-]+)"/gi)) {
+ const path = m[1] ? (m[1].endsWith('/') ? m[1] : m[1] + '/') : pagePath;
+ if (m[2] === 'main' || !existsSync(`${ROOT}dist${path}index.html`)) continue; // #main is the skip link
+ hashTargets.set(`${path}#${m[2]}`, { path, hash: m[2] });
+ }
+}
+
const server = spawn('python3', ['-m', 'http.server', '4399'], { cwd: `${ROOT}dist`, stdio: 'ignore' });
const server = spawn('python3', ['-m', 'http.server', '4399'], { cwd: `${ROOT}dist`, stdio: 'ignore' });
await new Promise((r) => setTimeout(r, 800));
await new Promise((r) => setTimeout(r, 800));
@@ -26,12 +41,22 @@ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome'
@@ -26,12 +41,22 @@ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome'
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: 'dark',
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: 'dark',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' });
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' });
const map = {};
const map = {};
-const shoot = async (url, key, label, file) => {
+const shoot = async (url, key, label, file, hash) => {
const page = await ctx.newPage();
const page = await ctx.newPage();
try {
try {
await page.addInitScript(() => { try { localStorage.setItem('ov_theme_asked', '1'); } catch {} });
await page.addInitScript(() => { try { localStorage.setItem('ov_theme_asked', '1'); } catch {} });
await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 }).catch(() => {});
await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 }).catch(() => {});
await page.waitForTimeout(url.startsWith('http://localhost') ? 2500 : 1500);
await page.waitForTimeout(url.startsWith('http://localhost') ? 2500 : 1500);
+ if (hash) {
+ const found = await page.evaluate((id) => {
+ const el = document.getElementById(id); if (!el) return false;
+ const y = el.getBoundingClientRect().top + scrollY - 84;
+ if (window.__lenis) window.__lenis.scrollTo(y, { immediate: true }); else scrollTo(0, y);
+ return true;
+ }, hash);
+ if (!found) throw new Error('no #' + hash);
+ await page.waitForTimeout(1600); // let reveal animations finish
+ }
await page.screenshot({ path: `${OUT}/${file}.jpg`, type: 'jpeg', quality: 74, clip: { x: 0, y: 0, width: W, height: H } });
await page.screenshot({ path: `${OUT}/${file}.jpg`, type: 'jpeg', quality: 74, clip: { x: 0, y: 0, width: W, height: H } });
map[key] = { img: `/assets/img/previews/${file}.jpg`, label };
map[key] = { img: `/assets/img/previews/${file}.jpg`, label };
console.log('ok', key);
console.log('ok', key);
@@ -46,6 +71,10 @@ for (const p of internal) {
@@ -46,6 +71,10 @@ for (const p of internal) {
const label = p === '/' ? 'alexfrison.net' : `alexfrison.net${p.replace(/\/$/, '')}`;
const label = p === '/' ? 'alexfrison.net' : `alexfrison.net${p.replace(/\/$/, '')}`;
await shoot(`http://localhost:4399${p}`, `path:${p.replace(/\/$/, '') || '/'}`, label, 'local' + (p.replace(/\/$/, '') || '/home').replace(/\//g, '_'));
await shoot(`http://localhost:4399${p}`, `path:${p.replace(/\/$/, '') || '/'}`, label, 'local' + (p.replace(/\/$/, '') || '/home').replace(/\//g, '_'));
}
}
+for (const { path, hash } of hashTargets.values()) {
+ const short = path.replace(/\/$/, '') || '/';
+ await shoot(`http://localhost:4399${path}`, `path:${short}#${hash}`, `alexfrison.net${short === '/' ? '' : short}#${hash}`, 'local' + (short === '/' ? '/home' : short).replace(/\//g, '_') + '__' + hash, hash);
+}
await browser.close();
await browser.close();
server.kill();
server.kill();
writeFileSync(`${ROOT}src/data/link-previews.json`, JSON.stringify(map, null, 2) + '\n');
writeFileSync(`${ROOT}src/data/link-previews.json`, JSON.stringify(map, null, 2) + '\n');
@@ -150,5 +150,29 @@
@@ -150,5 +150,29 @@
"path:/portfolio/youtube-desktop-client": {
"path:/portfolio/youtube-desktop-client": {
"img": "/assets/img/previews/local_portfolio_youtube-desktop-client.jpg",
"img": "/assets/img/previews/local_portfolio_youtube-desktop-client.jpg",
"label": "alexfrison.net/portfolio/youtube-desktop-client"
"label": "alexfrison.net/portfolio/youtube-desktop-client"
+ },
+ "path:/#network": {
+ "img": "/assets/img/previews/local_home__network.jpg",
+ "label": "alexfrison.net#network"
+ },
+ "path:/#work": {
+ "img": "/assets/img/previews/local_home__work.jpg",
+ "label": "alexfrison.net#work"
+ },
+ "path:/#experience": {
+ "img": "/assets/img/previews/local_home__experience.jpg",
+ "label": "alexfrison.net#experience"
+ },
+ "path:/#skills": {
+ "img": "/assets/img/previews/local_home__skills.jpg",
+ "label": "alexfrison.net#skills"
+ },
+ "path:/#contact": {
+ "img": "/assets/img/previews/local_home__contact.jpg",
+ "label": "alexfrison.net#contact"
+ },
+ "path:/#metrics": {
+ "img": "/assets/img/previews/local_home__metrics.jpg",
+ "label": "alexfrison.net#metrics"
}
}
}
}
@@ -383,7 +383,13 @@ const imageUrl = new URL(image, site).toString();
@@ -383,7 +383,13 @@ const imageUrl = new URL(image, site).toString();
const keyFor = (a: HTMLAnchorElement) => {
const keyFor = (a: HTMLAnchorElement) => {
try {
try {
const u = new URL(a.href, location.href);
const u = new URL(a.href, location.href);
- if (u.origin === location.origin) { const p = u.pathname.replace(/\/$/, '') || '/'; return previews['path:' + p] ? 'path:' + p : null; }
+ if (u.origin === location.origin) {
+ const p = u.pathname.replace(/\/$/, '') || '/';
+ const h = u.hash.replace(/^#/, '');
+ if (h && previews['path:' + p + '#' + h]) return 'path:' + p + '#' + h;
+ if (h && p === (location.pathname.replace(/\/$/, '') || '/')) return null; // same-page anchor with no shot: nothing useful to show
+ return previews['path:' + p] ? 'path:' + p : null;
+ }
const h = u.host.replace(/^www\./, '');
const h = u.host.replace(/^www\./, '');
return previews[h] ? h : (h.includes('linkedin') ? 'linkedin.com' : null);
return previews[h] ? h : (h.includes('linkedin') ? 'linkedin.com' : null);
} catch { return null; }
} catch { return null; }