text highlight and og image

This commit is contained in:
gosangam
2026-05-06 00:40:24 +05:30
parent 237840148f
commit 0a82a12580
16 changed files with 1611 additions and 890 deletions
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared constants used across the app — book names, CC līlā names, etc.
*/
export const BOOK_NAMES: Record<string, string> = {
bg: "Bhagavad-gītā As It Is",
sb: "Śrīmad-Bhāgavatam",
cc: "Śrī Caitanya-caritāmṛta",
sb1962: "Śrīmad-Bhāgavatam (1962 Edition)",
iso: "Śrī Īśopaniṣad",
noi: "The Nectar of Instruction",
nod: "The Nectar of Devotion",
bs: "Śrī Brahma-saṁhitā",
mm: "Mukunda-mālā-stotra",
nbs: "Nārada-bhakti-sūtra",
kb: "Kṛṣṇa, The Supreme Personality of Godhead",
tlc: "Teachings of Lord Caitanya",
ej: "Easy Journey to Other Planets",
ek: "Elevation to Kṛṣṇa Consciousness",
mg: "Kṛṣṇa Consciousness: The Matchless Gift",
bbd: "Beyond Birth and Death",
owk: "On the Way to Kṛṣṇa",
rv: "Rāja-vidyā: The King of Knowledge",
poy: "The Perfection of Yoga",
pqpa: "Perfect Questions, Perfect Answers",
ttp: "Teachings of Lord Kapila",
ochkm: "On Chanting Hare Kṛṣṇa",
hkh: "The Kṛṣṇa Consciousness Handbook",
lcl: "Life Comes from Life",
lob: "Light of the Bhāgavata",
tlk: "Teachings of Lord Kapila",
tqk: "Teachings of Queen Kuntī",
gg: "Gītār Gān",
mog: "Message of Godhead",
cbhc: "Śrī Caitanya-bhāgavata Commentary",
bk: "Bhagavāner Kathā",
pk: "Prabhupāda-Kṛpā",
ssr: "The Science of Self-Realization",
ds: "Dialectic Spiritualism",
sva: "Songs of the Vaiṣṇava Ācāryas",
va: "Vedānta-sūtra Transcript",
};
export const CC_LILAS: Record<string, string> = {
adi: "Ādi-līlā",
madhya: "Madhya-līlā",
antya: "Antya-līlā",
};
+171
View File
@@ -0,0 +1,171 @@
/**
* OG Image metadata utilities.
*
* Builds the /api/og URL with appropriate query params for dynamic
* Open Graph image generation. Used by page-level metadata exports.
*/
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type OgParams = {
title: string;
detail?: string;
quote?: string;
};
// ---------------------------------------------------------------------------
// Build the /api/og image URL
// ---------------------------------------------------------------------------
/**
* Build a fully-qualified URL to the /api/og image endpoint.
*
* The URL is kept as short as possible — only non-empty params are included.
* The quote is truncated to 260 chars to keep URL length reasonable.
*/
export function buildOgImageUrl(params: OgParams): string {
const url = new URL("/api/og", SITE_URL);
url.searchParams.set("title", params.title);
if (params.detail) {
url.searchParams.set("detail", params.detail);
}
if (params.quote) {
const truncated =
params.quote.length > 260
? params.quote.slice(0, 257) + "..."
: params.quote;
url.searchParams.set("quote", truncated);
}
return url.toString();
}
/**
* Build OG metadata object for use in Next.js page `metadata` exports.
*
* Returns the `openGraph` and `twitter` fields to spread into `Metadata`.
*/
export function buildOgMeta(params: OgParams & { description?: string }) {
const imageUrl = buildOgImageUrl(params);
const description =
params.description ||
params.quote ||
`${params.title}${params.detail ? ` — ${params.detail}` : ""}`;
return {
openGraph: {
title: params.title,
description,
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: params.title,
},
],
siteName: "Prabhupāda Says",
},
twitter: {
card: "summary_large_image" as const,
title: params.title,
description,
images: [imageUrl],
},
};
}
// ---------------------------------------------------------------------------
// Helpers for common page types
// ---------------------------------------------------------------------------
/** OG metadata for a book page. */
export function ogForBook(code: string) {
const title = BOOK_NAMES[code] || code.toUpperCase();
return buildOgMeta({ title });
}
/** OG metadata for a verse page. */
export function ogForVerse(
code: string,
parts: { division?: string; chapter: number; verse: number },
) {
const title = BOOK_NAMES[code] || code.toUpperCase();
let detail: string;
if (code === "sb" && parts.division) {
detail = `Canto ${parts.division}, Chapter ${parts.chapter}, Verse ${parts.verse}`;
} else if (code === "cc" && parts.division) {
const lila = CC_LILAS[parts.division] || parts.division;
detail = `${lila}, Chapter ${parts.chapter}, Verse ${parts.verse}`;
} else {
detail = `Chapter ${parts.chapter}, Verse ${parts.verse}`;
}
return buildOgMeta({ title, detail });
}
/** OG metadata for a prose chapter. */
export function ogForChapter(code: string, chapterTitle: string) {
const bookTitle = BOOK_NAMES[code] || code.toUpperCase();
return buildOgMeta({ title: bookTitle, detail: chapterTitle });
}
/** OG metadata for an essay. */
export function ogForEssay(title: string) {
return buildOgMeta({
title,
detail: "Essay by Śrīla Prabhupāda",
});
}
/** OG metadata for a letter. */
export function ogForLetter(recipient: string, meta?: string) {
return buildOgMeta({
title: `Letter to ${recipient}`,
detail: meta || undefined,
});
}
/** OG metadata for a journal entry. */
export function ogForJournal(journalTitle: string, entryTitle?: string) {
return buildOgMeta({
title: journalTitle,
detail: entryTitle || undefined,
});
}
/** OG metadata for a magazine issue. */
export function ogForMagazine(magazineTitle: string, issueTitle?: string) {
return buildOgMeta({
title: magazineTitle,
detail: issueTitle || undefined,
});
}
/** OG metadata for an other document chapter. */
export function ogForOtherDocument(docTitle: string, chapterTitle?: string) {
return buildOgMeta({
title: docTitle,
detail: chapterTitle || undefined,
});
}
/** Default OG metadata for the site. */
export function ogDefault() {
return buildOgMeta({
title: "Prabhupāda Says",
description:
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
});
}
+199
View File
@@ -0,0 +1,199 @@
// ---------------------------------------------------------------------------
// Source-reference helpers (browser-only)
//
// These functions access `window` and `document` and therefore can only run in
// the browser. They are intentionally NOT marked "use client" because they are
// plain utility functions, not React components or hooks. Any React component
// that calls them must itself be a client component.
// ---------------------------------------------------------------------------
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
import { buildHighlightUrl } from "@/lib/text-highlight";
/**
* Parse the current page URL into a human-readable source reference.
* Returns { title, detail, url } or null if on a non-content page.
*/
export function getSourceReference(): {
title: string;
detail: string | null;
url: string;
} | null {
const url = window.location.href;
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
// /books/bg/1/1 → Bhagavad-gītā As It Is, Chapter 1, Verse 1
if (parts[0] === "books" && parts.length >= 2) {
const code = parts[1];
const bookName = BOOK_NAMES[code] || code.toUpperCase();
// Verse-based: /books/bg/[chapter]/[verse]
if (code === "bg" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// SB: /books/sb/[canto]/[chapter]/[verse]
if (code === "sb" && parts.length >= 5) {
return {
title: bookName,
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC: /books/cc/[lila]/[chapter]/[verse]
if (code === "cc" && parts.length >= 5) {
const lila = CC_LILAS[parts[2]] || parts[2];
return {
title: bookName,
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
url,
};
}
// CC lila index: /books/cc/[lila]
if (code === "cc" && parts.length === 3) {
const lila = CC_LILAS[parts[2]] || parts[2];
return { title: bookName, detail: lila, url };
}
// SB1962: /books/sb1962/[chapter]/[verse]
if (code === "sb1962" && parts.length >= 4) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// ISO/NOI: /books/iso/[mantra]
if ((code === "iso" || code === "noi") && parts.length >= 3) {
const num = parts[2];
const label = code === "iso" ? "Mantra" : "Text";
return { title: bookName, detail: `${label} ${num}`, url };
}
// BS/MM/NBS: /books/{code}/[chapter]/[verse]
if (
(code === "bs" || code === "mm" || code === "nbs") &&
parts.length >= 4
) {
return {
title: bookName,
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
url,
};
}
// KB: /books/kb/[volume]/[slug]
if (code === "kb" && parts.length >= 4) {
return {
title: bookName,
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
url,
};
}
// Generic prose: /books/{code}/[chapter]
if (parts.length >= 3) {
const chSlug = parts[2];
const chNum = parseInt(chSlug, 10);
const detail = isNaN(chNum)
? chSlug.replace(/-/g, " ")
: `Chapter ${chNum}`;
return { title: bookName, detail, url };
}
// Book index page
return { title: bookName, detail: null, url };
}
// /letters/[slug]
if (
parts[0] === "letters" &&
parts.length === 2 &&
parts[1] !== "year" &&
parts[1] !== "recipient" &&
parts[1] !== "location"
) {
// h1 contains "Letter to Mukunda" — use it as the title
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || "Letter";
// The <p> sibling after h1 contains "15 January, 1968 · Calcutta"
const metaP = document.querySelector(
"header p.text-muted-foreground, header p[class*='muted']",
);
const meta = metaP?.textContent?.trim() || null;
return { title, detail: meta, url };
}
// /essays/[slug]
if (parts[0] === "essays" && parts.length === 2) {
const h1 = document.querySelector("h1");
const title = h1?.textContent?.trim() || parts[1].replace(/-/g, " ");
return { title, detail: "Essay by Śrīla Prabhupāda", url };
}
// /journals/[slug]/[chapter]
if (parts[0] === "journals" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/journals/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Journal";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /magazines/[slug]/[issue]
if (parts[0] === "magazines" && parts.length >= 3) {
const backLink = document.querySelector("header a[href^='/magazines/']");
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Magazine";
const h1 = document.querySelector("h1");
const issueTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: issueTitle, url };
}
// /other-documents/[slug]/[chapter]
if (parts[0] === "other-documents" && parts.length >= 3) {
const backLink = document.querySelector(
"header a[href^='/other-documents/']",
);
const parentTitle =
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Document";
const h1 = document.querySelector("h1");
const chapterTitle = h1?.textContent?.trim() || null;
return { title: parentTitle, detail: chapterTitle, url };
}
// /glossary — general
if (parts[0] === "glossary") {
return { title: "Glossary", detail: null, url };
}
// Fallback: try the h1
const h1 = document.querySelector("h1");
if (h1) {
return {
title: h1.textContent?.trim() || "Prabhupāda Says",
detail: null,
url,
};
}
return null;
}
/**
* Format selected text with source attribution appended.
*/
export function formatCopyText(selectedText: string): string {
const ref = getSourceReference();
if (!ref) return selectedText;
const highlightUrl = buildHighlightUrl(selectedText);
let source = `\n\nSource:\n${ref.title}`;
if (ref.detail) source += `\n${ref.detail}`;
source += `\n${highlightUrl}`;
return selectedText + source;
}
+195
View File
@@ -0,0 +1,195 @@
// ---------------------------------------------------------------------------
// Text Fragment Highlight — compact URL encoding for selected text
// ---------------------------------------------------------------------------
/**
* Encode a text selection into a compact URL-safe fragment.
*
* Format: `?hl=<prefix>,<suffix>,<length>`
* - prefix: first 4 words of the selection
* - suffix: last 4 words of the selection (omitted if same as prefix)
* - length: character count of the full selection (for disambiguation)
*
* This keeps URLs short while being specific enough to locate the text.
*/
export function encodeTextFragment(text: string): string {
const words = text.split(/\s+/).filter(Boolean);
if (words.length === 0) return "";
const prefixWords = words.slice(0, 4).join(" ");
const suffixWords = words.length > 6 ? words.slice(-4).join(" ") : "";
const len = text.length;
const parts = [prefixWords];
if (suffixWords) parts.push(suffixWords);
parts.push(String(len));
return parts.join(",");
}
/**
* Decode a text fragment from URL params.
* Returns { prefix, suffix, length } or null if invalid.
*/
export function decodeTextFragment(
hl: string,
): { prefix: string; suffix: string; length: number } | null {
const parts = hl.split(",");
if (parts.length < 2) return null;
const lengthStr = parts[parts.length - 1];
const length = parseInt(lengthStr, 10);
if (isNaN(length)) return null;
if (parts.length === 2) {
// prefix + length only (short selections)
return { prefix: parts[0], suffix: "", length };
}
// prefix + suffix + length
const prefix = parts.slice(0, -2).join(","); // in case prefix itself had commas
const suffix = parts[parts.length - 2];
return { prefix, suffix, length };
}
/**
* Build the full URL with highlight fragment for the selected text.
*/
export function buildHighlightUrl(selectedText: string): string {
const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href;
const url = new URL(window.location.href);
// Remove any existing hl param
url.searchParams.delete("hl");
url.searchParams.set("hl", fragment);
return url.toString();
}
/**
* Find and highlight text in the DOM that matches a decoded text fragment.
* Returns true if found and highlighted.
*/
export function findAndHighlightText(fragment: {
prefix: string;
suffix: string;
length: number;
}): boolean {
// Walk all text nodes in main/article/card-content areas
const contentAreas = document.querySelectorAll(
"main, [data-slot=card-content], article, [data-content-area]",
);
if (contentAreas.length === 0) return false;
const allTextNodes: Text[] = [];
contentAreas.forEach((area) => {
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && node.textContent.trim().length > 0) {
allTextNodes.push(node);
}
}
});
// Build a concatenated string of all text content with node boundaries
const segments: { node: Text; start: number; end: number }[] = [];
let fullText = "";
for (const node of allTextNodes) {
const text = node.textContent || "";
segments.push({
node,
start: fullText.length,
end: fullText.length + text.length,
});
fullText += text;
}
// Normalize whitespace for matching
const normalizedFull = fullText.replace(/\s+/g, " ");
// Search for the prefix in the full text
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ");
let searchFrom = 0;
let matchStart = -1;
while (searchFrom < normalizedFull.length) {
const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
// If we have a suffix, verify it appears near the expected length
if (fragment.suffix) {
const normalizedSuffix = fragment.suffix.replace(/\s+/g, " ");
const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEnd - normalizedSuffix.length - 20,
);
const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20);
const searchWindow = normalizedFull.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStart = idx;
break;
}
} else {
// No suffix — just match prefix + approximate length
matchStart = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStart === -1) return false;
const matchEnd = Math.min(
matchStart + fragment.length,
normalizedFull.length,
);
// Map the match range back to DOM text nodes and wrap with <mark>
for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start);
const overlapEnd = Math.min(matchEnd, seg.end);
if (overlapStart >= overlapEnd) continue;
const node = seg.node;
const text = node.textContent || "";
const localStart = overlapStart - seg.start;
const localEnd = overlapEnd - seg.start;
// Split the text node and wrap the matching portion in a <mark>
const before = text.slice(0, localStart);
const matched = text.slice(localStart, localEnd);
const after = text.slice(localEnd);
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
const mark = document.createElement("mark");
mark.className = "bg-amber-400/30 text-foreground rounded-sm px-0.5";
mark.setAttribute("data-text-highlight", "true");
mark.textContent = matched;
frag.appendChild(mark);
if (after) frag.appendChild(document.createTextNode(after));
parent.replaceChild(frag, node);
}
// Scroll to the first highlight
const firstMark = document.querySelector("[data-text-highlight]");
if (firstMark) {
setTimeout(() => {
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
return true;
}