Last active
January 19, 2024 02:37
-
-
Save jakelazaroff/36b9665efe02870576acfc033171d6bf to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import crypto from "node:crypto"; | |
import { dirname, join, isAbsolute, resolve } from "node:path"; | |
import process from "node:process"; | |
import { access, mkdir, readFile, writeFile } from "node:fs/promises"; | |
import ogs from "open-graph-scraper"; | |
const SKIP = new Set(["selfhostedsource.tech"]); | |
const filenames = process.argv.slice(2).filter(path => !path.includes("*")); | |
const files = await Promise.all(filenames.map(f => readFile(f).then(buffer => buffer.toString()))); | |
const hrefs = files | |
.flatMap(file => [...file.matchAll(/\[.+?\]\(<?(.+?)>?\)/g)]) | |
.map(matches => matches[1] || "") | |
.filter(href => /^[^#/]/.test(href)); | |
const BOT_HOSTNAMES = new Set(["twitter.com", "www.nytimes.com", "www.fastcompany.com"]); | |
const BOT_UA = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"; | |
let downloaded = 0; | |
const errors = []; | |
for (const href of progress(hrefs)) { | |
try { | |
const url = new URL(href); | |
if (SKIP.has(url.hostname)) continue; | |
const dir = resolve("./src/content/og", url.hostname); | |
await mkdir(dir, { recursive: true }); | |
const filename = crypto.createHash("md5").update(href).digest("hex"); | |
const filepath = resolve(dir, filename + ".json"); | |
const exists = await access(filepath) | |
.then(() => true) | |
.catch(() => false); | |
if (exists) continue; | |
const headers = new Headers(); | |
headers.set("accept", "text/html"); | |
if (BOT_HOSTNAMES.has(url.hostname)) headers.set("user-agent", BOT_UA); | |
const res = await fetch(url, { headers }); | |
const html = await res.text(); | |
const { result: og } = await ogs({ html }); | |
let title = og.ogTitle ?? ""; | |
let description = og.ogDescription ?? ""; | |
let image = og.ogImage?.[0]?.url || og.twitterImage?.[0]?.url || ""; | |
if (/^\//.test(image)) image = `${url.protocol}//${url.host}${image}`; | |
let favicon = og.favicon ?? ""; | |
if (!/^(?:https?:)?\/\//.test(favicon)) { | |
const result = new URL(url); | |
result.search = ""; | |
const pathname = favicon?.split("?")[0] || "/favicon.ico"; | |
result.pathname = isAbsolute(pathname) ? pathname : join(dirname(result.pathname), pathname); | |
favicon = result.href; | |
} | |
const contents = JSON.stringify({ slug: href, title, description, image, favicon }); | |
writeFile(filepath, contents); | |
downloaded += 1; | |
} catch (e) { | |
errors.push({ href, e }); | |
} | |
} | |
if (downloaded) console.log(`✅`, `Collected ${downloaded} new links!`); | |
for (const { href, e } of errors) { | |
console.error("🟥", href, e); | |
} | |
/** | |
* Given an array or an iterable, prints a progress bar as it's iterated through. | |
* Call `progress` as you iterate through, such as in a `for...of` loop: | |
* | |
* for (const element of progress(array)) { | |
* // do stuff with `element` | |
* } | |
* | |
* @template T | |
* @param {T[]} it An array or iterable to iterate through. | |
* @param {{ filled?: string; empty?: string; width?: number }} options Options controlling the progress bar appearance. | |
* @returns {Generator<T, void, T>} | |
*/ | |
function* progress(it, options = {}) { | |
const { filled = "█", empty = "░", width = 0 } = options; | |
const all = [...it], | |
n = all.length; | |
/** @param {number} i */ | |
function render(i) { | |
const suffix = ` ${i} / ${n}`; | |
const w = (width || process.stdout.columns) - suffix.length; | |
const pct = i / n; | |
// const label = Math.round(pct * 100) + "%"; | |
const bar = "".padEnd(Math.floor(w * pct), filled).padEnd(w, empty); | |
process.stdout.write("\r"); | |
process.stdout.write(bar + suffix + "\x1B[?25l"); | |
} | |
render(0); | |
let i = 0; | |
for (const el of all) { | |
render(i++); | |
yield el; | |
} | |
render(n); | |
process.stdout.write("\x1B[?25h\n"); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
import crypto from "node:crypto"; | |
import { getEntry } from "astro:content"; | |
import Icon from "./Icon.astro"; | |
interface Props { | |
href: string; | |
} | |
const props = Astro.props; | |
const shouldEnrich = /^[^#/]/.test(props.href); | |
let href = "", | |
title = "", | |
description = "", | |
image = "", | |
favicon = ""; | |
if (shouldEnrich) { | |
try { | |
const url = new URL(props.href); | |
const hash = crypto.createHash("md5").update(props.href).digest("hex"); | |
const og = await getEntry("og", `${url.hostname}/${hash}`); | |
href = props.href.slice(url.protocol.length + 2); | |
title = og?.data.title ?? ""; | |
description = og?.data.description ?? ""; | |
image = og?.data.image ?? ""; | |
favicon = og?.data.favicon ?? ""; | |
} catch (e) { | |
console.error(props.href, e); | |
} | |
} | |
--- | |
{/* NOTE: the tooltip starts on the same line to avoid extra whitespace after the link */} | |
<a class="link" {...props}><slot /></a>{ | |
!shouldEnrich ? null : ( | |
<a class="tooltip" data-tooltip href={props.href}> | |
{image ? <img class="thumbnail" src={image} alt="" onerror={"this.remove()"} /> : null} | |
{title ? <span class="title">{title}</span> : null} | |
{description ? <span class="description">{description}</span> : null} | |
<span class="href"> | |
{favicon ? <img class="favicon" src={favicon} alt="" onerror={"this.remove()"} /> : null} | |
<span class="url">{href}</span> | |
<Icon class="arrow" icon="share" /> | |
</span> | |
</a> | |
) | |
}<style> | |
:global(.tippy-box) { | |
transform: translateY(0); | |
} | |
:global(.tippy-box[data-state="hidden"]) { | |
opacity: 0; | |
transform: translateY(20px); | |
} | |
:global(.tippy-svg-arrow > svg) { | |
fill: var(--color-background); | |
} | |
:global(.tippy-box[data-placement^="top"] > .tippy-svg-arrow) { | |
bottom: -6px; | |
} | |
:global(.tippy-box[data-placement^="top"] > .tippy-svg-arrow > svg) { | |
transform: scale(2) rotate(180deg) translateY(-25%); | |
} | |
:global(.tippy-box[data-placement^="bottom"] > .tippy-svg-arrow) { | |
top: -6px; | |
} | |
:global(.tippy-box[data-placement^="bottom"] > .tippy-svg-arrow > svg) { | |
transform: scale(2) translateY(-25%); | |
} | |
.tooltip { | |
pointer-events: all; | |
display: none; | |
background-color: var(--color-background); | |
box-shadow: 0 0.5rem 2rem #00000066; | |
border-radius: var(--radius-large); | |
color: var(--color-text); | |
text-decoration: none; | |
overflow: hidden; | |
} | |
@media (prefers-color-scheme: dark) { | |
.tooltip { | |
box-shadow: 0 0.5rem 2rem #000000; | |
} | |
} | |
.thumbnail { | |
width: 100%; | |
max-height: calc(var(--line-size) * 24); | |
border-top-left-radius: var(--radius-large); | |
border-top-right-radius: var(--radius-large); | |
object-fit: cover; | |
} | |
.title { | |
display: -webkit-box; | |
-webkit-line-clamp: 2; | |
-webkit-box-orient: vertical; | |
padding: var(--line-size) calc(var(--line-size) * 2) 0; | |
font-weight: bold; | |
font-family: var(--font-header); | |
font-variant-ligatures: common-ligatures; | |
font-feature-settings: "salt"; | |
-webkit-line-clamp: 2; | |
overflow: hidden; | |
} | |
.title:last-child { | |
padding-bottom: var(--line-size); | |
} | |
.description { | |
display: -webkit-box; | |
-webkit-line-clamp: 4; | |
-webkit-box-orient: vertical; | |
padding: 0 calc(var(--line-size) * 2); | |
margin-bottom: var(--line-size); | |
font-size: var(--font-size-small); | |
line-height: var(--line-height-small); | |
overflow: hidden; | |
} | |
.description:first-child { | |
padding-top: var(--line-size); | |
} | |
.href { | |
display: flex; | |
align-items: center; | |
gap: 0.5rem; | |
padding: 0 calc(var(--line-size) * 2) var(--line-size); | |
color: var(--color-text-supporting); | |
font-family: var(--font-code); | |
font-size: calc(var(--font-size-small) * 0.9); | |
line-height: calc(var(--line-height-small) * 0.9); | |
} | |
.href:first-child { | |
padding-top: var(--line-size); | |
} | |
.favicon { | |
position: relative; | |
top: 0.0625rem; | |
width: 16px; | |
height: 16px; | |
} | |
.url { | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
} | |
.arrow { | |
flex-shrink: 0; | |
position: relative; | |
top: 0.0625rem; | |
display: block; | |
width: 0.75rem; | |
height: 0.75rem; | |
} | |
</style><script> | |
import tippy, { roundArrow } from "tippy.js"; | |
const links = document.querySelectorAll("a") as NodeListOf<HTMLAnchorElement>; | |
for (const a of links) { | |
const tooltip = a.nextElementSibling as HTMLElement | null; | |
if (tooltip?.dataset.tooltip === undefined) continue; | |
tooltip.style.display = "block"; | |
tippy(a, { | |
content: tooltip, | |
maxWidth: 480, | |
interactive: true, | |
inertia: true, | |
delay: [200, null], | |
arrow: roundArrow, | |
appendTo: () => document.body | |
}); | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
These are gorgeous and super helpful on mobile - thanks for making the source available. Came across it on your recent blog post - we wrote a bit about the dichotomy many years ago and it's been interesting to see how things have evolved in the meantime.