This post explains how to add a link card styled to match Hugo’s PaperMod theme as a Shortcode for use in posts. It also covers adding a Node.js script that automatically generates link cards from OGP metadata of a given URL, since filling in OGP data manually is tedious.
Environment
- Windows 11 / Git Bash
- Hugo v0.164.0 / Standard build compatible
- PaperMod v8.0 / Latest commit as of writing
- Node v24.18.0
$ hugo version
hugo v0.164.0-ce2470e7012b5ab5fc4e10ebe4027e9f8d9e00dc+extended windows/amd64 BuildDate=2026-07-06T16:39:30Z VendorInfo=gohugoio
$ cd themes/PaperMod && git log -1 --oneline
154d006 (HEAD -> master, origin/master, origin/HEAD) style(post-single): adjust padding for details summary element
$ node -v
v24.18.0
Setup
1. Add the Link Card Shortcode
When styles are written directly in HTML in Hugo, CSS gets duplicated each time the same component is used multiple times on a page. To avoid this, the implementation splits HTML and CSS into separate files.
Add the HTML
Create layouts/_shortcodes/linkCard.html and paste in the following code as-is.
{{- $url := .Get "url" -}}
{{- $title := .Get "title" -}}
{{- $description := .Get "description" -}}
{{- $image := .Get "image" -}}
<div class="link-card">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer">
{{- if $image }}
<div class="link-card-image">
<img src="{{ $image }}" alt="{{ $title }}" loading="lazy">
</div>
{{- end }}
<div class="link-card-content">
<div class="link-card-title">{{ $title }}</div>
{{- if $description }}
<div class="link-card-description">{{ $description }}</div>
{{- end }}
</div>
</a>
</div>
Add the CSS
Create assets/css/extended/linkcard.css and paste in the following code as-is.
.link-card {
margin: 1.5rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
background: var(--entry);
}
.link-card:hover {
border-color: var(--tertiary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .link-card:hover {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}
.link-card a {
display: flex;
height: 150px;
text-decoration: none;
color: var(--primary);
box-shadow: none;
}
.link-card a:hover {
text-decoration: none;
}
.link-card-image {
flex-shrink: 0;
width: 200px;
height: 150px;
overflow: hidden;
background: var(--code-bg);
}
.link-card-image img {
width: 100%;
height: 100%;
object-fit: contain;
margin: 0;
display: block;
}
.link-card-content {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
overflow: hidden;
}
.link-card-title {
font-weight: 600;
font-size: 1.1em;
line-height: 1.4;
color: var(--primary);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-card-description {
color: var(--secondary);
font-size: 0.9em;
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
margin: 0;
}
/* Responsive Design */
@media screen and (max-width: 768px) {
.link-card-image {
width: 120px;
height: 150px;
}
.link-card-content {
padding: 0.75rem;
}
.link-card-title {
font-size: 1em;
}
.link-card-description {
font-size: 0.85em;
}
}
2. Add a Node.js Script to Generate Link Cards from OGP Metadata
1. Initialize the Node.js environment
npm init -y
2. Install required libraries
npm install cheerio
3. Switch the module format to ESM
Add "type": "module" to package.json.
{
"name": "rurigani.com",
"version": "1.0.0",
"type": "module",
"dependencies": {
"cheerio": "^1.0.0"
}
}
4. Add the script
Create scripts/ogp2hugolinkcard.js and paste in the following code as-is.
import * as cheerio from "cheerio";
function esc(s = "") {
return s.replaceAll('"', """).trim();
}
function getMeta($, prop) {
return $(`meta[property='${prop}']`).attr("content") ??
$(`meta[name='${prop}']`).attr("content") ??
"";
}
function absolutize(baseUrl, imgUrl, $) {
if (!imgUrl) return "";
if (/^https?:\/\//i.test(imgUrl)) return imgUrl;
if (/^\/\//.test(imgUrl)) {
const u = new URL(baseUrl);
return `${u.protocol}${imgUrl}`;
}
const baseTag = $("base").attr("href") || "";
try {
const context = baseTag ? new URL(baseTag, baseUrl).href : baseUrl;
return new URL(imgUrl, context).href;
} catch {
try {
return new URL(imgUrl, baseUrl).href;
} catch {
return imgUrl;
}
}
}
function buildShortcode(args) {
const { url, ogTitle, ogDesc, ogImage } = args;
const attrs = [
`url="${esc(url)}"`,
`title="${esc(ogTitle)}"`,
];
if (ogDesc) attrs.push(`description="${esc(ogDesc)}"`);
if (ogImage) attrs.push(`image="${esc(ogImage)}"`);
return `{{< linkCard
\${attrs.join("\n ")}
>}}`;
}
async function main() {
const url = process.argv[2];
if (!url) {
console.error("Usage: node ogp2hugolinkcard.js <URL>");
process.exit(1);
}
try {
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome Safari" },
redirect: "follow",
cache: "no-store",
});
if (!res.ok) {
console.error(`❌ Failed to fetch OGP info: HTTP ${res.status} ${res.statusText}`);
process.exit(1);
}
const html = await res.text();
const $ = cheerio.load(html);
let ogTitle = (getMeta($, "og:title") || $("title").first().text() || "").trim();
if (!ogTitle) console.warn("⚠️ No title found.");
let ogDesc = (getMeta($, "og:description") || getMeta($, "twitter:description") || "").trim();
if (!ogDesc) console.warn("⚠️ No og:description found.");
let ogImage =
getMeta($, "og:image:secure_url") ||
getMeta($, "og:image") ||
getMeta($, "twitter:image") ||
"";
if (!ogImage) {
console.warn("⚠️ No og:image found.");
} else {
ogImage = absolutize(url, ogImage.trim(), $);
}
const output = buildShortcode({ url, ogTitle, ogDesc, ogImage });
console.log(output);
} catch (e) {
console.error("❌ Failed to fetch OGP info:", e?.message ?? String(e));
process.exit(1);
}
}
main();
3. Add a Shorthand Command
Since specifying the full path every time is tedious, add a shorthand command to the scripts section of package.json.
{
"name": "rurigani.com",
"version": "1.0.0",
"type": "module",
"scripts": {
"ogp": "node scripts/ogp2hugolinkcard.js"
},
"dependencies": {
"cheerio": "^1.0.0"
}
}
4. Verify the Link Card
Run the script to generate a link card shortcode.
npm run ogp https://rurigani.com/
Copy the generated shortcode, paste it into a post, start the development server with hugo server, and verify that the link card displays correctly.
It should look like this:
Bonus: Add Usage Instructions to README.md
It’s also handy to document the usage in the project’s README.md.
## Generate LinkCard from a URL (OGP fetch)
```bash
npm run ogp https://rurigani.com/
```